This commit is contained in:
Ladd Hoffman 2023-08-02 14:54:31 -05:00
parent 629274476c
commit 435633a893
35 changed files with 1106 additions and 347 deletions

17
notes/reputation-types.md Normal file
View File

@ -0,0 +1,17 @@
Reputation is comprised of non-fungible tokens associated with specific forum graph post -> author edges.
Therefore in principle, all information about the context of a given rep token can be derived by inspecting the forum graph.
However, in practice, the computational costs and the difficulty of preserving complete records will increase over time.
It is for this reason that we compute the current value of a given rep token and store that value.
Although the value could be recomputed when needed, it would be (unpredictably) expensive and time-consuming to do so.
In its current, singular form, all instances of reputation within a given DAO have equal power, assuming equal numeric value.
However, the question arises: what would it take to support the ability to initiate a validation pool in which the power of a reputation token
depends on something more than just its numeric value?
This would be something specified when a validation pool is initiated.
Suppose we support the notion of distinct types of reputation within a given DAO.
Let's say we have reputation type A and B.
Let's say we have a validation pool that requires reputation type A to vote, and mints reputation type A.
That means governance is separated.

View File

@ -25,7 +25,7 @@ export class Expert extends ReputationHolder {
return 0; return 0;
} }
const authorEdges = authorVertex.getEdges(EdgeTypes.AUTHOR, false); const authorEdges = authorVertex.getEdges(EdgeTypes.AUTHOR, false);
const tokenValues = authorEdges.map(({ data: { tokenId } }) => this.dao.reputation.valueOf(tokenId)); const tokenValues = authorEdges.map(({ data: { tokenAddress } }) => this.dao.reputation.valueOf(tokenAddress));
return tokenValues.reduce((value, total) => total += value, 0); return tokenValues.reduce((value, total) => total += value, 0);
} }
@ -42,7 +42,7 @@ export class Expert extends ReputationHolder {
await this.actions.submitPost.log(this, post); await this.actions.submitPost.log(this, post);
const postId = post.id; const postId = post.id;
const pool = await this.initiateValidationPool({ fee, postId }, params); const pool = await this.initiateValidationPool({ fee, postId }, params);
this.tokens.push(pool.tokenId); this.tokens.push(pool.tokenAddress);
return { postId, pool }; return { postId, pool };
} }
@ -53,7 +53,7 @@ export class Expert extends ReputationHolder {
postId, postId,
fee, fee,
}, params); }, params);
this.tokens.push(pool.tokenId); this.tokens.push(pool.tokenAddress);
return pool; return pool;
} }
@ -68,7 +68,7 @@ export class Expert extends ReputationHolder {
`(${position ? 'for' : 'against'}, stake: ${amount})`, `(${position ? 'for' : 'against'}, stake: ${amount})`,
); );
return validationPool.stake(this.reputationPublicKey, { return validationPool.stake(this.reputationPublicKey, {
position, amount, lockingTime, tokenId: this.tokens[0], position, amount, lockingTime, tokenAddress: this.tokens[0],
}); });
} }
@ -80,7 +80,7 @@ export class Expert extends ReputationHolder {
); );
this.workerId = await this.dao.availability.register(this.reputationPublicKey, { this.workerId = await this.dao.availability.register(this.reputationPublicKey, {
stakeAmount, stakeAmount,
tokenId: this.tokens[0], tokenAddress: this.tokens[0],
duration, duration,
}); });
} }

View File

@ -3,9 +3,9 @@ import { Actor } from '../display/actor.js';
import { CryptoUtil } from '../supporting/crypto.js'; import { CryptoUtil } from '../supporting/crypto.js';
class Worker { class Worker {
constructor(reputationPublicKey, tokenId, stakeAmount, duration) { constructor(reputationPublicKey, tokenAddress, stakeAmount, duration) {
this.reputationPublicKey = reputationPublicKey; this.reputationPublicKey = reputationPublicKey;
this.tokenId = tokenId; this.tokenAddress = tokenAddress;
this.stakeAmount = stakeAmount; this.stakeAmount = stakeAmount;
this.duration = duration; this.duration = duration;
this.available = true; this.available = true;
@ -28,11 +28,11 @@ export class Availability extends Actor {
this.workers = new Map(); this.workers = new Map();
} }
register(reputationPublicKey, { stakeAmount, tokenId, duration }) { register(reputationPublicKey, { stakeAmount, tokenAddress, duration }) {
// TODO: Should be signed by token owner // TODO: Should be signed by token owner
this.dao.reputation.lock(tokenId, stakeAmount, duration); this.dao.reputation.lock(tokenAddress, stakeAmount, duration);
const workerId = CryptoUtil.randomUUID(); const workerId = CryptoUtil.randomUUID();
this.workers.set(workerId, new Worker(reputationPublicKey, tokenId, stakeAmount, duration)); this.workers.set(workerId, new Worker(reputationPublicKey, tokenAddress, stakeAmount, duration));
return workerId; return workerId;
} }

View File

@ -87,7 +87,7 @@ export class Business extends Actor {
}); });
await pool.stake(reputationPublicKey, { await pool.stake(reputationPublicKey, {
tokenId: request.worker.tokenId, tokenAddress: request.worker.tokenAddress,
amount: request.worker.stakeAmount, amount: request.worker.stakeAmount,
position: true, position: true,
}); });

View File

@ -5,6 +5,7 @@ import { Availability } from './availability.js';
import { Business } from './business.js'; import { Business } from './business.js';
import { Voter } from '../supporting/voter.js'; import { Voter } from '../supporting/voter.js';
import { Actor } from '../display/actor.js'; import { Actor } from '../display/actor.js';
import { DEFAULT_REP_TOKEN_TYPE_ID } from '../../util/constants.js';
/** /**
* Purpose: * Purpose:
@ -49,15 +50,13 @@ export class DAO extends Actor {
}); });
} }
getActiveReputation() { /**
* @param {number} param0.reputationTypeId
* @returns {number}
*/
getActiveAvailableReputation({ reputationTypeId = DEFAULT_REP_TOKEN_TYPE_ID }) {
return this.listActiveVoters() return this.listActiveVoters()
.map(({ reputationPublicKey }) => this.reputation.valueOwnedBy(reputationPublicKey)) .map(({ reputationPublicKey }) => this.reputation.availableValueOwnedBy(reputationPublicKey, reputationTypeId))
.reduce((acc, cur) => (acc += cur), 0);
}
getActiveAvailableReputation() {
return this.listActiveVoters()
.map(({ reputationPublicKey }) => this.reputation.availableValueOwnedBy(reputationPublicKey))
.reduce((acc, cur) => (acc += cur), 0); .reduce((acc, cur) => (acc += cur), 0);
} }

View File

