diff --git a/notes/reputation-types.md b/notes/reputation-types.md new file mode 100644 index 0000000..df283fb --- /dev/null +++ b/notes/reputation-types.md @@ -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. \ No newline at end of file diff --git a/src/classes/actors/expert.js b/src/classes/actors/expert.js index eb297e2..1ea3bf3 100644 --- a/src/classes/actors/expert.js +++ b/src/classes/actors/expert.js @@ -25,7 +25,7 @@ export class Expert extends ReputationHolder { return 0; } 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); } @@ -42,7 +42,7 @@ export class Expert extends ReputationHolder { await this.actions.submitPost.log(this, post); const postId = post.id; const pool = await this.initiateValidationPool({ fee, postId }, params); - this.tokens.push(pool.tokenId); + this.tokens.push(pool.tokenAddress); return { postId, pool }; } @@ -53,7 +53,7 @@ export class Expert extends ReputationHolder { postId, fee, }, params); - this.tokens.push(pool.tokenId); + this.tokens.push(pool.tokenAddress); return pool; } @@ -68,7 +68,7 @@ export class Expert extends ReputationHolder { `(${position ? 'for' : 'against'}, stake: ${amount})`, ); 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, { stakeAmount, - tokenId: this.tokens[0], + tokenAddress: this.tokens[0], duration, }); } diff --git a/src/classes/dao/availability.js b/src/classes/dao/availability.js index 8cdb638..c2d6b06 100644 --- a/src/classes/dao/availability.js +++ b/src/classes/dao/availability.js @@ -3,9 +3,9 @@ import { Actor } from '../display/actor.js'; import { CryptoUtil } from '../supporting/crypto.js'; class Worker { - constructor(reputationPublicKey, tokenId, stakeAmount, duration) { + constructor(reputationPublicKey, tokenAddress, stakeAmount, duration) { this.reputationPublicKey = reputationPublicKey; - this.tokenId = tokenId; + this.tokenAddress = tokenAddress; this.stakeAmount = stakeAmount; this.duration = duration; this.available = true; @@ -28,11 +28,11 @@ export class Availability extends Actor { this.workers = new Map(); } - register(reputationPublicKey, { stakeAmount, tokenId, duration }) { + register(reputationPublicKey, { stakeAmount, tokenAddress, duration }) { // 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(); - this.workers.set(workerId, new Worker(reputationPublicKey, tokenId, stakeAmount, duration)); + this.workers.set(workerId, new Worker(reputationPublicKey, tokenAddress, stakeAmount, duration)); return workerId; } diff --git a/src/classes/dao/business.js b/src/classes/dao/business.js index 7dcf53d..a82387d 100644 --- a/src/classes/dao/business.js +++ b/src/classes/dao/business.js @@ -87,7 +87,7 @@ export class Business extends Actor { }); await pool.stake(reputationPublicKey, { - tokenId: request.worker.tokenId, + tokenAddress: request.worker.tokenAddress, amount: request.worker.stakeAmount, position: true, }); diff --git a/src/classes/dao/dao.js b/src/classes/dao/dao.js index 4e31fa8..1cc2552 100644 --- a/src/classes/dao/dao.js +++ b/src/classes/dao/dao.js @@ -5,6 +5,7 @@ import { Availability } from './availability.js'; import { Business } from './business.js'; import { Voter } from '../supporting/voter.js'; import { Actor } from '../display/actor.js'; +import { DEFAULT_REP_TOKEN_TYPE_ID } from '../../util/constants.js'; /** * 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() - .map(({ reputationPublicKey }) => this.reputation.valueOwnedBy(reputationPublicKey)) - .reduce((acc, cur) => (acc += cur), 0); - } - - getActiveAvailableReputation() { - return this.listActiveVoters() - .map(({ reputationPublicKey }) => this.reputation.availableValueOwnedBy(reputationPublicKey)) + .map(({ reputationPublicKey }) => this.reputation.availableValueOwnedBy(reputationPublicKey, reputationTypeId)) .reduce((acc, cur) => (acc += cur), 0); } diff --git a/src/classes/dao/forum.js b/src/classes/dao/forum.js index a5e3fee..5dbba05 100644 --- a/src/classes/dao/forum.js +++ b/src/classes/dao/forum.js @@ -15,8 +15,8 @@ class Post extends Actor { this.forum = forum; this.id = postContent.id ?? name; this.senderId = senderId; - this.value = 0; - this.initialValue = 0; + this.values = new Map(); + this.initialValues = new Map(); this.authors = postContent.authors; this.citations = postContent.citations; this.title = postContent.title; @@ -83,16 +83,21 @@ export class Forum extends ReputationHolder { // getContract(type) { } async onValidate({ - pool, postId, tokenId, referenceChainLimit, leachingValue, + pool, postId, tokenAddress, referenceChainLimit, leachingValue, }) { - console.log('onValidate', { pool, postId, tokenId }); - const initialValue = this.dao.reputation.valueOf(tokenId); + console.log('onValidate', { pool, postId, tokenAddress }); + + // 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 post = postVertex.data; 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. const author = this.scene.findActor(({ reputationPublicKey }) => reputationPublicKey === publicKey); author.setDisplayValue('reputation', () => author.getReputation()); @@ -105,7 +110,7 @@ export class Forum extends ReputationHolder { postVertex, authorVertex, weight, - { tokenId: authorTokenId }, + { tokenAddress: authorTokenAddress }, { 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. // TODO: Verify that cumulative author weight == 1. if (!post.authors?.length) { - addAuthorToGraph(post.senderId, 1, tokenId); + addAuthorToGraph(post.senderId, 1, tokenAddress); } else { for (const { publicKey, weight } of post.authors) { // 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); - addAuthorToGraph(publicKey, weight, authorTokenId); + const authorTokenAddress = (publicKey === post.senderId) + ? 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 (!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 } }, { rewardsAccumulator, - increment: initialValue, + increments: initialValues, referenceChainLimit, leachingValue, }, @@ -142,16 +149,16 @@ export class Forum extends ReputationHolder { // Apply computed rewards to update values of tokens for (const [authorEdge, amount] of rewardsAccumulator) { - const { to: authorVertex, data: { tokenId: authorTokenId } } = authorEdge; + const { to: authorVertex, data: { tokenAddress: authorTokenAddress } } = authorEdge; const { data: author } = authorVertex; // The primary author gets the validation pool minted token. // So we don't need to transfer any reputation to the primary author. // Their reward will be the remaining balance after all other transfers. - if (authorTokenId !== tokenId) { + if (authorTokenAddress !== tokenAddress) { if (amount < 0) { - this.dao.reputation.transferValueFrom(authorTokenId, tokenId, -amount); + this.dao.reputation.transferValueFrom(authorTokenAddress, tokenAddress, -amount); } else { - this.dao.reputation.transferValueFrom(tokenId, authorTokenId, amount); + this.dao.reputation.transferValueFrom(tokenAddress, authorTokenAddress, amount); } await author.computeDisplayValues((label, value) => authorVertex.setProperty(label, value)); authorVertex.displayVertex(); @@ -167,8 +174,8 @@ export class Forum extends ReputationHolder { for (const authorEdge of postVertex.getEdges(EdgeTypes.AUTHOR, true)) { const authorVertex = authorEdge.to; const author = authorVertex.data; - const { tokenId: authorTokenId } = authorEdge.data; - this.dao.reputation.transfer(this.id, author.reputationPublicKey, authorTokenId); + const { tokenAddress: authorTokenAddress } = authorEdge.data; + this.dao.reputation.transfer(this.id, author.reputationPublicKey, authorTokenAddress); } } @@ -178,7 +185,7 @@ export class Forum extends ReputationHolder { */ async propagateValue(edge, { rewardsAccumulator, - increment, + increments, depth = 0, initialNegative = false, referenceChainLimit, @@ -186,7 +193,8 @@ export class Forum extends ReputationHolder { }) { const postVertex = edge.to; 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) { this.actions.propagate.log( @@ -196,7 +204,7 @@ export class Forum extends ReputationHolder { null, '-x', ); - return increment; + return increments; } console.log('propagateValue start', { @@ -204,95 +212,104 @@ export class Forum extends ReputationHolder { to: edge.to.id, depth, value: post.value, - increment, + increments, initialNegative, }); const propagate = async (positive) => { - let totalOutboundAmount = 0; + const totalOutboundAmounts = increments.map(() => 0); const citationEdges = postVertex.getEdges(EdgeTypes.CITATION, true) .filter(({ weight }) => (positive ? weight > 0 : weight < 0)); for (const citationEdge of citationEdges) { const { weight } = citationEdge; - let outboundAmount = weight * increment; - if (Math.abs(outboundAmount) > EPSILON) { - const balanceToOutbound = this.graph.getEdgeWeight(EdgeTypes.BALANCE, citationEdge.from, citationEdge.to) + 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) { + const balanceToOutbound = this.graph.getEdgeWeight(EdgeTypes.BALANCE, citationEdge.from, citationEdge.to) ?? 0; - let refundFromOutbound = 0; + let refundFromOutbound = 0; - // Special case: Incineration. - if (citationEdge.to.id === INCINERATOR_ADDRESS) { + // Special case: Incineration. + if (citationEdge.to.id === INCINERATOR_ADDRESS) { // Only a positive amount may be incinerated! Otherwise the sink could be used as a source. - if (outboundAmount < 0) { - this.scene?.flowchart?.log(`style ${citationEdge.from.id} fill:#620000`); - this.actions.propagate.log( - citationEdge.from.data, - { name: 'Incinerator' }, - `(${increment})`, - undefined, - '-x', - ); - throw new Error('Incinerator can only receive positive citations!'); - } - // Reputation sent to the incinerator is burned! This means it is deducted from the sender, - // without increasing the value of any other token. - this.actions.propagate.log(citationEdge.from.data, { name: 'Incinerator' }, `(${increment})`); - } else { + if (outboundAmount < 0) { + this.scene?.flowchart?.log(`style ${citationEdge.from.id} fill:#620000`); + this.actions.propagate.log( + citationEdge.from.data, + { name: 'Incinerator' }, + incrementsStr, + undefined, + '-x', + ); + throw new Error('Incinerator can only receive positive citations!'); + } + // Reputation sent to the incinerator is burned! This means it is deducted from the sender, + // without increasing the value of any other token. + this.actions.propagate.log(citationEdge.from.data, { name: 'Incinerator' }, incrementsStr); + } else { // We need to ensure that we at most undo the prior effects of this post - if (initialNegative) { - outboundAmount = outboundAmount < 0 - ? Math.max(outboundAmount, -balanceToOutbound) - : Math.min(outboundAmount, -balanceToOutbound); + if (initialNegative) { + outboundAmount = outboundAmount < 0 + ? Math.max(outboundAmount, -balanceToOutbound) + : Math.min(outboundAmount, -balanceToOutbound); + } + + // Recursively propagate reputation effects + refundFromOutbound = await this.propagateValue(citationEdge, { + rewardsAccumulator, + increment: outboundAmount, + depth: depth + 1, + initialNegative: initialNegative || (depth === 0 && outboundAmount < 0), + referenceChainLimit, + leachingValue, + }); + + // Any excess (negative) amount that could not be propagated, + // i.e. because a cited post has been reduced to zero value, + // is retained by the citing post. + outboundAmount -= refundFromOutbound; } - // Recursively propagate reputation effects - refundFromOutbound = await this.propagateValue(citationEdge, { - rewardsAccumulator, - increment: outboundAmount, - depth: depth + 1, - initialNegative: initialNegative || (depth === 0 && outboundAmount < 0), - referenceChainLimit, - leachingValue, - }); - - // Any excess (negative) amount that could not be propagated, - // i.e. because a cited post has been reduced to zero value, - // is retained by the citing post. - outboundAmount -= refundFromOutbound; + // Keep a record of the effect of the reputation transferred along this edge in the graph, + // so that later, negative citations can be constrained to at most undo these effects. + this.graph.setEdgeWeight( + EdgeTypes.BALANCE, + citationEdge.from, + citationEdge.to, + balanceToOutbound + outboundAmount, + ); + refundsFromOutbound[idx] = refundFromOutbound; + totalOutboundAmounts[idx] += outboundAmount; } - - // Keep a record of the effect of the reputation transferred along this edge in the graph, - // so that later, negative citations can be constrained to at most undo these effects. - this.graph.setEdgeWeight( - EdgeTypes.BALANCE, - citationEdge.from, - citationEdge.to, - balanceToOutbound + outboundAmount, - ); - totalOutboundAmount += outboundAmount; - + const refundStr = refundsFromOutbound.map((refund) => displayNumber(refund)).join('/'); this.actions.confirm.log( citationEdge.to.data, citationEdge.from.data, - `(refund: ${displayNumber(refundFromOutbound)}, leach: ${outboundAmount * leachingValue})`, + `(refund: ${refundStr}, leach: ${outboundAmount * leachingValue})`, undefined, '-->>', ); } } - return totalOutboundAmount; + return totalOutboundAmounts; }; // First, leach value via negative citations - const totalLeachingAmount = await propagate(false); - increment -= totalLeachingAmount * leachingValue; + const totalLeachingAmounts = await propagate(false); + for (let idx = 0; idx < totalLeachingAmounts.length; idx++) { + increments[idx] -= totalLeachingAmounts[idx] * leachingValue; + } // Now propagate value via positive citations 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 - const rawNewValue = post.value + increment; + const rawNewValues = post.value + increment; const newValue = Math.max(0, rawNewValue); const appliedIncrement = newValue - post.value; const refundToInbound = increment - appliedIncrement; diff --git a/src/classes/dao/validation-pool.js b/src/classes/dao/validation-pool.js index 520fc83..f14893b 100644 --- a/src/classes/dao/validation-pool.js +++ b/src/classes/dao/validation-pool.js @@ -2,6 +2,7 @@ import { ReputationHolder } from '../reputation/reputation-holder.js'; import { Stake } from '../supporting/stake.js'; import { Action } from '../display/action.js'; import { displayNumber } from '../../util/helpers.js'; +import { DEFAULT_REP_TOKEN_TYPE_ID } from '../../util/constants.js'; const params = { /* Validation Pool parameters */ @@ -51,6 +52,7 @@ export class ValidationPool extends ReputationHolder { duration, tokenLossRatio, contentiousDebate = false, + reputationTypes, }, name, scene, @@ -69,6 +71,16 @@ export class ValidationPool extends ReputationHolder { this.actions.initiate.log(fromActor, this, `(fee: ${fee})`); 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 @@ -130,22 +142,34 @@ export class ValidationPool extends ReputationHolder { this.duration = duration; this.tokenLossRatio = tokenLossRatio; this.contentiousDebate = contentiousDebate; - this.mintedValue = fee * params.mintingRatio(); - this.tokenId = this.dao.reputation.mint(this.id, this.mintedValue); - // Tokens minted "for" the post go toward stake of author voting for their own post. - // Also, author can provide additional stakes, e.g. availability stakes for work evidence post. - this.stake(this.id, { - position: true, - amount: this.mintedValue * params.stakeForAuthor, - tokenId: this.tokenId, - }); - this.stake(this.id, { - position: false, - amount: this.mintedValue * (1 - params.stakeForAuthor), - tokenId: this.tokenId, - }); - this.actions.mint.log(this, this, `(${this.mintedValue})`); + const mintTotal = fee * params.mintingRatio(); + const reputationTypeIds = this.reputationTypes + .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, { + position: true, + amount: mintTotal * params.stakeForAuthor * weight, + tokenAddress: this.tokenAddress, + reputationTypeId, + }); + this.stake(this.id, { + position: false, + amount: this.mintedValue * (1 - params.stakeForAuthor) * weight, + tokenAddress: this.tokenAddress, + reputationTypeId, + }); + } + + this.actions.mint.log(this, this, `(${mintTotal})`); // Keep a record of voters and their votes 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 * @returns stake[] */ - getStakes(outcome, { excludeSystem }) { + getStakes({ outcome, tokenTypeId, excludeSystem }) { 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); } /** - * @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 */ - getTotalStakedOnPost(outcome) { - return this.getStakes(outcome, { excludeSystem: false }) - .map((stake) => stake.getStakeValue({ lockingTimeExponent: params.lockingTimeExponent })) - .reduce((acc, cur) => (acc += cur), 0); + getStakedAmount({ outcome, tokenTypeId }) { + return this.getStakes({ outcome, tokenTypeId, excludeSystem: false }) + .map((stake) => stake.getAmount({ lockingTimeExponent: params.lockingTimeExponent })) + .reduce((total, amount) => (total += amount), 0); } /** - * @param {boolean} outcome: null --> all entries. Otherwise filters to position === outcome. - * @returns number + * Stake reputation in favor of a given outcome for this validation pool. + * + * @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, { - 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) { 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!'); } const stake = new Stake({ - tokenId, position, amount, lockingTime, + tokenAddress, tokenTypeId, position, amount, lockingTime, }); this.stakes.add(stake); // 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 if (reputationPublicKey !== this.id) { @@ -243,8 +266,10 @@ export class ValidationPool extends ReputationHolder { // Before evaluating the winning conditions, // we need to make sure any staked tokens are locked for the // specified amounts of time. - for (const { tokenId, amount, lockingTime } of this.stakes.values()) { - this.dao.reputation.lock(tokenId, amount, lockingTime); + for (const { + 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. } } @@ -261,23 +286,33 @@ export class ValidationPool extends ReputationHolder { this.state = ValidationPoolStates.CLOSED; this.setStatus('Closed'); - const upvoteValue = this.getTotalValueOfStakesForOutcome(true); - const downvoteValue = this.getTotalValueOfStakesForOutcome(false); - const activeAvailableReputation = this.dao.getActiveAvailableReputation(); - const votePasses = upvoteValue >= params.winningRatio * downvoteValue; + // Votes should be scaled by weights of this.reputationTypes + const upvoteValue = this.reputationTypes.reduce((value, { reputationTypeId, weight }) => { + value += this.getStakedAmount({ outcome: true, reputationTypeId }) * weight; + 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 result = { - votePasses, + outcome, upvoteValue, downvoteValue, }; if (quorumMet) { - this.setStatus(`Resolved - ${votePasses ? 'Won' : 'Lost'}`); - this.scene?.sequence.log(`note over ${this.name} : ${votePasses ? 'Win' : 'Lose'}`); + this.setStatus(`Resolved - ${outcome ? 'Won' : 'Lost'}`); + this.scene?.sequence.log(`note over ${this.name} : ${outcome ? 'Win' : 'Lose'}`); this.applyTokenLocking(); - await this.distributeReputation({ votePasses }); + await this.distributeReputation({ outcome }); // TODO: distribute fees } else { this.setStatus('Resolved - Quorum not met'); @@ -301,47 +336,57 @@ export class ValidationPool extends ReputationHolder { return result; } - async distributeReputation({ votePasses }) { - // For now we assume a tightly binding pool, where all staked reputation is lost - // TODO: Take tokenLossRatio into account - // TODO: revoke staked reputation from losing voters + async distributeReputation({ outcome }) { + // In a binding validation pool, losing voter stakes are transferred to winning voters. + // TODO: Regression tests for different tokenLossRatio values + const tokenLossRatio = this.getTokenLossRatio(); + for (const { reputationTypeId, weight } of this.reputationTypes) { + const tokensForWinners = this.getStakedAmount({ outcome: !outcome, reputationTypeId }) * weight * tokenLossRatio; + const winningEntries = this.getStakes({ outcome, reputationTypeId, excludeSystem: true }); + const totalValueOfStakesForWin = this.getStakedAmount({ outcome, reputationTypeId }); - // In a tightly binding validation pool, losing voter stakes are transferred to winning voters. - const tokensForWinners = this.getTotalStakedOnPost(!votePasses); - const winningEntries = this.getStakes(votePasses, { excludeSystem: true }); - const totalValueOfStakesForWin = this.getTotalValueOfStakesForOutcome(votePasses); - - // Compute rewards for the winning voters, in proportion to the value of their stakes. - for (const stake of winningEntries) { - const { tokenId, amount } = stake; - const value = stake.getStakeValue({ lockingTimeExponent: params.lockingTimeExponent }); - const reward = tokensForWinners * (value / totalValueOfStakesForWin); - // Also return each winning voter their staked amount - const reputationPublicKey = this.dao.reputation.ownerOf(tokenId); - console.log(`reward for winning stake by ${reputationPublicKey}: ${reward}`); - this.dao.reputation.transferValueFrom(this.tokenId, tokenId, reward + amount); - const toActor = this.scene?.findActor((actor) => actor.reputationPublicKey === reputationPublicKey); - this.actions.reward.log(this, toActor, `(${displayNumber(reward)})`); + // Compute rewards for the winning voters, in proportion to the value of their stakes. + for (const stake of winningEntries) { + const { tokenAddress, amount } = stake; + const value = stake.getAmount({ lockingTimeExponent: params.lockingTimeExponent }); + const reward = tokensForWinners * (value / totalValueOfStakesForWin); + // Also return each winning voter their staked amount + const reputationPublicKey = this.dao.reputation.ownerOf(tokenAddress); + console.log(`reward of type ${reputationTypeId} for winning stake by ${reputationPublicKey}: ${reward}`); + this.dao.reputation.transferValueFrom(this.tokenAddress, tokenAddress, reputationTypeId, reward + amount); + const toActor = this.scene?.findActor((actor) => actor.reputationPublicKey === reputationPublicKey); + this.actions.reward.log(this, toActor, `(${displayNumber(reward)} type ${reputationTypeId})`); + } } - if (votePasses) { + if (outcome === true) { // Distribute awards to author via the forum - // const tokensForAuthor = this.mintedValue * params.stakeForAuthor + rewards.get(this.tokenId); - console.log(`sending reward for author stake to forum: ${this.dao.reputation.valueOf(this.tokenId)}`); + const tokens = this.reputationTypes.reduce((values, { reputationTypeId }) => { + 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 - this.dao.reputation.transfer(this.id, this.dao.forum.id, this.tokenId); - // const value = this.dao.reputation.valueOf(this.tokenId); + this.dao.reputation.transfer(this.id, this.dao.forum.id, this.tokenAddress); + // const value = this.dao.reputation.valueOf(this.tokenAddress); // this.actions.transfer.log(this, this.dao.forum, `(${value})`); - // Recurse through forum to determine reputation effects - await this.dao.forum.onValidate({ + const result = { pool: this, postId: this.postId, - tokenId: this.tokenId, + tokenAddress: this.tokenAddress, referenceChainLimit: params.referenceChainLimit, 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'); diff --git a/src/classes/display/actor.js b/src/classes/display/actor.js index 08ef1c6..2d54b42 100644 --- a/src/classes/display/actor.js +++ b/src/classes/display/actor.js @@ -89,6 +89,9 @@ export class Actor { async computeDisplayValues(cb) { for (const [label, fn] of this.valueFunctions.entries()) { const value = fn(); + console.log('computeDisplay', { + label, value, fn, cb, + }); await this.setDisplayValue(label, value); if (cb) { cb(label, value); diff --git a/src/classes/display/box.js b/src/classes/display/box.js index b25746c..7c5078d 100644 --- a/src/classes/display/box.js +++ b/src/classes/display/box.js @@ -1,5 +1,6 @@ import { DisplayValue } from './display-value.js'; import { randomID } from '../../util/helpers.js'; +import { Rectangle } from './geometry.js'; export class Box { constructor(name, parentEl, options = {}) { @@ -20,6 +21,7 @@ export class Box { parentEl.appendChild(this.el); } } + this.boxes = []; } flex({ center = false } = {}) { @@ -35,11 +37,6 @@ export class Box { return this; } - hidden() { - this.addClass('hidden'); - return this; - } - addClass(className) { this.el.classList.add(className); return this; @@ -47,6 +44,7 @@ export class Box { addBox(name) { const box = new Box(name, this.el); + this.boxes.push(box); return box; } @@ -63,4 +61,16 @@ export class Box { getId() { 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`; + } } diff --git a/src/classes/display/document.js b/src/classes/display/document.js index 2726e18..74b1974 100644 --- a/src/classes/display/document.js +++ b/src/classes/display/document.js @@ -37,7 +37,6 @@ export class Document extends Box { } get lastElement() { - if (!this.elements.length) return null; return this.elements[this.elements.length - 1]; } } diff --git a/src/classes/display/force-directed.js b/src/classes/display/force-directed.js new file mode 100644 index 0000000..e1d1adf --- /dev/null +++ b/src/classes/display/force-directed.js @@ -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); + } + } +} diff --git a/src/classes/display/form.js b/src/classes/display/form.js index 1b20f6e..eff9fa5 100644 --- a/src/classes/display/form.js +++ b/src/classes/display/form.js @@ -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 { constructor(name, form, opts) { super(name, form, { ...opts, cbEventTypes: ['click'] }); @@ -43,15 +57,18 @@ export class TextField extends FormElement { constructor(name, form, opts) { super(name, form, opts); this.flex({ center: true }); - this.label = document.createElement('label'); - this.labelDiv = document.createElement('div'); - this.label.appendChild(this.labelDiv); - this.labelDiv.innerHTML = opts.label || name; - this.input = document.createElement('input'); - this.input.disabled = !!opts.disabled; - this.input.defaultValue = opts.defaultValue || ''; - this.label.appendChild(this.input); - this.el.appendChild(this.label); + + // Place label inside a div, for improved styling + const labelDiv = document.createElement('div'); + labelDiv.innerHTML = opts.label || name; + const label = document.createElement('label'); + label.appendChild(labelDiv); + const input = document.createElement('input'); + input.disabled = !!opts.disabled; + input.defaultValue = opts.defaultValue || ''; + label.appendChild(input); + this.el.appendChild(label); + this.input = input; } get value() { @@ -61,6 +78,30 @@ export class TextField 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 { constructor(name, form, opts) { super(name, form, opts); @@ -76,36 +117,30 @@ export class SubFormArray extends FormElement { this.subForms.splice(idx, 1); subForm.el.remove(); } -} -export class SubForm extends FormElement { - constructor(name, form, opts) { - 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); - } + subForm(opts = {}) { + const subForm = new SubForm(opts.name, this.form, { ...opts, subFormArray: this }); + this.subForms.push(subForm); + return this; } - get value() { - return this.subForm.value; + get lastSubForm() { + return this.subForms[this.subForms.length - 1]; } } export class FileInput extends FormElement { constructor(name, form, opts) { super(name, form, opts); - this.input = document.createElement('input'); - this.input.type = 'file'; - this.input.accept = 'application/json'; - this.input.classList.add('visually-hidden'); - this.label = document.createElement('label'); - this.button = form.button({ name, cb: () => this.input.click() }).lastItem; - this.label.appendChild(this.button.el); - this.label.appendChild(this.input); - this.el.appendChild(this.label); + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json'; + input.classList.add('visually-hidden'); + const label = document.createElement('label'); + const button = form.button({ name, cb: () => input.click() }).lastItem; + label.appendChild(button.el); + label.appendChild(input); + this.el.appendChild(label); } } @@ -119,6 +154,11 @@ export class Form extends Box { this.el.onsubmit = () => false; } + select(opts) { + this.items.push(new Select(opts.name, this, opts)); + return this; + } + button(opts) { this.items.push(new Button(opts.name, this, opts)); return this; @@ -154,6 +194,10 @@ export class Form extends Box { return this; } + remark(text, opts) { + this.document.remark(text, { ...opts, parentEl: this.el }); + } + get lastItem() { return this.items[this.items.length - 1]; } diff --git a/src/classes/display/geometry.js b/src/classes/display/geometry.js new file mode 100644 index 0000000..f8b21eb --- /dev/null +++ b/src/classes/display/geometry.js @@ -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); + }); + } +} diff --git a/src/classes/display/scene.js b/src/classes/display/scene.js index 43103e5..b650c2e 100644 --- a/src/classes/display/scene.js +++ b/src/classes/display/scene.js @@ -12,7 +12,7 @@ export class Scene { constructor(name, rootBox) { this.name = 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(' '); this.topSection = this.box.addBox('Top section').flex(); this.displayValuesBox = this.topSection.addBox('Values'); diff --git a/src/classes/reputation/reputation-token.js b/src/classes/reputation/reputation-token.js index 73ea289..23cc80e 100644 --- a/src/classes/reputation/reputation-token.js +++ b/src/classes/reputation/reputation-token.js @@ -1,56 +1,64 @@ -import { ERC721 } from '../supporting/erc721.js'; +import { ERC1155 } from '../supporting/erc1155.js'; import { randomID } from '../../util/helpers.js'; import { EPSILON } from '../../util/constants.js'; class Lock { - constructor(tokenId, amount, duration) { + constructor(tokenAddress, tokenTypeId, amount, duration) { this.dateCreated = new Date(); - this.tokenId = tokenId; + this.tokenAddress = tokenAddress; this.amount = amount; this.duration = duration; + this.tokenTypeId = tokenTypeId; } } -export class ReputationTokenContract extends ERC721 { +export class ReputationTokenContract extends ERC1155 { constructor() { super('Reputation', 'REP'); - this.histories = new Map(); // token id --> {increment, context (i.e. validation pool id)} - this.values = new Map(); // token id --> current value - this.locks = new Set(); // {tokenId, amount, start, duration} + this.histories = new Map(); // token address --> {tokenTypeId, increment, context (i.e. validation pool id)} + this.values = new Map(); // token address --> token type id --> current value + this.locks = new Set(); // {tokenAddress, tokenTypeId, amount, start, duration} } /** * - * @param to - * @param value - * @param context + * @param to Recipient address + * @param values Object with reputation type id as key, and amount of reputation as value * @returns {string} */ - mint(to, value, context = {}) { - const tokenId = `token_${randomID()}`; - super.mint(to, tokenId); - this.values.set(tokenId, value); - this.histories.set(tokenId, [{ increment: value, context }]); - return tokenId; + mintBatch(to, tokenTypeIds, values) { + const tokenAddress = `token_${randomID()}`; + super.mintBatch(to, tokenAddress, tokenTypeIds, tokenTypeIds.map(() => 1)); + const tokenMap = new Map(); + for (let idx = 0; idx < tokenTypeIds.length; idx++) { + 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) { - const value = this.values.get(tokenId); - if (value === undefined) { - throw new Error(`Token not found: ${tokenId}`); + incrementValue(tokenAddress, tokenTypeId, increment, context) { + const tokenTypeIds = this.values.get(tokenAddress); + if (tokenTypeIds === undefined) { + throw new Error(`Token not found: ${tokenAddress}`); } + const value = tokenTypeIds?.get(tokenTypeId); const newValue = value + increment; - const history = this.histories.get(tokenId) || []; + const history = this.histories.get(tokenAddress) || []; if (newValue < -EPSILON) { throw new Error(`Token value can not become negative. Attempted to set value = ${newValue}`); } - this.values.set(tokenId, newValue); - history.push({ increment, context }); - this.histories.set(tokenId, history); + tokenTypeIds.set(tokenAddress, newValue); + this.values.set(tokenAddress, tokenTypeIds); + history.push({ tokenTypeId, increment, context }); + this.histories.set(tokenAddress, history); } - transferValueFrom(fromTokenId, toTokenId, amount) { + transferValueFrom(from, to, tokenTypeId, amount) { if (amount === undefined) { throw new Error('Transfer value: amount is undefined!'); } @@ -60,64 +68,83 @@ export class ReputationTokenContract extends ERC721 { if (amount < 0) { throw new Error('Transfer value: amount must be positive'); } - const sourceAvailable = this.availableValueOf(fromTokenId); + 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(fromTokenId, -amount); - this.incrementValue(toTokenId, amount); + this.incrementValue(from, tokenTypeId, -amount); + this.incrementValue(to, tokenTypeId, amount); } - lock(tokenId, amount, duration) { - const lock = new Lock(tokenId, amount, duration); + batchTransferValueFrom(from, to, tokenTypeIds, amounts) { + 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); } - historyOf(tokenId) { - return this.histories.get(tokenId); + historyOf(tokenAddress) { + return this.histories.get(tokenAddress); } - valueOf(tokenId) { - const value = this.values.get(tokenId); - if (value === undefined) { - throw new Error(`Token not found: ${tokenId}`); + valueOf(tokenAddress, tokenTypeId) { + const tokenTypeIds = this.values.get(tokenAddress); + if (tokenTypeIds === undefined) { + 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()) - .filter(({ tokenId: lockTokenId }) => lockTokenId === tokenId) + .filter((lock) => lock.tokenAddress === tokenAddress && lock.tokenTypeId === tokenTypeId) .filter(({ dateCreated, duration }) => new Date() - dateCreated < duration) .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()) - .filter(([__, owner]) => owner === ownerId) - .map(([tokenId, __]) => this.valueOf(tokenId)) + .filter(([__, owner]) => owner === ownerAddress) + .map(([tokenAddress, __]) => this.valueOf(tokenAddress, tokenTypeId)) .reduce((total, value) => total += value, 0); } - availableValueOwnedBy(ownerId) { + availableValueOwnedBy(ownerAddress, tokenTypeId) { return Array.from(this.owners.entries()) - .filter(([__, owner]) => owner === ownerId) - .map(([tokenId, __]) => this.availableValueOf(tokenId)) + .filter(([__, owner]) => owner === ownerAddress) + .map(([tokenAddress, __]) => this.availableValueOf(tokenAddress, tokenTypeId)) .reduce((total, value) => total += value, 0); } - getTotal() { - return Array.from(this.values.values()).reduce((total, value) => total += value, 0); + getTotal(tokenTypeId) { + return Array.from(this.values.values()) + .flatMap((tokens) => tokens.get(tokenTypeId)) + .reduce((total, value) => total += value, 0); } - getTotalAvailable() { - const amountLocked = Array.from(this.locks.values()) - .filter(({ dateCreated, duration }) => new Date() - dateCreated < duration) - .reduce((total, { amount }) => total += amount, 0); - - return this.getTotal() - amountLocked; - } + // burn(tokenAddress, tokenTypeId, ) } diff --git a/src/classes/supporting/edge.js b/src/classes/supporting/edge.js index a05e48f..1b9cc64 100644 --- a/src/classes/supporting/edge.js +++ b/src/classes/supporting/edge.js @@ -75,6 +75,12 @@ export class Edge { static prepareEditorDocument(graph, doc, from, to) { const form = doc.form({ name: 'editorForm' }).lastElement; + form.button({ + name: 'New Vertex', + cb: () => { + graph.resetEditor(); + }, + }); doc.remark('

Edit Edge

', { parentEl: form.el }); form .textField({ @@ -88,7 +94,7 @@ export class Edge { const subFormArray = form.subFormArray({ id: 'edges', name: 'edges' }).lastItem; const addEdgeForm = (edge) => { - const { subForm } = form.subForm({ name: 'subform', subFormArray }).lastItem; + const { subForm } = form.subForm({ subFormArray }).lastItem; subForm.textField({ id: 'type', name: 'type', defaultValue: edge.type, required: true, }) diff --git a/src/classes/supporting/erc1155.js b/src/classes/supporting/erc1155.js new file mode 100644 index 0000000..945a5b5 --- /dev/null +++ b/src/classes/supporting/erc1155.js @@ -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) {} +} diff --git a/src/classes/supporting/erc721.js b/src/classes/supporting/erc721.js index 9a68166..c69bb26 100644 --- a/src/classes/supporting/erc721.js +++ b/src/classes/supporting/erc721.js @@ -16,78 +16,78 @@ export class ERC721 { this.balances = new Map(); // owner address --> token count this.owners = new Map(); // token id --> owner address // this.tokenApprovals = new Map(); // token id --> approved addresses - // this.operatorApprovals = new Map(); // owner --> operator approvals + // this.operatorApprovals = new Map(); // ownerAddress --> operator approvals this.events = { - // Transfer: (_from, _to, _tokenId) => {}, - // Approval: (_owner, _approved, _tokenId) => {}, + // Transfer: (_from, _to, _tokenAddress) => {}, + // Approval: (_owner, _approved, _tokenAddress) => {}, // ApprovalForAll: (_owner, _operator, _approved) => {}, }; } - incrementBalance(owner, increment) { - const balance = this.balances.get(owner) ?? 0; - this.balances.set(owner, balance + increment); + incrementBalance(ownerAddress, increment) { + const balance = this.balances.get(ownerAddress) ?? 0; + this.balances.set(ownerAddress, balance + increment); } - mint(to, tokenId) { - console.log('ERC721.mint', { to, tokenId }); - if (this.owners.get(tokenId)) { + mint(to, tokenAddress) { + console.log('ERC721.mint', { to, tokenAddress }); + if (this.owners.get(tokenAddress)) { throw new Error('ERC721: token already minted'); } this.incrementBalance(to, 1); - this.owners.set(tokenId, to); + this.owners.set(tokenAddress, to); } - burn(tokenId) { - const owner = this.owners.get(tokenId); - this.incrementBalance(owner, -1); - this.owners.delete(tokenId); + burn(tokenAddress) { + const ownerAddress = this.owners.get(tokenAddress); + this.incrementBalance(ownerAddress, -1); + this.owners.delete(tokenAddress); } - balanceOf(owner) { - if (!owner) { + balanceOf(ownerAddress) { + if (!ownerAddress) { 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) { - const owner = this.owners.get(tokenId); - if (!owner) { - throw new Error(`ERC721: invalid token ID: ${tokenId}`); + ownerOf(tokenAddress) { + const ownerAddress = this.owners.get(tokenAddress); + if (!ownerAddress) { + throw new Error(`ERC721: invalid token ID: ${tokenAddress}`); } - return owner; + return ownerAddress; } - transfer(from, to, tokenId) { - console.log('ERC721.transfer', { from, to, tokenId }); - const owner = this.owners.get(tokenId); - if (owner !== from) { - throw new Error(`ERC721: transfer from incorrect owner ${from}; should be ${owner}`); + transfer(from, to, tokenAddress) { + console.log('ERC721.transfer', { from, to, tokenAddress }); + const ownerAddress = this.owners.get(tokenAddress); + if (ownerAddress !== from) { + throw new Error(`ERC721: transfer from incorrect owner ${from}; should be ${ownerAddress}`); } this.incrementBalance(from, -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 /// all of `msg.sender`'s assets /// @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 _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 `_tokenId` is not a valid NFT. - /// @param _tokenId The NFT to find the approved address for + /// @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(_tokenId) {} + // 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 owner + /// @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) {} } diff --git a/src/classes/supporting/schema.js b/src/classes/supporting/schema.js new file mode 100644 index 0000000..5644ffd --- /dev/null +++ b/src/classes/supporting/schema.js @@ -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('

Node Types

'); + 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(`

Node Type: ${name}

`); + } else { + nodeTypeForm.remark('

New Node Type

'); + } + 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('

Edge Types

'); + const form = doc.form('Edge Types Editor').lastElement; + for (const { name } of schema.edgeTypes.values()) { + form.remark(`

Edge Type: ${name}

`); + form.textField({ name: 'name', defaultValue: name }); + } + form.submit({ + name: 'Save', + }); + } +} + +// Properties +// Data types +// Relationships diff --git a/src/classes/supporting/stake.js b/src/classes/supporting/stake.js index 8b6ac7e..6fad7e4 100644 --- a/src/classes/supporting/stake.js +++ b/src/classes/supporting/stake.js @@ -1,14 +1,15 @@ export class Stake { constructor({ - tokenId, position, amount, lockingTime, + tokenAddress, tokenTypeId, position, amount, lockingTime, }) { - this.tokenId = tokenId; + this.tokenAddress = tokenAddress; this.position = position; this.amount = amount; this.lockingTime = lockingTime; + this.tokenTypeId = tokenTypeId; } - getStakeValue({ lockingTimeExponent } = {}) { + getAmount({ lockingTimeExponent } = {}) { return this.amount * this.lockingTime ** lockingTimeExponent; } } diff --git a/src/classes/supporting/vertex.js b/src/classes/supporting/vertex.js index 1137bf6..92671c0 100644 --- a/src/classes/supporting/vertex.js +++ b/src/classes/supporting/vertex.js @@ -15,7 +15,7 @@ export class Vertex { to: [], }; this.installedClickCallback = false; - this.properties = new Map(); + this.properties = options.properties ?? new Map(); } toJSON() { @@ -51,14 +51,18 @@ export class Vertex { } let html = ''; - html += `${this.label}`; + if (this.type) { + html += `${this.type}`; + } + html += `${this.label || this.id}`; html += ''; + console.log('displayVertex', { properties: this.properties }); for (const [key, value] of this.properties.entries()) { const displayValue = typeof value === 'number' ? displayNumber(value) : value; html += ``; } html += '
${key}${displayValue}
'; - if (this.id !== this.label) { + if (this.label && this.id !== this.label) { html += `${this.id}
`; } html = html.replaceAll(/\n\s*/g, ''); @@ -73,26 +77,34 @@ export class Vertex { static prepareEditorDocument(graph, doc, vertexId) { const vertex = vertexId ? graph.getVertex(vertexId) : undefined; const form = doc.form().lastElement; + + if (vertex) { + form.button({ + name: 'New Vertex', + cb: () => { + graph.resetEditor(); + }, + }); + } doc.remark(`

${vertex ? 'Edit' : 'Add'} Vertex

`, { parentEl: form.el }); form .textField({ - id: 'id', name: 'id', defaultValue: vertex?.id, + name: 'id', defaultValue: vertex?.id, }) - .textField({ id: 'type', name: 'type', defaultValue: vertex?.type }) - .textField({ id: 'label', name: 'label', defaultValue: vertex?.label }); + .textField({ name: 'type', defaultValue: vertex?.type }) + .textField({ name: 'label', defaultValue: vertex?.label }); doc.remark('

Properties

', { parentEl: form.el }); - const subFormArray = form.subFormArray({ id: 'properties', name: 'properties' }).lastItem; + const subFormArray = form.subFormArray({ name: 'properties' }).lastItem; const addPropertyForm = (key, value) => { - const { subForm } = form.subForm({ name: 'subform', subFormArray }).lastItem; - subForm.textField({ id: 'key', name: 'key', defaultValue: key }) - .textField({ id: 'value', name: 'value', defaultValue: value }) + const { subForm } = form.subForm({ subFormArray }).lastItem; + subForm.textField({ name: 'key', defaultValue: key }) + .textField({ name: 'value', defaultValue: value }) .button({ - id: 'remove', name: 'Remove Property', cb: () => subFormArray.remove(subForm), - }); - doc.remark('
', { parentEl: subForm.el }); + }) + .remark('
'); }; if (vertex) { @@ -102,13 +114,11 @@ export class Vertex { } form.button({ - id: 'add', name: 'Add Property', cb: () => addPropertyForm('', ''), }); form.submit({ - id: 'save', name: 'Save', cb: ({ form: { value: formValue } }) => { let fullRedraw = false; @@ -121,7 +131,10 @@ export class Vertex { Object.assign(vertex, formValue); vertex.displayVertex(); } 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); doc.clear(); Vertex.prepareEditorDocument(graph, doc, newVertex.id); @@ -134,7 +147,6 @@ export class Vertex { if (vertex) { form.button({ - id: 'delete', name: 'Delete Vertex', cb: () => { graph.deleteVertex(vertex.id); @@ -159,7 +171,6 @@ export class Vertex { } form.button({ - id: 'cancel', name: 'Cancel', cb: () => graph.resetEditor(), parentEl: doc.el, diff --git a/src/classes/supporting/wdg.js b/src/classes/supporting/wdg.js index c454279..8636c3c 100644 --- a/src/classes/supporting/wdg.js +++ b/src/classes/supporting/wdg.js @@ -131,9 +131,10 @@ export class WeightedDirectedGraph { const form = this.controlDoc.form({ name: 'controlForm' }).lastElement; const { subForm: graphPropertiesForm } = form.subForm({ name: 'graphPropsForm' }).lastItem; graphPropertiesForm.flex() - .textField({ name: 'name', label: 'Graph name', defaultValue: this.name }) - .submit({ - name: 'Save', + .textField({ + name: 'name', + label: 'Graph name', + defaultValue: this.name, cb: (({ form: { value: { name } } }) => { this.name = name; }), diff --git a/src/index.css b/src/index.css index cfa67b0..72c4aaf 100644 --- a/src/index.css +++ b/src/index.css @@ -50,6 +50,12 @@ a:visited { left: 12em; top: -0.5em; } +.fixed { + position: fixed; +} +.absolute { + position: absolute; +} svg { width: 800px; } diff --git a/src/index.html b/src/index.html index decbfdb..8735cfd 100644 --- a/src/index.html +++ b/src/index.html @@ -15,18 +15,19 @@ For more information please see the DGF Wiki.

+

+ The code for this site is available in GitLab. +

Tools

Example Scenarios

Below are example scenarios with various assertions covering features of our reputation system.

-

- The code for this site is available in GitLab. -