@ -15,8 +15,8 @@ class Post extends Actor {
this.forum = forum; this.forum = forum;
this.id = postContent.id ?? name; this.id = postContent.id ?? name;
this.senderId = senderId; this.senderId = senderId;
this.value = 0; this.values = new Map();
this.initialValue = 0; this.initialValues = new Map();
this.authors = postContent.authors; this.authors = postContent.authors;
this.citations = postContent.citations; this.citations = postContent.citations;
this.title = postContent.title; this.title = postContent.title;
@ -83,16 +83,21 @@ export class Forum extends ReputationHolder {
// getContract(type) { } // getContract(type) { }
async onValidate({ async onValidate({
pool, postId, tokenId, referenceChainLimit, leachingValue, pool, postId, tokenAddress, referenceChainLimit, leachingValue,
}) { }) {
console.log('onValidate', { pool, postId, tokenId }); console.log('onValidate', { pool, postId, tokenAddress });
const initialValue = this.dao.reputation.valueOf(tokenId);
// What we have here now is an ERC-1155 rep token, which can contain multiple reputation types.
// ERC-1155 supports a batch transfer operation, so it makes sense to leverage that.
const initialValues = pool.reputationTypeIds
.map((tokenTypeId) => this.dao.reputation.valueOf(tokenAddress, tokenTypeId));
const postVertex = this.graph.getVertex(postId); const postVertex = this.graph.getVertex(postId);
const post = postVertex.data; const post = postVertex.data;
post.setStatus('Validated'); post.setStatus('Validated');
post.initialValue = initialValue; post.initialValues = initialValues;
const addAuthorToGraph = (publicKey, weight, authorTokenId) => { const addAuthorToGraph = (publicKey, weight, authorTokenAddress) => {
// For graph display purposes, we want to use the existing Expert actors from the current scene. // For graph display purposes, we want to use the existing Expert actors from the current scene.
const author = this.scene.findActor(({ reputationPublicKey }) => reputationPublicKey === publicKey); const author = this.scene.findActor(({ reputationPublicKey }) => reputationPublicKey === publicKey);
author.setDisplayValue('reputation', () => author.getReputation()); author.setDisplayValue('reputation', () => author.getReputation());
@ -105,7 +110,7 @@ export class Forum extends ReputationHolder {
postVertex, postVertex,
authorVertex, authorVertex,
weight, weight,
{ tokenId: authorTokenId }, { tokenAddress: authorTokenAddress },
{ hide: author.options.hide }, { hide: author.options.hide },
); );
}; };
@ -114,16 +119,18 @@ export class Forum extends ReputationHolder {
// If no authors are specified, treat the sender as the sole author. // If no authors are specified, treat the sender as the sole author.
// TODO: Verify that cumulative author weight == 1. // TODO: Verify that cumulative author weight == 1.
if (!post.authors?.length) { if (!post.authors?.length) {
addAuthorToGraph(post.senderId, 1, tokenId); addAuthorToGraph(post.senderId, 1, tokenAddress);
} else { } else {
for (const { publicKey, weight } of post.authors) { for (const { publicKey, weight } of post.authors) {
// If the sender is also listed among the authors, do not mint them an additional token. // If the sender is also listed among the authors, do not mint them an additional token.
const authorTokenId = (publicKey === post.senderId) ? tokenId : this.dao.reputation.mint(this.id, 0); const authorTokenAddress = (publicKey === post.senderId)
addAuthorToGraph(publicKey, weight, authorTokenId); ? tokenAddress
: this.dao.reputation.mintBatch(this.id, pool.reputationTypeIds, pool.reputationTypeIds.map(() => 0));
addAuthorToGraph(publicKey, weight, authorTokenAddress);
} }
// If the sender is not an author, they will end up with the minted token but with zero value. // If the sender is not an author, they will end up with the minted token but with zero value.
if (!post.authors.find(({ publicKey }) => publicKey === post.senderId)) { if (!post.authors.find(({ publicKey }) => publicKey === post.senderId)) {
addAuthorToGraph(post.senderId, 0, tokenId); addAuthorToGraph(post.senderId, 0, tokenAddress);
} }
} }
@ -134,7 +141,7 @@ export class Forum extends ReputationHolder {
{ to: postVertex, from: { data: pool } }, { to: postVertex, from: { data: pool } },
{ {
rewardsAccumulator, rewardsAccumulator,
increment: initialValue, increments: initialValues,
referenceChainLimit, referenceChainLimit,
leachingValue, leachingValue,
}, },
@ -142,16 +149,16 @@ export class Forum extends ReputationHolder {
// Apply computed rewards to update values of tokens // Apply computed rewards to update values of tokens
for (const [authorEdge, amount] of rewardsAccumulator) { for (const [authorEdge, amount] of rewardsAccumulator) {
const { to: authorVertex, data: { tokenId: authorTokenId } } = authorEdge; const { to: authorVertex, data: { tokenAddress: authorTokenAddress } } = authorEdge;
const { data: author } = authorVertex; const { data: author } = authorVertex;
// The primary author gets the validation pool minted token. // The primary author gets the validation pool minted token.
// So we don't need to transfer any reputation to the primary author. // So we don't need to transfer any reputation to the primary author.
// Their reward will be the remaining balance after all other transfers. // Their reward will be the remaining balance after all other transfers.
if (authorTokenId !== tokenId) { if (authorTokenAddress !== tokenAddress) {
if (amount < 0) { if (amount < 0) {
this.dao.reputation.transferValueFrom(authorTokenId, tokenId, -amount); this.dao.reputation.transferValueFrom(authorTokenAddress, tokenAddress, -amount);
} else { } else {
this.dao.reputation.transferValueFrom(tokenId, authorTokenId, amount); this.dao.reputation.transferValueFrom(tokenAddress, authorTokenAddress, amount);
} }
await author.computeDisplayValues((label, value) => authorVertex.setProperty(label, value)); await author.computeDisplayValues((label, value) => authorVertex.setProperty(label, value));
authorVertex.displayVertex(); authorVertex.displayVertex();
@ -167,8 +174,8 @@ export class Forum extends ReputationHolder {
for (const authorEdge of postVertex.getEdges(EdgeTypes.AUTHOR, true)) { for (const authorEdge of postVertex.getEdges(EdgeTypes.AUTHOR, true)) {
const authorVertex = authorEdge.to; const authorVertex = authorEdge.to;
const author = authorVertex.data; const author = authorVertex.data;
const { tokenId: authorTokenId } = authorEdge.data; const { tokenAddress: authorTokenAddress } = authorEdge.data;
this.dao.reputation.transfer(this.id, author.reputationPublicKey, authorTokenId); this.dao.reputation.transfer(this.id, author.reputationPublicKey, authorTokenAddress);
} }
} }
@ -178,7 +185,7 @@ export class Forum extends ReputationHolder {
*/ */
async propagateValue(edge, { async propagateValue(edge, {
rewardsAccumulator, rewardsAccumulator,
increment, increments,
depth = 0, depth = 0,
initialNegative = false, initialNegative = false,
referenceChainLimit, referenceChainLimit,
@ -186,7 +193,8 @@ export class Forum extends ReputationHolder {
}) { }) {
const postVertex = edge.to; const postVertex = edge.to;
const post = postVertex.data; const post = postVertex.data;
this.actions.propagate.log(edge.from.data, post, `(${increment})`); const incrementsStr = `(${increments.join(')(')})`;
this.actions.propagate.log(edge.from.data, post, incrementsStr);
if (!!referenceChainLimit && depth > referenceChainLimit) { if (!!referenceChainLimit && depth > referenceChainLimit) {
this.actions.propagate.log( this.actions.propagate.log(
@ -196,7 +204,7 @@ export class Forum extends ReputationHolder {
null, null,
'-x', '-x',
); );
return increment; return increments;
} }
console.log('propagateValue start', { console.log('propagateValue start', {
@ -204,17 +212,20 @@ export class Forum extends ReputationHolder {
to: edge.to.id, to: edge.to.id,
depth, depth,
value: post.value, value: post.value,
increment, increments,
initialNegative, initialNegative,
}); });
const propagate = async (positive) => { const propagate = async (positive) => {
let totalOutboundAmount = 0; const totalOutboundAmounts = increments.map(() => 0);
const citationEdges = postVertex.getEdges(EdgeTypes.CITATION, true) const citationEdges = postVertex.getEdges(EdgeTypes.CITATION, true)
.filter(({ weight }) => (positive ? weight > 0 : weight < 0)); .filter(({ weight }) => (positive ? weight > 0 : weight < 0));
for (const citationEdge of citationEdges) { for (const citationEdge of citationEdges) {
const { weight } = citationEdge; const { weight } = citationEdge;
let outboundAmount = weight * increment; const outboundAmounts = increments.map((increment) => weight * increment);
const refundsFromOutbound = increments.map(() => 0);
for (let idx = 0; idx < outboundAmounts.length; idx++) {
let outboundAmount = outboundAmounts[idx];
if (Math.abs(outboundAmount) > EPSILON) { if (Math.abs(outboundAmount) > EPSILON) {
const balanceToOutbound = this.graph.getEdgeWeight(EdgeTypes.BALANCE, citationEdge.from, citationEdge.to) const balanceToOutbound = this.graph.getEdgeWeight(EdgeTypes.BALANCE, citationEdge.from, citationEdge.to)
?? 0; ?? 0;
@ -228,7 +239,7 @@ export class Forum extends ReputationHolder {
this.actions.propagate.log( this.actions.propagate.log(
citationEdge.from.data, citationEdge.from.data,
{ name: 'Incinerator' }, { name: 'Incinerator' },
`(${increment})`, incrementsStr,
undefined, undefined,
'-x', '-x',
); );
@ -236,7 +247,7 @@ export class Forum extends ReputationHolder {
} }
// Reputation sent to the incinerator is burned! This means it is deducted from the sender, // Reputation sent to the incinerator is burned! This means it is deducted from the sender,
// without increasing the value of any other token. // without increasing the value of any other token.
this.actions.propagate.log(citationEdge.from.data, { name: 'Incinerator' }, `(${increment})`); this.actions.propagate.log(citationEdge.from.data, { name: 'Incinerator' }, incrementsStr);
} else { } else {
// We need to ensure that we at most undo the prior effects of this post // We need to ensure that we at most undo the prior effects of this post
if (initialNegative) { if (initialNegative) {
@ -269,30 +280,36 @@ export class Forum extends ReputationHolder {
citationEdge.to, citationEdge.to,
balanceToOutbound + outboundAmount, balanceToOutbound + outboundAmount,
); );
totalOutboundAmount += outboundAmount; refundsFromOutbound[idx] = refundFromOutbound;
totalOutboundAmounts[idx] += outboundAmount;
}
const refundStr = refundsFromOutbound.map((refund) => displayNumber(refund)).join('/');
this.actions.confirm.log( this.actions.confirm.log(
citationEdge.to.data, citationEdge.to.data,
citationEdge.from.data, citationEdge.from.data,
`(refund: ${displayNumber(refundFromOutbound)}, leach: ${outboundAmount * leachingValue})`, `(refund: ${refundStr}, leach: ${outboundAmount * leachingValue})`,
undefined, undefined,
'-->>', '-->>',
); );
} }
} }
return totalOutboundAmount; return totalOutboundAmounts;
}; };
// First, leach value via negative citations // First, leach value via negative citations
const totalLeachingAmount = await propagate(false); const totalLeachingAmounts = await propagate(false);
increment -= totalLeachingAmount * leachingValue; for (let idx = 0; idx < totalLeachingAmounts.length; idx++) {
increments[idx] -= totalLeachingAmounts[idx] * leachingValue;
}
// Now propagate value via positive citations // Now propagate value via positive citations
const totalDonationAmount = await propagate(true); const totalDonationAmount = await propagate(true);
increment -= totalDonationAmount * leachingValue; for (let idx = 0; idx < totalDonationAmounts.length; idx++) {
increments[idx] -= totalDonationAmounts[idx] * leachingValue;
}
// Apply the remaining increment to the present post // Apply the remaining increment to the present post
const rawNewValue = post.value + increment; const rawNewValues = post.value + increment;
const newValue = Math.max(0, rawNewValue); const newValue = Math.max(0, rawNewValue);
const appliedIncrement = newValue - post.value; const appliedIncrement = newValue - post.value;
const refundToInbound = increment - appliedIncrement; const refundToInbound = increment - appliedIncrement;

View File

@ -2,6 +2,7 @@ import { ReputationHolder } from '../reputation/reputation-holder.js';
import { Stake } from '../supporting/stake.js'; import { Stake } from '../supporting/stake.js';
import { Action } from '../display/action.js'; import { Action } from '../display/action.js';
import { displayNumber } from '../../util/helpers.js'; import { displayNumber } from '../../util/helpers.js';
import { DEFAULT_REP_TOKEN_TYPE_ID } from '../../util/constants.js';
const params = { const params = {
/* Validation Pool parameters */ /* Validation Pool parameters */
@ -51,6 +52,7 @@ export class ValidationPool extends ReputationHolder {
duration, duration,
tokenLossRatio, tokenLossRatio,
contentiousDebate = false, contentiousDebate = false,
reputationTypes,
}, },
name, name,
scene, scene,
@ -69,6 +71,16 @@ export class ValidationPool extends ReputationHolder {
this.actions.initiate.log(fromActor, this, `(fee: ${fee})`); this.actions.initiate.log(fromActor, this, `(fee: ${fee})`);
this.activate(); this.activate();
// Supporting a simplified use case, if the reputation type is not specified let's use a default
this.reputationTypes = reputationTypes ?? [{ reputationTypeId: DEFAULT_REP_TOKEN_TYPE_ID, weight: 1 }];
// Normalize so reputation weights sum to 1
{
const weightTotal = this.reputationTypes.reduce((total, { weight }) => total += weight, 0);
for (const reputationType of this.reputationTypes) {
reputationType.weight /= weightTotal;
}
}
// If contentiousDebate = true, we will follow the progression defined by getTokenLossRatio() // If contentiousDebate = true, we will follow the progression defined by getTokenLossRatio()
if ( if (
!contentiousDebate !contentiousDebate
@ -130,22 +142,34 @@ export class ValidationPool extends ReputationHolder {
this.duration = duration; this.duration = duration;
this.tokenLossRatio = tokenLossRatio; this.tokenLossRatio = tokenLossRatio;
this.contentiousDebate = contentiousDebate; this.contentiousDebate = contentiousDebate;
this.mintedValue = fee * params.mintingRatio();
this.tokenId = this.dao.reputation.mint(this.id, this.mintedValue); const mintTotal = fee * params.mintingRatio();
// Tokens minted "for" the post go toward stake of author voting for their own post. const reputationTypeIds = this.reputationTypes
// Also, author can provide additional stakes, e.g. availability stakes for work evidence post. .map(({ reputationTypeId }) => reputationTypeId);
const mintValues = this.reputationTypes
.map(({ weight }) => mintTotal * weight);
console.log('validation pool constructor', { reputationTypeIds, mintValues });
this.tokenAddress = this.dao.reputation.mintBatch(this.id, reputationTypeIds, mintValues);
this.reputationTypeIds = reputationTypeIds;
// Minted tokens are staked for/against the post at configured ratio
// Each type of reputation is staked in the proportions specified by the `reputationTypes` parameter
for (const { reputationTypeId, weight } of this.reputationTypes) {
this.stake(this.id, { this.stake(this.id, {
position: true, position: true,
amount: this.mintedValue * params.stakeForAuthor, amount: mintTotal * params.stakeForAuthor * weight,
tokenId: this.tokenId, tokenAddress: this.tokenAddress,
reputationTypeId,
}); });
this.stake(this.id, { this.stake(this.id, {
position: false, position: false,
amount: this.mintedValue * (1 - params.stakeForAuthor), amount: this.mintedValue * (1 - params.stakeForAuthor) * weight,
tokenId: this.tokenId, tokenAddress: this.tokenAddress,
reputationTypeId,
}); });
}
this.actions.mint.log(this, this, `(${this.mintedValue})`); this.actions.mint.log(this, this, `(${mintTotal})`);
// Keep a record of voters and their votes // Keep a record of voters and their votes
this.dao.addVoteRecord(reputationPublicKey, this); this.dao.addVoteRecord(reputationPublicKey, this);
@ -174,39 +198,38 @@ export class ValidationPool extends ReputationHolder {
} }
/** /**
* @param {boolean} outcome: null --> all entries. Otherwise filters to position === outcome. * @param {boolean} options.outcome: null --> all entries. Otherwise filters to position === outcome.
* @param {boolean} options.tokenTypeId: null --> all entries. Otherwise filters to the given token type.
* @param {boolean} options.excludeSystem: Whether to exclude votes cast during pool initialization * @param {boolean} options.excludeSystem: Whether to exclude votes cast during pool initialization
* @returns stake[] * @returns stake[]
*/ */
getStakes(outcome, { excludeSystem }) { getStakes({ outcome, tokenTypeId, excludeSystem }) {
return Array.from(this.stakes.values()) return Array.from(this.stakes.values())
.filter(({ tokenId }) => !excludeSystem || tokenId !== this.tokenId) .filter((stake) => tokenTypeId === null || stake.tokenTypeId === tokenTypeId)
.filter(({ tokenAddress }) => !excludeSystem || tokenAddress !== this.tokenAddress)
.filter(({ position }) => outcome === null || position === outcome); .filter(({ position }) => outcome === null || position === outcome);
} }
/** /**
* @param {boolean} outcome: null --> all entries. Otherwise filters to position === outcome. * @param {boolean} options.outcome: null --> all entries. Otherwise filters to position === outcome.
* @returns number * @returns number
*/ */
getTotalStakedOnPost(outcome) { getStakedAmount({ outcome, tokenTypeId }) {
return this.getStakes(outcome, { excludeSystem: false }) return this.getStakes({ outcome, tokenTypeId, excludeSystem: false })
.map((stake) => stake.getStakeValue({ lockingTimeExponent: params.lockingTimeExponent })) .map((stake) => stake.getAmount({ lockingTimeExponent: params.lockingTimeExponent }))
.reduce((acc, cur) => (acc += cur), 0); .reduce((total, amount) => (total += amount), 0);
} }
/** /**
* @param {boolean} outcome: null --> all entries. Otherwise filters to position === outcome. * Stake reputation in favor of a given outcome for this validation pool.
* @returns number *
* @param {*} reputationPublicKey
* @param {object} opts
*/ */
getTotalValueOfStakesForOutcome(outcome) {
return this.getStakes(outcome, { excludeSystem: false })
.reduce((total, { amount }) => (total += amount), 0);
}
// TODO: This can be handled as a hook on receipt of reputation token transfer
async stake(reputationPublicKey, { async stake(reputationPublicKey, {
tokenId, position, amount, lockingTime = 0, tokenAddress, tokenTypeId = DEFAULT_REP_TOKEN_TYPE_ID, position, amount, lockingTime = 0,
}) { }) {
// TODO: This can be handled as a hook on receipt of reputation token transfer
if (this.state === ValidationPoolStates.CLOSED) { if (this.state === ValidationPoolStates.CLOSED) {
throw new Error(`Validation pool ${this.id} is closed.`); throw new Error(`Validation pool ${this.id} is closed.`);
} }
@ -217,17 +240,17 @@ export class ValidationPool extends ReputationHolder {
); );
} }
if (reputationPublicKey !== this.dao.reputation.ownerOf(tokenId)) { if (reputationPublicKey !== this.dao.reputation.ownerOf(tokenAddress)) {
throw new Error('Reputation may only be staked by its owner!'); throw new Error('Reputation may only be staked by its owner!');
} }
const stake = new Stake({ const stake = new Stake({
tokenId, position, amount, lockingTime, tokenAddress, tokenTypeId, position, amount, lockingTime,
}); });
this.stakes.add(stake); this.stakes.add(stake);
// Transfer staked amount from the sender to the validation pool // Transfer staked amount from the sender to the validation pool
this.dao.reputation.transferValueFrom(tokenId, this.tokenId, amount); this.dao.reputation.transferValueFrom(tokenAddress, this.tokenAddress, tokenTypeId, amount);
// Keep a record of voters and their votes // Keep a record of voters and their votes
if (reputationPublicKey !== this.id) { if (reputationPublicKey !== this.id) {
@ -243,8 +266,10 @@ export class ValidationPool extends ReputationHolder {
// Before evaluating the winning conditions, // Before evaluating the winning conditions,
// we need to make sure any staked tokens are locked for the // we need to make sure any staked tokens are locked for the
// specified amounts of time. // specified amounts of time.
for (const { tokenId, amount, lockingTime } of this.stakes.values()) { for (const {
this.dao.reputation.lock(tokenId, amount, lockingTime); tokenAddress, tokenTypeId, amount, lockingTime,
} of this.stakes.values()) {
this.dao.reputation.lock(tokenAddress, tokenTypeId, amount, lockingTime);
// TODO: If there is an exception here, the voter may have voted incorrectly. Consider penalties. // TODO: If there is an exception here, the voter may have voted incorrectly. Consider penalties.
} }
} }
@ -261,23 +286,33 @@ export class ValidationPool extends ReputationHolder {
this.state = ValidationPoolStates.CLOSED; this.state = ValidationPoolStates.CLOSED;
this.setStatus('Closed'); this.setStatus('Closed');
const upvoteValue = this.getTotalValueOfStakesForOutcome(true); // Votes should be scaled by weights of this.reputationTypes
const downvoteValue = this.getTotalValueOfStakesForOutcome(false); const upvoteValue = this.reputationTypes.reduce((value, { reputationTypeId, weight }) => {
const activeAvailableReputation = this.dao.getActiveAvailableReputation(); value += this.getStakedAmount({ outcome: true, reputationTypeId }) * weight;
const votePasses = upvoteValue >= params.winningRatio * downvoteValue; return value;
}, 0);
const downvoteValue = this.reputationTypes.reduce((value, { reputationTypeId, weight }) => {
value += this.getStakedAmount({ outcome: false, reputationTypeId }) * weight;
return value;
}, 0);
const activeAvailableReputation = this.reputationTypes.reduce((value, { reputationTypeId, weight }) => {
value += this.dao.getActiveAvailableReputation({ reputationTypeId }) * weight;
return value;
}, 0);
const outcome = upvoteValue >= params.winningRatio * downvoteValue;
const quorumMet = upvoteValue + downvoteValue >= params.quorum * activeAvailableReputation; const quorumMet = upvoteValue + downvoteValue >= params.quorum * activeAvailableReputation;
const result = { const result = {
votePasses, outcome,
upvoteValue, upvoteValue,
downvoteValue, downvoteValue,
}; };
if (quorumMet) { if (quorumMet) {
this.setStatus(`Resolved - ${votePasses ? 'Won' : 'Lost'}`); this.setStatus(`Resolved - ${outcome ? 'Won' : 'Lost'}`);
this.scene?.sequence.log(`note over ${this.name} : ${votePasses ? 'Win' : 'Lose'}`); this.scene?.sequence.log(`note over ${this.name} : ${outcome ? 'Win' : 'Lose'}`);
this.applyTokenLocking(); this.applyTokenLocking();
await this.distributeReputation({ votePasses }); await this.distributeReputation({ outcome });
// TODO: distribute fees // TODO: distribute fees
} else { } else {
this.setStatus('Resolved - Quorum not met'); this.setStatus('Resolved - Quorum not met');
@ -301,47 +336,57 @@ export class ValidationPool extends ReputationHolder {
return result; return result;
} }
async distributeReputation({ votePasses }) { async distributeReputation({ outcome }) {
// For now we assume a tightly binding pool, where all staked reputation is lost // In a binding validation pool, losing voter stakes are transferred to winning voters.
// TODO: Take tokenLossRatio into account // TODO: Regression tests for different tokenLossRatio values
// TODO: revoke staked reputation from losing voters const tokenLossRatio = this.getTokenLossRatio();
for (const { reputationTypeId, weight } of this.reputationTypes) {
// In a tightly binding validation pool, losing voter stakes are transferred to winning voters. const tokensForWinners = this.getStakedAmount({ outcome: !outcome, reputationTypeId }) * weight * tokenLossRatio;
const tokensForWinners = this.getTotalStakedOnPost(!votePasses); const winningEntries = this.getStakes({ outcome, reputationTypeId, excludeSystem: true });
const winningEntries = this.getStakes(votePasses, { excludeSystem: true }); const totalValueOfStakesForWin = this.getStakedAmount({ outcome, reputationTypeId });
const totalValueOfStakesForWin = this.getTotalValueOfStakesForOutcome(votePasses);
// Compute rewards for the winning voters, in proportion to the value of their stakes. // Compute rewards for the winning voters, in proportion to the value of their stakes.
for (const stake of winningEntries) { for (const stake of winningEntries) {
const { tokenId, amount } = stake; const { tokenAddress, amount } = stake;
const value = stake.getStakeValue({ lockingTimeExponent: params.lockingTimeExponent }); const value = stake.getAmount({ lockingTimeExponent: params.lockingTimeExponent });
const reward = tokensForWinners * (value / totalValueOfStakesForWin); const reward = tokensForWinners * (value / totalValueOfStakesForWin);
// Also return each winning voter their staked amount // Also return each winning voter their staked amount
const reputationPublicKey = this.dao.reputation.ownerOf(tokenId); const reputationPublicKey = this.dao.reputation.ownerOf(tokenAddress);
console.log(`reward for winning stake by ${reputationPublicKey}: ${reward}`); console.log(`reward of type ${reputationTypeId} for winning stake by ${reputationPublicKey}: ${reward}`);
this.dao.reputation.transferValueFrom(this.tokenId, tokenId, reward + amount); this.dao.reputation.transferValueFrom(this.tokenAddress, tokenAddress, reputationTypeId, reward + amount);
const toActor = this.scene?.findActor((actor) => actor.reputationPublicKey === reputationPublicKey); const toActor = this.scene?.findActor((actor) => actor.reputationPublicKey === reputationPublicKey);
this.actions.reward.log(this, toActor, `(${displayNumber(reward)})`); this.actions.reward.log(this, toActor, `(${displayNumber(reward)} type ${reputationTypeId})`);
}
} }
if (votePasses) { if (outcome === true) {
// Distribute awards to author via the forum // Distribute awards to author via the forum
// const tokensForAuthor = this.mintedValue * params.stakeForAuthor + rewards.get(this.tokenId); const tokens = this.reputationTypes.reduce((values, { reputationTypeId }) => {
console.log(`sending reward for author stake to forum: ${this.dao.reputation.valueOf(this.tokenId)}`); const value = this.dao.reputation.valueOf(this.tokenAddress, reputationTypeId) ?? 0;
values[reputationTypeId] = value;
return values;
}, {});
console.log('sending reward for author stake to forum', tokens);
// Transfer ownership of the minted token, from the pool to the forum // Transfer ownership of the minted token, from the pool to the forum
this.dao.reputation.transfer(this.id, this.dao.forum.id, this.tokenId); this.dao.reputation.transfer(this.id, this.dao.forum.id, this.tokenAddress);
// const value = this.dao.reputation.valueOf(this.tokenId); // const value = this.dao.reputation.valueOf(this.tokenAddress);
// this.actions.transfer.log(this, this.dao.forum, `(${value})`); // this.actions.transfer.log(this, this.dao.forum, `(${value})`);
// Recurse through forum to determine reputation effects const result = {
await this.dao.forum.onValidate({
pool: this, pool: this,
postId: this.postId, postId: this.postId,
tokenId: this.tokenId, tokenAddress: this.tokenAddress,
referenceChainLimit: params.referenceChainLimit, referenceChainLimit: params.referenceChainLimit,
leachingValue: params.leachingValue, leachingValue: params.leachingValue,
}); };
// Recurse through forum to determine reputation effects
await this.dao.forum.onValidate({ ...result });
if (this.onValidate) {
await this.onValidate({ ...result });
}
} }
console.log('pool complete'); console.log('pool complete');

View File

@ -89,6 +89,9 @@ export class Actor {
async computeDisplayValues(cb) { async computeDisplayValues(cb) {
for (const [label, fn] of this.valueFunctions.entries()) { for (const [label, fn] of this.valueFunctions.entries()) {
const value = fn(); const value = fn();
console.log('computeDisplay', {
label, value, fn, cb,
});
await this.setDisplayValue(label, value); await this.setDisplayValue(label, value);
if (cb) { if (cb) {
cb(label, value); cb(label, value);

View File

@ -1,5 +1,6 @@
import { DisplayValue } from './display-value.js'; import { DisplayValue } from './display-value.js';
import { randomID } from '../../util/helpers.js'; import { randomID } from '../../util/helpers.js';
import { Rectangle } from './geometry.js';
export class Box { export class Box {
constructor(name, parentEl, options = {}) { constructor(name, parentEl, options = {}) {
@ -20,6 +21,7 @@ export class Box {
parentEl.appendChild(this.el); parentEl.appendChild(this.el);
} }
} }
this.boxes = [];
} }
flex({ center = false } = {}) { flex({ center = false } = {}) {
@ -35,11 +37,6 @@ export class Box {
return this; return this;
} }
hidden() {
this.addClass('hidden');
return this;
}
addClass(className) { addClass(className) {
this.el.classList.add(className); this.el.classList.add(className);
return this; return this;
@ -47,6 +44,7 @@ export class Box {
addBox(name) { addBox(name) {
const box = new Box(name, this.el); const box = new Box(name, this.el);
this.boxes.push(box);
return box; return box;
} }
@ -63,4 +61,16 @@ export class Box {
getId() { getId() {
return this.el.id; return this.el.id;
} }
getGeometry() {
const {
x, y, width, height,
} = this.el.getBoundingClientRect();
return new Rectangle([x, y], [width, height]);
}
move(vector) {
this.el.style.left = `${parseInt(this.el.style.left, 10) + vector[0]}px`;
this.el.style.top = `${parseInt(this.el.style.top, 10) + vector[1]}px`;
}
} }

View File

@ -37,7 +37,6 @@ export class Document extends Box {
} }
get lastElement() { get lastElement() {
if (!this.elements.length) return null;
return this.elements[this.elements.length - 1]; return this.elements[this.elements.length - 1];
} }
} }

View File

@ -0,0 +1,111 @@
import {
DEFAULT_TARGET_RADIUS, DISTANCE_FACTOR, MINIMUM_FORCE, OVERLAP_FORCE, VISCOSITY_FACTOR,
} from '../../util/constants.js';
import { Box } from './box.js';
import { Rectangle, Vector } from './geometry.js';
// Render children with absolute css positioning.
// Let there be a force between elements such that the force between
// any two elements is along the line between their centers,
// so that the elements repel when too close but attract when too far.
// The equilibrium distance can be tuned, e.g. can be scaled by an input.
// NOTE: (with optional overlay preferring a grid or some other shape?),
// NOTE: Could also allow user input dragging elements.
// What might be neat here is to implement a force-based resistance effect;
// basically, the mouse pointer drags the element with a spring rather than directly.
// If the shape of the graph resists the transformation,
// the distance between the element and the cursor should increase.
// On an interval, compute forces among the elements.
// Simulate the effects of these forces
// NOTE: Impart random nudges, and resolve their effects to a user-visible resolution
// before rendering.
// NOTE: When mouse is in our box, we could hijack the scroll actions to zoom in/out.
export class ForceDirectedGraph extends Box {
constructor(name, parentEl, options = {}) {
super(name, parentEl, options);
this.addClass('fixed');
}
addBox(name) {
const box = super.addBox(name);
box.addClass('absolute');
box.el.style.left = '0px';
box.el.style.top = '0px';
box.velocity = Vector.from([0, 0]);
return box;
}
static pairwiseForce(boxA, boxB, targetRadius) {
const rectA = boxA instanceof Rectangle ? boxA : boxA.getGeometry();
const centerA = rectA.center;
const rectB = boxB instanceof Rectangle ? boxB : boxB.getGeometry();
const centerB = rectB.center;
const r = centerB.subtract(centerA);
// Apply a stronger force when overlap occurs
if (rectA.doesOverlap(rectB)) {
// if their centers actually coincide we can just randomize the direction.
if (r.magnitudeSquared === 0) {
return Vector.randomUnitVector(rectA.dim).scale(OVERLAP_FORCE);
}
return r.normalize().scale(OVERLAP_FORCE);
}
// repel if closer than targetRadius
// attract if farther than targetRadius
const force = -DISTANCE_FACTOR * (r.magnitude - targetRadius);
return r.normalize().scale(force);
}
computeEulerFrame(tDelta) {
// Compute all net forces
const netForces = Array.from(Array(this.boxes.length), () => Vector.from([0, 0]));
for (const boxA of this.boxes) {
const idxA = this.boxes.indexOf(boxA);
for (const boxB of this.boxes.slice(idxA + 1)) {
const idxB = this.boxes.indexOf(boxB);
const force = ForceDirectedGraph.pairwiseForce(boxA, boxB, DEFAULT_TARGET_RADIUS);
// Ignore forces below a certain threshold
if (force.magnitude >= MINIMUM_FORCE) {
netForces[idxA] = netForces[idxA].subtract(force);
netForces[idxB] = netForces[idxB].add(force);
}
}
}
// Compute motions
for (const box of this.boxes) {
const idx = this.boxes.indexOf(box);
box.velocity = box.velocity.add(netForces[idx].scale(tDelta));
// Apply some drag
box.velocity = box.velocity.scale(1 - VISCOSITY_FACTOR);
}
for (const box of this.boxes) {
box.move(box.velocity);
}
// TODO: translate everything to keep coordinates positive
const translate = Vector.zeros(2);
for (const box of this.boxes) {
const rect = box.getGeometry();
console.log({ box, rect });
for (const vertex of rect.vertices) {
for (let dim = 0; dim < vertex.dim; dim++) {
translate[dim] = Math.max(translate[dim], -vertex[dim]);
console.log(`vertex[${dim}] = ${vertex[dim]}, translate[${dim}] = ${translate[dim]}`);
}
}
}
for (const box of this.boxes) {
box.move(translate);
}
}
}

View File

@ -19,6 +19,20 @@ export class FormElement extends Box {
} }
} }
export class Select extends FormElement {
constructor(name, form, opts) {
super(name, form, opts);
const { options } = opts;
this.selectEl = document.createElement('select');
for (const { value, label } of options) {
const optionEl = document.createElement('option');
optionEl.setAttribute('value', value);
optionEl.innerHTML = label || value;
}
this.el.appendChild(this.selectEl);
}
}
export class Button extends FormElement { export class Button extends FormElement {
constructor(name, form, opts) { constructor(name, form, opts) {
super(name, form, { ...opts, cbEventTypes: ['click'] }); super(name, form, { ...opts, cbEventTypes: ['click'] });
@ -43,15 +57,18 @@ export class TextField extends FormElement {
constructor(name, form, opts) { constructor(name, form, opts) {
super(name, form, opts); super(name, form, opts);
this.flex({ center: true }); this.flex({ center: true });
this.label = document.createElement('label');
this.labelDiv = document.createElement('div'); // Place label inside a div, for improved styling
this.label.appendChild(this.labelDiv); const labelDiv = document.createElement('div');
this.labelDiv.innerHTML = opts.label || name; labelDiv.innerHTML = opts.label || name;
this.input = document.createElement('input'); const label = document.createElement('label');
this.input.disabled = !!opts.disabled; label.appendChild(labelDiv);
this.input.defaultValue = opts.defaultValue || ''; const input = document.createElement('input');
this.label.appendChild(this.input); input.disabled = !!opts.disabled;
this.el.appendChild(this.label); input.defaultValue = opts.defaultValue || '';
label.appendChild(input);
this.el.appendChild(label);
this.input = input;
} }
get value() { get value() {
@ -61,6 +78,30 @@ export class TextField extends FormElement {
export class TextArea extends FormElement { } export class TextArea extends FormElement { }
export class SubForm extends FormElement {
// Form has:
// this.document = document;
// this.items = [];
// this.id = opts.id ?? `form_${randomID()}`;
// FormElement has
constructor(name, form, opts) {
if (!name) {
name = `subform${randomID()}`;
}
const parentEl = opts.subFormArray ? opts.subFormArray.el : form.el;
const subForm = form.document.form({ name, parentEl, tagName: 'div' }).lastElement;
super(name, form, { ...opts, parentEl });
this.subForm = subForm;
if (opts.subFormArray) {
opts.subFormArray.subForms.push(this);
}
}
get value() {
return this.subForm.value;
}
}
export class SubFormArray extends FormElement { export class SubFormArray extends FormElement {
constructor(name, form, opts) { constructor(name, form, opts) {
super(name, form, opts); super(name, form, opts);
@ -76,36 +117,30 @@ export class SubFormArray extends FormElement {
this.subForms.splice(idx, 1); this.subForms.splice(idx, 1);
subForm.el.remove(); subForm.el.remove();
} }
subForm(opts = {}) {
const subForm = new SubForm(opts.name, this.form, { ...opts, subFormArray: this });
this.subForms.push(subForm);
return this;
} }
export class SubForm extends FormElement { get lastSubForm() {
constructor(name, form, opts) { return this.subForms[this.subForms.length - 1];
const parentEl = opts.subFormArray ? opts.subFormArray.el : form.el;
const subForm = form.document.form({ name, parentEl, tagName: 'div' }).lastElement;
super(name, form, { ...opts, parentEl });
this.subForm = subForm;
if (opts.subFormArray) {
opts.subFormArray.subForms.push(this);
}
}
get value() {
return this.subForm.value;
} }
} }
export class FileInput extends FormElement { export class FileInput extends FormElement {
constructor(name, form, opts) { constructor(name, form, opts) {
super(name, form, opts); super(name, form, opts);
this.input = document.createElement('input'); const input = document.createElement('input');
this.input.type = 'file'; input.type = 'file';
this.input.accept = 'application/json'; input.accept = 'application/json';
this.input.classList.add('visually-hidden'); input.classList.add('visually-hidden');
this.label = document.createElement('label'); const label = document.createElement('label');
this.button = form.button({ name, cb: () => this.input.click() }).lastItem; const button = form.button({ name, cb: () => input.click() }).lastItem;
this.label.appendChild(this.button.el); label.appendChild(button.el);
this.label.appendChild(this.input); label.appendChild(input);
this.el.appendChild(this.label); this.el.appendChild(label);
} }
} }
@ -119,6 +154,11 @@ export class Form extends Box {
this.el.onsubmit = () => false; this.el.onsubmit = () => false;
} }
select(opts) {
this.items.push(new Select(opts.name, this, opts));
return this;
}
button(opts) { button(opts) {
this.items.push(new Button(opts.name, this, opts)); this.items.push(new Button(opts.name, this, opts));
return this; return this;
@ -154,6 +194,10 @@ export class Form extends Box {
return this; return this;
} }
remark(text, opts) {
this.document.remark(text, { ...opts, parentEl: this.el });
}
get lastItem() { get lastItem() {
return this.items[this.items.length - 1]; return this.items[this.items.length - 1];
} }

View File

@ -0,0 +1,100 @@
export class Vector extends Array {
get dim() {
return this.length ?? 0;
}
add(vector) {
if (vector.dim !== this.dim) {
throw new Error('Can only add vectors of the same dimensions');
}
return Vector.from(this.map((q, idx) => q + vector[idx]));
}
subtract(vector) {
if (vector.dim !== this.dim) {
throw new Error('Can only subtract vectors of the same dimensions');
}
return Vector.from(this.map((q, idx) => q - vector[idx]));
}
static unitVector(dim, totalDim) {
return Vector.from(Array(totalDim).map((_, idx) => (idx === dim ? 1 : 0)));
}
get magnitudeSquared() {
return this.reduce((total, q) => total += q ** 2, 0);
}
get magnitude() {
return Math.sqrt(this.magnitudeSquared);
}
scale(factor) {
return Vector.from(this.map((q) => q * factor));
}
normalize() {
return this.scale(1 / this.magnitude);
}
static randomUnitVector(totalDim) {
return Vector.from(Array(totalDim), () => Math.random()).normalize();
}
static zeros(totalDim) {
return Vector.from(Array(totalDim), () => 0);
}
}
export class Polygon {
constructor() {
this.vertices = [];
this.dim = 0;
}
addVertex(point) {
point = point instanceof Vector ? point : Vector.from(point);
if (!this.dim) {
this.dim = point.dim;
} else if (this.dim !== point.dim) {
throw new Error('All vertices of a polygon must have the same dimensionality');
}
this.vertices.push(point);
}
}
export class Rectangle extends Polygon {
constructor(startPoint, dimensions) {
super();
this.startPoint = Vector.from(startPoint);
this.dimensions = Vector.from(dimensions);
// Next point is obtained by moving the specified length along each dimension
// one at a time, then reversing these movements in the same order.
let point = this.startPoint;
for (let dim = 0; dim < dimensions.length; dim++) {
this.addVertex(point);
const increment = Vector.unitVector(dim, dimensions.length);
point = point.add(increment);
}
for (let dim = 0; dim < dimensions.length; dim++) {
this.addVertex(point);
const increment = Vector.unitVector(dim, dimensions.length);
point = point.subtract(increment);
}
}
get center() {
return Vector.from(this.dimensions.map((Q, idx) => this.startPoint[idx] + Q / 2));
}
doesOverlap(rect) {
return this.dimensions.every((_, idx) => {
const thisMin = this.startPoint[idx];
const thisMax = this.startPoint[idx] + this.dimensions[idx];
const thatMin = rect.startPoint[idx];
const thatMax = rect.startPoint[idx] + rect.dimensions[idx];
return (thisMin <= thatMin && thisMax >= thatMin)
|| (thisMin >= thatMin && thisMin <= thatMax);
});
}
}

View File

@ -12,7 +12,7 @@ export class Scene {
constructor(name, rootBox) { constructor(name, rootBox) {
this.name = name; this.name = name;
this.box = rootBox.addBox(name); this.box = rootBox.addBox(name);
this.titleBox = this.box.addBox('Title').setInnerHTML(name); // this.titleBox = this.box.addBox('Title').setInnerHTML(name);
this.box.addBox('Spacer').setInnerHTML('&nbsp;'); this.box.addBox('Spacer').setInnerHTML('&nbsp;');
this.topSection = this.box.addBox('Top section').flex(); this.topSection = this.box.addBox('Top section').flex();
this.displayValuesBox = this.topSection.addBox('Values'); this.displayValuesBox = this.topSection.addBox('Values');

View File

@ -1,56 +1,64 @@
import { ERC721 } from '../supporting/erc721.js'; import { ERC1155 } from '../supporting/erc1155.js';
import { randomID } from '../../util/helpers.js'; import { randomID } from '../../util/helpers.js';
import { EPSILON } from '../../util/constants.js'; import { EPSILON } from '../../util/constants.js';
class Lock { class Lock {
constructor(tokenId, amount, duration) { constructor(tokenAddress, tokenTypeId, amount, duration) {
this.dateCreated = new Date(); this.dateCreated = new Date();
this.tokenId = tokenId; this.tokenAddress = tokenAddress;
this.amount = amount; this.amount = amount;
this.duration = duration; this.duration = duration;
this.tokenTypeId = tokenTypeId;
} }
} }
export class ReputationTokenContract extends ERC721 { export class ReputationTokenContract extends ERC1155 {
constructor() { constructor() {
super('Reputation', 'REP'); super('Reputation', 'REP');
this.histories = new Map(); // token id --> {increment, context (i.e. validation pool id)} this.histories = new Map(); // token address --> {tokenTypeId, increment, context (i.e. validation pool id)}
this.values = new Map(); // token id --> current value this.values = new Map(); // token address --> token type id --> current value
this.locks = new Set(); // {tokenId, amount, start, duration} this.locks = new Set(); // {tokenAddress, tokenTypeId, amount, start, duration}
} }
/** /**
* *
* @param to * @param to Recipient address
* @param value * @param values Object with reputation type id as key, and amount of reputation as value
* @param context
* @returns {string} * @returns {string}
*/ */
mint(to, value, context = {}) { mintBatch(to, tokenTypeIds, values) {
const tokenId = `token_${randomID()}`; const tokenAddress = `token_${randomID()}`;
super.mint(to, tokenId); super.mintBatch(to, tokenAddress, tokenTypeIds, tokenTypeIds.map(() => 1));
this.values.set(tokenId, value); const tokenMap = new Map();
this.histories.set(tokenId, [{ increment: value, context }]); for (let idx = 0; idx < tokenTypeIds.length; idx++) {
return tokenId; const tokenTypeId = tokenTypeIds[idx];
const value = values[idx];
tokenMap.set(tokenTypeId, value);
}
this.values.set(tokenAddress, tokenMap);
this.histories.set(tokenAddress, [{ operation: 'mintBatch', args: { to, tokenTypeIds, values } }]);
return tokenAddress;
} }
incrementValue(tokenId, increment, context) { incrementValue(tokenAddress, tokenTypeId, increment, context) {
const value = this.values.get(tokenId); const tokenTypeIds = this.values.get(tokenAddress);
if (value === undefined) { if (tokenTypeIds === undefined) {
throw new Error(`Token not found: ${tokenId}`); throw new Error(`Token not found: ${tokenAddress}`);
} }
const value = tokenTypeIds?.get(tokenTypeId);
const newValue = value + increment; const newValue = value + increment;
const history = this.histories.get(tokenId) || []; const history = this.histories.get(tokenAddress) || [];
if (newValue < -EPSILON) { if (newValue < -EPSILON) {
throw new Error(`Token value can not become negative. Attempted to set value = ${newValue}`); throw new Error(`Token value can not become negative. Attempted to set value = ${newValue}`);
} }
this.values.set(tokenId, newValue); tokenTypeIds.set(tokenAddress, newValue);
history.push({ increment, context }); this.values.set(tokenAddress, tokenTypeIds);
this.histories.set(tokenId, history); history.push({ tokenTypeId, increment, context });
this.histories.set(tokenAddress, history);
} }
transferValueFrom(fromTokenId, toTokenId, amount) { transferValueFrom(from, to, tokenTypeId, amount) {
if (amount === undefined) { if (amount === undefined) {
throw new Error('Transfer value: amount is undefined!'); throw new Error('Transfer value: amount is undefined!');
} }
@ -60,64 +68,83 @@ export class ReputationTokenContract extends ERC721 {
if (amount < 0) { if (amount < 0) {
throw new Error('Transfer value: amount must be positive'); throw new Error('Transfer value: amount must be positive');
} }
const sourceAvailable = this.availableValueOf(fromTokenId); const sourceAvailable = this.availableValueOf(from, tokenTypeId);
if (sourceAvailable < amount - EPSILON) { if (sourceAvailable < amount - EPSILON) {
throw new Error('Token value transfer: source has insufficient available value. ' throw new Error('Token value transfer: source has insufficient available value. '
+ `Needs ${amount}; has ${sourceAvailable}.`); + `Needs ${amount}; has ${sourceAvailable}.`);
} }
this.incrementValue(fromTokenId, -amount); this.incrementValue(from, tokenTypeId, -amount);
this.incrementValue(toTokenId, amount); this.incrementValue(to, tokenTypeId, amount);
} }
lock(tokenId, amount, duration) { batchTransferValueFrom(from, to, tokenTypeIds, amounts) {
const lock = new Lock(tokenId, amount, duration); for (let idx = 0; idx < tokenTypeIds.length; idx++) {
const tokenTypeId = tokenTypeIds[idx];
const amount = amounts[idx];
if (amount === undefined) {
throw new Error('Transfer value: amount is undefined!');
}
if (amount === 0) {
return;
}
if (amount < 0) {
throw new Error('Transfer value: amount must be positive');
}
const sourceAvailable = this.availableValueOf(from, tokenTypeId);
if (sourceAvailable < amount - EPSILON) {
throw new Error('Token value transfer: source has insufficient available value. '
+ `Needs ${amount}; has ${sourceAvailable}.`);
}
this.incrementValue(from, tokenTypeId, -amount);
this.incrementValue(to, tokenTypeId, amount);
}
}
lock(tokenAddress, tokenTypeId, amount, duration) {
const lock = new Lock(tokenAddress, tokenTypeId, amount, duration);
this.locks.add(lock); this.locks.add(lock);
} }
historyOf(tokenId) { historyOf(tokenAddress) {
return this.histories.get(tokenId); return this.histories.get(tokenAddress);
} }
valueOf(tokenId) { valueOf(tokenAddress, tokenTypeId) {
const value = this.values.get(tokenId); const tokenTypeIds = this.values.get(tokenAddress);
if (value === undefined) { if (tokenTypeIds === undefined) {
throw new Error(`Token not found: ${tokenId}`); throw new Error(`Token not found: ${tokenAddress}`);
} }
return value; return tokenTypeIds.get(tokenTypeId);
} }
availableValueOf(tokenId) { availableValueOf(tokenAddress, tokenTypeId) {
const amountLocked = Array.from(this.locks.values()) const amountLocked = Array.from(this.locks.values())
.filter(({ tokenId: lockTokenId }) => lockTokenId === tokenId) .filter((lock) => lock.tokenAddress === tokenAddress && lock.tokenTypeId === tokenTypeId)
.filter(({ dateCreated, duration }) => new Date() - dateCreated < duration) .filter(({ dateCreated, duration }) => new Date() - dateCreated < duration)
.reduce((total, { amount }) => total += amount, 0); .reduce((total, { amount }) => total += amount, 0);
return this.valueOf(tokenId) - amountLocked; return this.valueOf(tokenAddress, tokenTypeId) - amountLocked;
} }
valueOwnedBy(ownerId) { valueOwnedBy(ownerAddress, tokenTypeId) {
return Array.from(this.owners.entries()) return Array.from(this.owners.entries())
.filter(([__, owner]) => owner === ownerId) .filter(([__, owner]) => owner === ownerAddress)
.map(([tokenId, __]) => this.valueOf(tokenId)) .map(([tokenAddress, __]) => this.valueOf(tokenAddress, tokenTypeId))
.reduce((total, value) => total += value, 0); .reduce((total, value) => total += value, 0);
} }
availableValueOwnedBy(ownerId) { availableValueOwnedBy(ownerAddress, tokenTypeId) {
return Array.from(this.owners.entries()) return Array.from(this.owners.entries())
.filter(([__, owner]) => owner === ownerId) .filter(([__, owner]) => owner === ownerAddress)
.map(([tokenId, __]) => this.availableValueOf(tokenId)) .map(([tokenAddress, __]) => this.availableValueOf(tokenAddress, tokenTypeId))
.reduce((total, value) => total += value, 0); .reduce((total, value) => total += value, 0);
} }
getTotal() { getTotal(tokenTypeId) {
return Array.from(this.values.values()).reduce((total, value) => total += value, 0); return Array.from(this.values.values())
.flatMap((tokens) => tokens.get(tokenTypeId))
.reduce((total, value) => total += value, 0);
} }
getTotalAvailable() { // burn(tokenAddress, tokenTypeId, )
const amountLocked = Array.from(this.locks.values())
.filter(({ dateCreated, duration }) => new Date() - dateCreated < duration)
.reduce((total, { amount }) => total += amount, 0);
return this.getTotal() - amountLocked;
}
} }

View File

@ -75,6 +75,12 @@ export class Edge {
static prepareEditorDocument(graph, doc, from, to) { static prepareEditorDocument(graph, doc, from, to) {
const form = doc.form({ name: 'editorForm' }).lastElement; const form = doc.form({ name: 'editorForm' }).lastElement;
form.button({
name: 'New Vertex',
cb: () => {
graph.resetEditor();
},
});
doc.remark('<h3>Edit Edge</h3>', { parentEl: form.el }); doc.remark('<h3>Edit Edge</h3>', { parentEl: form.el });
form form
.textField({ .textField({
@ -88,7 +94,7 @@ export class Edge {
const subFormArray = form.subFormArray({ id: 'edges', name: 'edges' }).lastItem; const subFormArray = form.subFormArray({ id: 'edges', name: 'edges' }).lastItem;
const addEdgeForm = (edge) => { const addEdgeForm = (edge) => {
const { subForm } = form.subForm({ name: 'subform', subFormArray }).lastItem; const { subForm } = form.subForm({ subFormArray }).lastItem;
subForm.textField({ subForm.textField({
id: 'type', name: 'type', defaultValue: edge.type, required: true, id: 'type', name: 'type', defaultValue: edge.type, required: true,
}) })

View File

@ -0,0 +1,105 @@
/**
* ERC-1155: Multi Token Standard
* See https://eips.ethereum.org/EIPS/eip-1155
* and https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC1155/ERC1155.sol
*
* This implementation is currently incomplete. It lacks the following:
* - Token approvals
* - Operator approvals
* - Emitting events
* - transferFrom
*/
export class ERC1155 {
constructor(name, symbol) {
this.name = name;
this.symbol = symbol;
this.balances = new Map(); // owner address --> token id --> token count
this.owners = new Map(); // token address --> owner address
// this.tokenApprovals = new Map(); // token address --> approved addresses
// this.operatorApprovals = new Map(); // ownerAddress --> operator approvals
this.events = {
// Transfer: (_from, _to, _tokenAddress) => {},
// Approval: (_owner, _approved, _tokenAddress) => {},
// ApprovalForAll: (_owner, _operator, _approved) => {},
};
}
incrementBalance(ownerAddress, tokenTypeId, increment) {
const tokens = this.balances.get(ownerAddress) ?? new Map();
const balance = tokens.get(tokenTypeId) ?? 0;
tokens.set(tokenTypeId, balance + increment);
this.balances.set(ownerAddress, tokens);
}
mintBatch(to, tokenAddress, tokenTypeIds, amounts = null) {
if (!amounts) {
amounts = tokenTypeIds.map(() => 1);
}
console.log('ERC1155.mintBatch', {
to, tokenAddress, tokenTypeIds, amounts,
});
if (this.owners.get(tokenAddress)) {
throw new Error('ERC1155: token already minted');
}
for (let idx = 0; idx < tokenTypeIds.length; idx++) {
const tokenTypeId = tokenTypeIds[idx];
const amount = amounts[idx];
this.incrementBalance(to, tokenTypeId, amount);
}
this.owners.set(tokenAddress, to);
}
burn(tokenAddress, tokenTypeId, amount = 1) {
const ownerAddress = this.owners.get(tokenAddress);
this.incrementBalance(ownerAddress, tokenTypeId, -amount);
}
balanceOf(ownerAddress, tokenTypeId) {
if (!ownerAddress) {
throw new Error('ERC1155: address zero is not a valid owner');
}
const tokens = this.balances.get(ownerAddress) ?? new Map();
return tokens.get(tokenTypeId) ?? 0;
}
ownerOf(tokenAddress) {
const ownerAddress = this.owners.get(tokenAddress);
if (!ownerAddress) {
throw new Error(`ERC1155: invalid token address: ${tokenAddress}`);
}
return ownerAddress;
}
transfer(from, to, tokenAddress) {
console.log('ERC1155.transfer', { from, to, tokenAddress });
const ownerAddress = this.owners.get(tokenAddress);
if (ownerAddress !== from) {
throw new Error(`ERC1155: transfer from incorrect owner ${from}; should be ${ownerAddress}`);
}
this.incrementBalance(from, -1);
this.incrementBalance(to, 1);
this.owners.set(tokenAddress, to);
}
/// @notice Enable or disable approval for a third party ("operator") to manage
/// all of `msg.sender`'s assets
/// @dev Emits the ApprovalForAll event. The contract MUST allow
/// multiple operators per ownerAddress.
/// @param _operator Address to add to the set of authorized operators
/// @param _approved True if the operator is approved, false to revoke approval
// setApprovalForAll(_operator, _approved) {}
/// @notice Get the approved address for a single NFT
/// @dev Throws if `_tokenAddress` is not a valid NFT.
/// @param _tokenAddress The NFT to find the approved address for
/// @return The approved address for this NFT, or the zero address if there is none
// getApproved(_tokenAddress) {}
/// @notice Query if an address is an authorized operator for another address
/// @param _owner The address that owns the NFTs
/// @param _operator The address that acts on behalf of the ownerAddress
/// @return True if `_operator` is an approved operator for `_owner`, false otherwise
// isApprovedForAll(_owner, _operator) {}
}

View File

@ -16,78 +16,78 @@ export class ERC721 {
this.balances = new Map(); // owner address --> token count this.balances = new Map(); // owner address --> token count
this.owners = new Map(); // token id --> owner address this.owners = new Map(); // token id --> owner address
// this.tokenApprovals = new Map(); // token id --> approved addresses // this.tokenApprovals = new Map(); // token id --> approved addresses
// this.operatorApprovals = new Map(); // owner --> operator approvals // this.operatorApprovals = new Map(); // ownerAddress --> operator approvals
this.events = { this.events = {
// Transfer: (_from, _to, _tokenId) => {}, // Transfer: (_from, _to, _tokenAddress) => {},
// Approval: (_owner, _approved, _tokenId) => {}, // Approval: (_owner, _approved, _tokenAddress) => {},
// ApprovalForAll: (_owner, _operator, _approved) => {}, // ApprovalForAll: (_owner, _operator, _approved) => {},
}; };
} }
incrementBalance(owner, increment) { incrementBalance(ownerAddress, increment) {
const balance = this.balances.get(owner) ?? 0; const balance = this.balances.get(ownerAddress) ?? 0;
this.balances.set(owner, balance + increment); this.balances.set(ownerAddress, balance + increment);
} }
mint(to, tokenId) { mint(to, tokenAddress) {
console.log('ERC721.mint', { to, tokenId }); console.log('ERC721.mint', { to, tokenAddress });
if (this.owners.get(tokenId)) { if (this.owners.get(tokenAddress)) {
throw new Error('ERC721: token already minted'); throw new Error('ERC721: token already minted');
} }
this.incrementBalance(to, 1); this.incrementBalance(to, 1);
this.owners.set(tokenId, to); this.owners.set(tokenAddress, to);
} }
burn(tokenId) { burn(tokenAddress) {
const owner = this.owners.get(tokenId); const ownerAddress = this.owners.get(tokenAddress);
this.incrementBalance(owner, -1); this.incrementBalance(ownerAddress, -1);
this.owners.delete(tokenId); this.owners.delete(tokenAddress);
} }
balanceOf(owner) { balanceOf(ownerAddress) {
if (!owner) { if (!ownerAddress) {
throw new Error('ERC721: address zero is not a valid owner'); throw new Error('ERC721: address zero is not a valid owner');
} }
return this.balances.get(owner) ?? 0; return this.balances.get(ownerAddress) ?? 0;
} }
ownerOf(tokenId) { ownerOf(tokenAddress) {
const owner = this.owners.get(tokenId); const ownerAddress = this.owners.get(tokenAddress);
if (!owner) { if (!ownerAddress) {
throw new Error(`ERC721: invalid token ID: ${tokenId}`); throw new Error(`ERC721: invalid token ID: ${tokenAddress}`);
} }
return owner; return ownerAddress;
} }
transfer(from, to, tokenId) { transfer(from, to, tokenAddress) {
console.log('ERC721.transfer', { from, to, tokenId }); console.log('ERC721.transfer', { from, to, tokenAddress });
const owner = this.owners.get(tokenId); const ownerAddress = this.owners.get(tokenAddress);
if (owner !== from) { if (ownerAddress !== from) {
throw new Error(`ERC721: transfer from incorrect owner ${from}; should be ${owner}`); throw new Error(`ERC721: transfer from incorrect owner ${from}; should be ${ownerAddress}`);
} }
this.incrementBalance(from, -1); this.incrementBalance(from, -1);
this.incrementBalance(to, 1); this.incrementBalance(to, 1);
this.owners.set(tokenId, to); this.owners.set(tokenAddress, to);
} }
/// @notice Enable or disable approval for a third party ("operator") to manage /// @notice Enable or disable approval for a third party ("operator") to manage
/// all of `msg.sender`'s assets /// all of `msg.sender`'s assets
/// @dev Emits the ApprovalForAll event. The contract MUST allow /// @dev Emits the ApprovalForAll event. The contract MUST allow
/// multiple operators per owner. /// multiple operators per ownerAddress.
/// @param _operator Address to add to the set of authorized operators /// @param _operator Address to add to the set of authorized operators
/// @param _approved True if the operator is approved, false to revoke approval /// @param _approved True if the operator is approved, false to revoke approval
// setApprovalForAll(_operator, _approved) {} // setApprovalForAll(_operator, _approved) {}
/// @notice Get the approved address for a single NFT /// @notice Get the approved address for a single NFT
/// @dev Throws if `_tokenId` is not a valid NFT. /// @dev Throws if `_tokenAddress` is not a valid NFT.
/// @param _tokenId The NFT to find the approved address for /// @param _tokenAddress The NFT to find the approved address for
/// @return The approved address for this NFT, or the zero address if there is none /// @return The approved address for this NFT, or the zero address if there is none
// getApproved(_tokenId) {} // getApproved(_tokenAddress) {}
/// @notice Query if an address is an authorized operator for another address /// @notice Query if an address is an authorized operator for another address
/// @param _owner The address that owns the NFTs /// @param _owner The address that owns the NFTs
/// @param _operator The address that acts on behalf of the owner /// @param _operator The address that acts on behalf of the ownerAddress
/// @return True if `_operator` is an approved operator for `_owner`, false otherwise /// @return True if `_operator` is an approved operator for `_owner`, false otherwise
// isApprovedForAll(_owner, _operator) {} // isApprovedForAll(_owner, _operator) {}
} }

View File

@ -0,0 +1,123 @@
import { Document } from '../display/document.js';
export const PropertyTypes = {
string: 'string',
number: 'number',
};
class Property {
constructor(name, type) {
this.name = name;
this.type = type;
}
}
class NodeType {
constructor(name) {
this.name = name;
this.properties = new Set();
}
addProperty(name, type) {
this.properties.add(new Property(name, type));
}
}
class EdgeType {
constructor(name, fromNodeTypes, toNodeTypes) {
this.name = name;
this.fromNodeTypes = fromNodeTypes;
this.toNodeTypes = toNodeTypes;
}
}
export class Schema {
constructor() {
this.nodeTypes = new Set();
this.edgeTypes = new Set();
}
addNodeType(name) {
const nodeType = new NodeType(name);
this.nodeTypes.add(nodeType);
}
addEdgeType(name) {
const edgeType = new EdgeType(name);
this.nodeTypes.add(edgeType);
}
}
export class SchemaEditor {
constructor(name, scene, options = {}) {
this.name = name;
this.scene = scene;
this.options = options;
this.schemaEditorBox = scene.middleSection.addBox('Schema Editor').flex();
this.nodeEditorDoc = new Document('NodeSchemaEditor', this.schemaEditorBox.el);
this.edgeEditorDoc = new Document('EdgeSchemaEditor', this.schemaEditorBox.el);
this.prepareNodeEditorDocument();
this.prepareEdgeEditorDocument();
}
prepareNodeEditorDocument(schema = new Schema()) {
const doc = this.nodeEditorDoc;
doc.clear();
doc.remark('<h2>Node Types</h2>');
const form = doc.form({ name: 'Node Types Editor' }).lastElement;
const nodeTypesSubFormArray = form.subFormArray({ name: 'nodeTypes' }).lastItem;
const addNodeForm = (name, properties) => {
const nodeTypeForm = nodeTypesSubFormArray.subForm().lastSubForm;
if (name) {
nodeTypeForm.remark(`<h3>Node Type: ${name}</h3>`);
} else {
nodeTypeForm.remark('<h3>New Node Type</h3>');
}
nodeTypeForm.textField({ name: 'name', defaultValue: name });
const propertiesSubFormArray = nodeTypeForm.subFormArray({ name: 'properties' }).lastItem;
for (const property of properties.values()) {
const propertyForm = propertiesSubFormArray.subForm().lastSubForm;
propertyForm.textField({ name: 'name', defaultValue: property.name });
propertyForm.textField({ name: 'type', defaultValue: property.type });
}
};
for (const { name, properties } of schema.nodeTypes.values()) {
addNodeForm(name, properties);
}
form.button({
name: 'Add Node Type',
cb: () => {
addNodeForm('', new Set());
},
});
form.submit({
name: 'Save',
cb: ({ form: { value: formValue } }) => {
console.log('save', { formValue });
},
});
}
prepareEdgeEditorDocument(schema = new Schema()) {
const doc = this.edgeEditorDoc;
doc.clear();
doc.remark('<h2>Edge Types</h2>');
const form = doc.form('Edge Types Editor').lastElement;
for (const { name } of schema.edgeTypes.values()) {
form.remark(`<h3>Edge Type: ${name}</h3>`);
form.textField({ name: 'name', defaultValue: name });
}
form.submit({
name: 'Save',
});
}
}
// Properties
// Data types
// Relationships

View File

@ -1,14 +1,15 @@
export class Stake { export class Stake {
constructor({ constructor({
tokenId, position, amount, lockingTime, tokenAddress, tokenTypeId, position, amount, lockingTime,
}) { }) {
this.tokenId = tokenId; this.tokenAddress = tokenAddress;
this.position = position; this.position = position;
this.amount = amount; this.amount = amount;
this.lockingTime = lockingTime; this.lockingTime = lockingTime;
this.tokenTypeId = tokenTypeId;
} }
getStakeValue({ lockingTimeExponent } = {}) { getAmount({ lockingTimeExponent } = {}) {
return this.amount * this.lockingTime ** lockingTimeExponent; return this.amount * this.lockingTime ** lockingTimeExponent;
} }
} }

View File

@ -15,7 +15,7 @@ export class Vertex {
to: [], to: [],
}; };
this.installedClickCallback = false; this.installedClickCallback = false;
this.properties = new Map(); this.properties = options.properties ?? new Map();
} }
toJSON() { toJSON() {
@ -51,14 +51,18 @@ export class Vertex {
} }
let html = ''; let html = '';
html += `${this.label}`; if (this.type) {
html += `<span class='small'>${this.type}</span>`;
}
html += `${this.label || this.id}`;
html += '<table>'; html += '<table>';
console.log('displayVertex', { properties: this.properties });
for (const [key, value] of this.properties.entries()) { for (const [key, value] of this.properties.entries()) {
const displayValue = typeof value === 'number' ? displayNumber(value) : value; const displayValue = typeof value === 'number' ? displayNumber(value) : value;
html += `<tr><td>${key}</td><td>${displayValue}</td></tr>`; html += `<tr><td>${key}</td><td>${displayValue}</td></tr>`;
} }
html += '</table>'; html += '</table>';
if (this.id !== this.label) { if (this.label && this.id !== this.label) {
html += `<span class=small>${this.id}</span><br>`; html += `<span class=small>${this.id}</span><br>`;
} }
html = html.replaceAll(/\n\s*/g, ''); html = html.replaceAll(/\n\s*/g, '');
@ -73,26 +77,34 @@ export class Vertex {
static prepareEditorDocument(graph, doc, vertexId) { static prepareEditorDocument(graph, doc, vertexId) {
const vertex = vertexId ? graph.getVertex(vertexId) : undefined; const vertex = vertexId ? graph.getVertex(vertexId) : undefined;
const form = doc.form().lastElement; const form = doc.form().lastElement;
if (vertex) {
form.button({
name: 'New Vertex',
cb: () => {
graph.resetEditor();
},
});
}
doc.remark(`<h3>${vertex ? 'Edit' : 'Add'} Vertex</h3>`, { parentEl: form.el }); doc.remark(`<h3>${vertex ? 'Edit' : 'Add'} Vertex</h3>`, { parentEl: form.el });
form form
.textField({ .textField({
id: 'id', name: 'id', defaultValue: vertex?.id, name: 'id', defaultValue: vertex?.id,
}) })
.textField({ id: 'type', name: 'type', defaultValue: vertex?.type }) .textField({ name: 'type', defaultValue: vertex?.type })
.textField({ id: 'label', name: 'label', defaultValue: vertex?.label }); .textField({ name: 'label', defaultValue: vertex?.label });
doc.remark('<h4>Properties</h4>', { parentEl: form.el }); doc.remark('<h4>Properties</h4>', { parentEl: form.el });
const subFormArray = form.subFormArray({ id: 'properties', name: 'properties' }).lastItem; const subFormArray = form.subFormArray({ name: 'properties' }).lastItem;
const addPropertyForm = (key, value) => { const addPropertyForm = (key, value) => {
const { subForm } = form.subForm({ name: 'subform', subFormArray }).lastItem; const { subForm } = form.subForm({ subFormArray }).lastItem;
subForm.textField({ id: 'key', name: 'key', defaultValue: key }) subForm.textField({ name: 'key', defaultValue: key })
.textField({ id: 'value', name: 'value', defaultValue: value }) .textField({ name: 'value', defaultValue: value })
.button({ .button({
id: 'remove',
name: 'Remove Property', name: 'Remove Property',
cb: () => subFormArray.remove(subForm), cb: () => subFormArray.remove(subForm),
}); })
doc.remark('<br>', { parentEl: subForm.el }); .remark('<br>');
}; };
if (vertex) { if (vertex) {
@ -102,13 +114,11 @@ export class Vertex {
} }
form.button({ form.button({
id: 'add',
name: 'Add Property', name: 'Add Property',
cb: () => addPropertyForm('', ''), cb: () => addPropertyForm('', ''),
}); });
form.submit({ form.submit({
id: 'save',
name: 'Save', name: 'Save',
cb: ({ form: { value: formValue } }) => { cb: ({ form: { value: formValue } }) => {
let fullRedraw = false; let fullRedraw = false;
@ -121,7 +131,10 @@ export class Vertex {
Object.assign(vertex, formValue); Object.assign(vertex, formValue);
vertex.displayVertex(); vertex.displayVertex();
} else { } else {
const newVertex = graph.addVertex(formValue.type, formValue.id, null, formValue.label); const {
type, id, label, properties,
} = formValue;
const newVertex = graph.addVertex(type, id, null, label, { properties });
Object.assign(newVertex, formValue); Object.assign(newVertex, formValue);
doc.clear(); doc.clear();
Vertex.prepareEditorDocument(graph, doc, newVertex.id); Vertex.prepareEditorDocument(graph, doc, newVertex.id);
@ -134,7 +147,6 @@ export class Vertex {
if (vertex) { if (vertex) {
form.button({ form.button({
id: 'delete',
name: 'Delete Vertex', name: 'Delete Vertex',
cb: () => { cb: () => {
graph.deleteVertex(vertex.id); graph.deleteVertex(vertex.id);
@ -159,7 +171,6 @@ export class Vertex {
} }
form.button({ form.button({
id: 'cancel',
name: 'Cancel', name: 'Cancel',
cb: () => graph.resetEditor(), cb: () => graph.resetEditor(),
parentEl: doc.el, parentEl: doc.el,

View File

@ -131,9 +131,10 @@ export class WeightedDirectedGraph {
const form = this.controlDoc.form({ name: 'controlForm' }).lastElement; const form = this.controlDoc.form({ name: 'controlForm' }).lastElement;
const { subForm: graphPropertiesForm } = form.subForm({ name: 'graphPropsForm' }).lastItem; const { subForm: graphPropertiesForm } = form.subForm({ name: 'graphPropsForm' }).lastItem;
graphPropertiesForm.flex() graphPropertiesForm.flex()
.textField({ name: 'name', label: 'Graph name', defaultValue: this.name }) .textField({
.submit({ name: 'name',
name: 'Save', label: 'Graph name',
defaultValue: this.name,
cb: (({ form: { value: { name } } }) => { cb: (({ form: { value: { name } } }) => {
this.name = name; this.name = name;
}), }),

View File

@ -50,6 +50,12 @@ a:visited {
left: 12em; left: 12em;
top: -0.5em; top: -0.5em;
} }
.fixed {
position: fixed;
}
.absolute {
position: absolute;
}
svg { svg {
width: 800px; width: 800px;
} }

View File

@ -15,18 +15,19 @@
For more information please see the <a href="https://daogovernanceframework.com/wiki/DAO_Governance_Framework">DGF For more information please see the <a href="https://daogovernanceframework.com/wiki/DAO_Governance_Framework">DGF
Wiki</a>. Wiki</a>.
</p> </p>
<p>
The code for this site is available in <a
href="https://gitlab.com/dao-governance-framework/science-publishing-dao/-/tree/main/forum-network/src">GitLab</a>.
</p>
<h2>Tools</h2> <h2>Tools</h2>
<ul> <ul>
<li><a href="./wdg-editor">Weighted Directed Graph Editor</a></li> <li><a href="./wdg-editor">Weighted Directed Graph Editor</a></li>
<li><a href="./schema-editor">Schema Editor</a></li>
</ul> </ul>
<h2>Example Scenarios</h2> <h2>Example Scenarios</h2>
<p> <p>
Below are example scenarios with various assertions covering features of our reputation system. Below are example scenarios with various assertions covering features of our reputation system.
</p> </p>
<p>
The code for this site is available in <a
href="https://gitlab.com/dao-governance-framework/science-publishing-dao/-/tree/main/forum-network/src">GitLab</a>.
</p>
<ul> <ul>
<li><a href="./tests/validation-pool.test.html">Validation Pool</a></li> <li><a href="./tests/validation-pool.test.html">Validation Pool</a></li>
<li><a href="./tests/availability.test.html">Availability + Business</a></li> <li><a href="./tests/availability.test.html">Availability + Business</a></li>
@ -66,6 +67,7 @@
<li><a href="./tests/mocha.test.html">Mocha</a></li> <li><a href="./tests/mocha.test.html">Mocha</a></li>
<li><a href="./tests/input.test.html">Input</a></li> <li><a href="./tests/input.test.html">Input</a></li>
<li><a href="./tests/document.test.html">Document</a></li> <li><a href="./tests/document.test.html">Document</a></li>
<li><a href="./tests/force-directed.test.html">Force-Directed Graph</a></li>
</ul> </ul>
<ul> <ul>
<h4><a href="./tests/all.test.html">All</a></h4> <h4><a href="./tests/all.test.html">All</a></h4>

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<head>
<title>Schema Editor</title>
<link type="text/css" rel="stylesheet" href="../index.css" />
<script type="module" src="./index.js"></script>
<script defer data-domain="dgov.io" src="https://plausible.io/js/script.js"></script>
</head>
<body>
<h2><a href="../">DGF Prototype</a></h2>
<h1>Schema Editor</h1>
<div id="scene"></div>
</body>

View File

@ -0,0 +1,9 @@
import { Box } from '../classes/display/box.js';
import { Scene } from '../classes/display/scene.js';
import { SchemaEditor } from '../classes/supporting/schema.js';
const rootElement = document.getElementById('scene');
const rootBox = new Box('rootBox', rootElement).flex();
window.disableSceneControls = true;
window.scene = new Scene('Schema Editor', rootBox);
window.schemaEditor = new SchemaEditor('new', window.scene);

View File

@ -40,6 +40,7 @@
<script type="module" src="./scripts/forum/forum11.test.js"></script> <script type="module" src="./scripts/forum/forum11.test.js"></script>
<script type="module" src="./scripts/input.test.js"></script> <script type="module" src="./scripts/input.test.js"></script>
<script type="module" src="./scripts/document.test.js"></script> <script type="module" src="./scripts/document.test.js"></script>
<script type="module" src="./scripts/force-directed.test.js"></script>
<script defer class="mocha-init"> <script defer class="mocha-init">
mocha.setup({ mocha.setup({
ui: 'bdd', ui: 'bdd',

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<head>
<title>Force-Directed Graph test</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://unpkg.com/mocha/mocha.css" />
<link type="text/css" rel="stylesheet" href="../index.css" />
<script defer data-domain="dgov.io" src="https://plausible.io/js/script.js"></script>
</head>
<body>
<h2><a href="../">DGF Prototype</a></h2>
<div id="mocha"></div>
<div id="scene"></div>
</body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/radash/10.7.0/radash.js"
integrity="sha512-S207zKWG3iqXqe6msO7/Mr8X3DzzF4u8meFlokHjGtBPTGUhgzVo0lpcqEy0GoiMUdcoct+H+SqzoLsxXbynzg=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://unpkg.com/chai/chai.js"></script>
<script src="https://unpkg.com/mocha/mocha.js"></script>
<script defer class="mocha-init">
mocha.setup({
ui: 'bdd',
});
chai.should();
</script>
<script type="module" src="./scripts/force-directed.test.js"></script>

View File

@ -6,6 +6,7 @@ import { Public } from '../../classes/actors/public.js';
import { PostContent } from '../../classes/supporting/post-content.js'; import { PostContent } from '../../classes/supporting/post-content.js';
import { delayOrWait } from '../../classes/display/scene-controls.js'; import { delayOrWait } from '../../classes/display/scene-controls.js';
import { mochaRun } from '../../util/helpers.js'; import { mochaRun } from '../../util/helpers.js';
import { DEFAULT_REP_TOKEN_TYPE_ID } from '../../util/constants.js';
const DELAY_INTERVAL = 100; const DELAY_INTERVAL = 100;
const POOL_DURATION = 200; const POOL_DURATION = 200;
@ -32,7 +33,7 @@ const setup = async () => {
scene.withTable(); scene.withTable();
dao = new DAO('DGF', scene); dao = new DAO('DGF', scene);
await dao.setDisplayValue('total rep', () => dao.reputation.getTotal()); await dao.setDisplayValue('total rep', () => dao.reputation.getTotal(DEFAULT_REP_TOKEN_TYPE_ID));
experts = []; experts = [];
@ -58,7 +59,7 @@ const setup = async () => {
await pool1.evaluateWinningConditions(); await pool1.evaluateWinningConditions();
await delayOrWait(DELAY_INTERVAL); await delayOrWait(DELAY_INTERVAL);
dao.reputation.valueOwnedBy(experts[0].reputationPublicKey).should.equal(10); dao.reputation.valueOwnedBy(experts[0].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(10);
const { pool: pool2 } = await experts[1].submitPostWithFee( const { pool: pool2 } = await experts[1].submitPostWithFee(
new PostContent({ hello: 'to you as well' }) new PostContent({ hello: 'to you as well' })
@ -77,8 +78,8 @@ const setup = async () => {
await pool2.evaluateWinningConditions(); await pool2.evaluateWinningConditions();
await delayOrWait(DELAY_INTERVAL); await delayOrWait(DELAY_INTERVAL);
dao.reputation.valueOwnedBy(experts[0].reputationPublicKey).should.equal(15); dao.reputation.valueOwnedBy(experts[0].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(15);
dao.reputation.valueOwnedBy(experts[1].reputationPublicKey).should.equal(5); dao.reputation.valueOwnedBy(experts[1].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(5);
}; };
const getActiveWorker = async () => { const getActiveWorker = async () => {

View File

@ -0,0 +1,73 @@
import { Box } from '../../classes/display/box.js';
import { ForceDirectedGraph } from '../../classes/display/force-directed.js';
import { Rectangle, Vector } from '../../classes/display/geometry.js';
import { delayOrWait } from '../../classes/display/scene-controls.js';
import { Scene } from '../../classes/display/scene.js';
import { EPSILON } from '../../util/constants.js';
import { mochaRun } from '../../util/helpers.js';
const rootElement = document.getElementById('scene');
const rootBox = new Box('rootBox', rootElement).flex();
window.scene = new Scene('WDG test', rootBox);
describe('Weighted Directed Graph', function tests() {
this.timeout(0);
let graph;
before(() => {
graph = (window.graph = new ForceDirectedGraph('test1', window.scene.middleSection.el));
graph.addBox('box1').setInnerHTML('Box 1');
});
it('rectangle should be a polygon with 4 vertices', () => {
const rect = new Rectangle([0, 0], [1, 1]);
rect.vertices[0].should.eql([0, 0]);
rect.vertices[1].should.eql([0, 1]);
rect.vertices[2].should.eql([1, 1]);
rect.vertices[3].should.eql([1, 0]);
});
it('overlapping boxes should repel with default force', () => {
const rect1 = new Rectangle([0, 0], [1, 1]);
const rect2 = new Rectangle([0, 0], [1, 2]);
rect1.center.should.eql([0.5, 0.5]);
rect2.center.should.eql([0.5, 1]);
const force1 = ForceDirectedGraph.pairwiseForce(rect1, rect2, 10);
force1.should.eql([0, 100]);
});
it('boxes at target radius should have no net force', () => {
const rect1 = new Rectangle([0, 0], [1, 1]);
const rect2 = new Rectangle([10, 0], [1, 1]);
rect1.center.should.eql([0.5, 0.5]);
rect2.center.should.eql([10.5, 0.5]);
const force = ForceDirectedGraph.pairwiseForce(rect1, rect2, 10);
force[0].should.be.within(-EPSILON, EPSILON);
force[1].should.be.within(-EPSILON, EPSILON);
});
it('normalized vector should have length = 1', () => {
const v = Vector.from([2, 0]);
const u = v.normalize();
u.magnitude.should.be.within(1 - EPSILON, 1 + EPSILON);
});
it('random unit vector should have length = 1', () => {
const u = Vector.randomUnitVector(2);
console.log('unit vector:', u);
u.magnitude.should.be.within(1 - EPSILON, 1 + EPSILON);
});
it('can add a second box to the graph', async () => {
await delayOrWait(1000);
graph.addBox('box2').setInnerHTML('Box 2');
for (let i = 1; i < 50; i++) {
await delayOrWait(100);
graph.computeEulerFrame(0.1);
}
});
});
mochaRun();

View File

@ -4,6 +4,7 @@ import { Expert } from '../../classes/actors/expert.js';
import { PostContent } from '../../classes/supporting/post-content.js'; import { PostContent } from '../../classes/supporting/post-content.js';
import { DAO } from '../../classes/dao/dao.js'; import { DAO } from '../../classes/dao/dao.js';
import { delayOrWait } from '../../classes/display/scene-controls.js'; import { delayOrWait } from '../../classes/display/scene-controls.js';
import { DEFAULT_REP_TOKEN_TYPE_ID } from '../../util/constants.js';
export class ForumTest { export class ForumTest {
constructor(options) { constructor(options) {
@ -86,6 +87,6 @@ export class ForumTest {
await this.newExpert(); await this.newExpert();
await this.dao.addComputedValue('total value', () => this.dao.reputation.getTotal()); await this.dao.addComputedValue('total rep', () => this.dao.reputation.getTotal(DEFAULT_REP_TOKEN_TYPE_ID));
} }
} }

View File

@ -1,3 +1,4 @@
import { DEFAULT_REP_TOKEN_TYPE_ID } from '../../../util/constants.js';
import { mochaRun } from '../../../util/helpers.js'; import { mochaRun } from '../../../util/helpers.js';
import { ForumTest } from '../forum.test-util.js'; import { ForumTest } from '../forum.test-util.js';
@ -34,9 +35,9 @@ describe('Forum', function tests() {
await forumTest.addPost(authors, 10); await forumTest.addPost(authors, 10);
forum.getPost(posts[0]).value.should.equal(10); forum.getPost(posts[0]).value.should.equal(10);
dao.reputation.valueOwnedBy(experts[0].reputationPublicKey).should.equal(5); dao.reputation.valueOwnedBy(experts[0].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(5);
dao.reputation.valueOwnedBy(experts[1].reputationPublicKey).should.equal(2.5); dao.reputation.valueOwnedBy(experts[1].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(2.5);
dao.reputation.valueOwnedBy(experts[2].reputationPublicKey).should.equal(2.5); dao.reputation.valueOwnedBy(experts[2].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(2.5);
}); });
}); });
}); });

View File

@ -1,3 +1,4 @@
import { DEFAULT_REP_TOKEN_TYPE_ID } from '../../../util/constants.js';
import { mochaRun } from '../../../util/helpers.js'; import { mochaRun } from '../../../util/helpers.js';
import { ForumTest } from '../forum.test-util.js'; import { ForumTest } from '../forum.test-util.js';
@ -33,9 +34,9 @@ describe('Forum', function tests() {
await forumTest.addPost(authors, 10); await forumTest.addPost(authors, 10);
forum.getPost(posts[0]).value.should.equal(10); forum.getPost(posts[0]).value.should.equal(10);
dao.reputation.valueOwnedBy(experts[0].reputationPublicKey).should.equal(5); dao.reputation.valueOwnedBy(experts[0].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(5);
dao.reputation.valueOwnedBy(experts[1].reputationPublicKey).should.equal(5); dao.reputation.valueOwnedBy(experts[1].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(5);
dao.reputation.valueOwnedBy(experts[2].reputationPublicKey).should.equal(0); dao.reputation.valueOwnedBy(experts[2].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(0);
}); });
it('Post2 with two authors, one shared with Post1', async () => { it('Post2 with two authors, one shared with Post1', async () => {
@ -46,9 +47,9 @@ describe('Forum', function tests() {
await forumTest.addPost(authors, 10); await forumTest.addPost(authors, 10);
forum.getPost(posts[0]).value.should.equal(10); forum.getPost(posts[0]).value.should.equal(10);
dao.reputation.valueOwnedBy(experts[0].reputationPublicKey).should.equal(5); dao.reputation.valueOwnedBy(experts[0].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(5);
dao.reputation.valueOwnedBy(experts[1].reputationPublicKey).should.equal(10); dao.reputation.valueOwnedBy(experts[1].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(10);
dao.reputation.valueOwnedBy(experts[2].reputationPublicKey).should.equal(5); dao.reputation.valueOwnedBy(experts[2].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(5);
}); });
}); });
}); });

View File

@ -1,14 +1,17 @@
export const EPSILON = 2.23e-16;
export const INCINERATOR_ADDRESS = '0';
export const EdgeTypes = { export const EdgeTypes = {
CITATION: 'citation', CITATION: 'citation',
BALANCE: 'balance', BALANCE: 'balance',
AUTHOR: 'author', AUTHOR: 'author',
}; };
export const VertexTypes = { export const VertexTypes = {
POST: 'post', POST: 'post',
AUTHOR: 'author', AUTHOR: 'author',
}; };
export const DEFAULT_REP_TOKEN_TYPE_ID = 0;
export const DEFAULT_TARGET_RADIUS = 100;
export const EPSILON = 2.23e-16;
export const INCINERATOR_ADDRESS = '0';
export const OVERLAP_FORCE = 100;
export const DISTANCE_FACTOR = 0.25;
export const MINIMUM_FORCE = 2;
export const VISCOSITY_FACTOR = 0.4;