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 */ mintingRatio: () => 1, // c1 // NOTE: c2 overlaps with c3 and adds excess complexity, so we omit it for now stakeForAuthor: 0.5, // c3 winningRatio: 0.5, // c4 quorum: 0, // c5 activeVoterThreshold: null, // c6 voteDuration: { // c7 min: 0, max: null, }, // NOTE: c8 is the token loss ratio, which is specified as a runtime argument contentiousDebate: { period: 5000, // c9 stages: 3, // c10 }, lockingTimeExponent: 0, // c11 /* Forum parameters */ initialPostValue: () => 1, // q1 revaluationLimit: 1, // q2 referenceChainLimit: 3, // q3 leachingValue: 1, // q4 }; const ValidationPoolStates = Object.freeze({ OPEN: 'OPEN', CLOSED: 'CLOSED', RESOLVED: 'RESOLVED', }); /** * Purpose: Enable voting */ export class ValidationPool extends ReputationHolder { constructor( dao, { postId, reputationPublicKey, fee, }, { duration, tokenLossRatio, contentiousDebate = false, reputationTypes, }, name, scene, fromActor, ) { super(name, scene); this.id = this.reputationPublicKey; this.actions = { initiate: new Action('initiate validation pool', scene), reward: new Action('reward', scene), transfer: new Action('transfer', scene), mint: new Action('mint', scene), }; 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 && (tokenLossRatio < 0 || tokenLossRatio > 1 || [null, undefined].includes(tokenLossRatio)) ) { throw new Error( `Token loss ratio must be in the range [0, 1]; got ${tokenLossRatio}`, ); } if ( duration < params.voteDuration.min || (params.voteDuration.max && duration > params.voteDuration.max) || [null, undefined].includes(duration) ) { throw new Error( `Duration must be in the range [${params.voteDuration.min}, ${params.voteDuration.max ?? 'Inf' }]; got ${duration}`, ); } this.dao = dao; this.postId = postId; const post = this.dao.forum.graph.getVertexData(postId); const leachingTotal = post.citations .filter(({ weight }) => weight < 0) .reduce((total, { weight }) => total += -weight, 0); const donationTotal = post.citations .filter(({ weight }) => weight > 0) .reduce((total, { weight }) => total += weight, 0); if (leachingTotal > params.revaluationLimit) { throw new Error('Post leaching total exceeds revaluation limit ' + `(${leachingTotal} > ${params.revaluationLimit})`); } if (donationTotal > params.revaluationLimit) { throw new Error('Post donation total exceeds revaluation limit ' + `(${donationTotal} > ${params.revaluationLimit})`); } if (post.citations.some(({ weight }) => Math.abs(weight) > params.revaluationLimit)) { throw new Error(`Each citation magnitude must not exceed revaluation limit ${params.revaluationLimit}`); } if (post.authors?.length) { const totalAuthorWeight = post.authors.reduce((total, { weight }) => total += weight, 0); if (totalAuthorWeight !== 1) { throw new Error(`Total author weight ${totalAuthorWeight} !== 1`); } } this.state = ValidationPoolStates.OPEN; this.setStatus('Open'); this.stakes = new Set(); this.dateStart = new Date(); this.authorReputationPublicKey = reputationPublicKey; this.fee = fee; this.duration = duration; this.tokenLossRatio = tokenLossRatio; this.contentiousDebate = contentiousDebate; 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); } getTokenLossRatio() { if (!this.contentiousDebate) { return this.tokenLossRatio; } const elapsed = new Date() - this.dateStart; let stageDuration = params.contentiousDebate.period / 2; let stage = 0; let t = 0; while (true) { t += stageDuration; stageDuration /= 2; if (t > elapsed) { break; } stage += 1; if (stage >= params.contentiousDebate.stages - 1) { break; } } return stage / (params.contentiousDebate.stages - 1); } /** * @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, tokenTypeId, excludeSystem }) { return Array.from(this.stakes.values()) .filter((stake) => tokenTypeId === null || stake.tokenTypeId === tokenTypeId) .filter(({ tokenAddress }) => !excludeSystem || tokenAddress !== this.tokenAddress) .filter(({ position }) => outcome === null || position === outcome); } /** * @param {boolean} options.outcome: null --> all entries. Otherwise filters to position === outcome. * @returns number */ getStakedAmount({ outcome, tokenTypeId }) { return this.getStakes({ outcome, tokenTypeId, excludeSystem: false }) .map((stake) => stake.getAmount({ lockingTimeExponent: params.lockingTimeExponent })) .reduce((total, amount) => (total += amount), 0); } /** * Stake reputation in favor of a given outcome for this validation pool. * * @param {*} reputationPublicKey * @param {object} opts */ async stake(reputationPublicKey, { 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.`); } if (this.duration && new Date() - this.dateStart > this.duration) { throw new Error( `Validation pool ${this.id} has expired, no new votes may be cast.`, ); } if (reputationPublicKey !== this.dao.reputation.ownerOf(tokenAddress)) { throw new Error('Reputation may only be staked by its owner!'); } const stake = new Stake({ tokenAddress, tokenTypeId, position, amount, lockingTime, }); this.stakes.add(stake); // Transfer staked amount from the sender to the validation pool this.dao.reputation.transferValueFrom(tokenAddress, this.tokenAddress, tokenTypeId, amount); // Keep a record of voters and their votes if (reputationPublicKey !== this.id) { this.dao.addVoteRecord(reputationPublicKey, this); // Update computed display values const actor = this.scene?.findActor((a) => a.reputationPublicKey === reputationPublicKey); await actor.computeDisplayValues(); } } applyTokenLocking() { // Before evaluating the winning conditions, // we need to make sure any staked tokens are locked for the // specified amounts of time. 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. } } async evaluateWinningConditions() { if (this.state === ValidationPoolStates.RESOLVED) { throw new Error('Validation pool has already been resolved!'); } const elapsed = new Date() - this.dateStart; if (elapsed < this.duration) { throw new Error(`Validation pool duration has not yet elapsed! ${this.duration - elapsed} ms remaining.`); } // Now we can evaluate winning conditions this.state = ValidationPoolStates.CLOSED; this.setStatus('Closed'); // 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 = { outcome, upvoteValue, downvoteValue, }; if (quorumMet) { this.setStatus(`Resolved - ${outcome ? 'Won' : 'Lost'}`); this.scene?.sequence.log(`note over ${this.name} : ${outcome ? 'Win' : 'Lose'}`); this.applyTokenLocking(); await this.distributeReputation({ outcome }); // TODO: distribute fees } else { this.setStatus('Resolved - Quorum not met'); this.scene?.sequence.log(`note over ${this.name} : Quorum not met`); } // Update computed display values for (const voter of this.dao.experts.values()) { const actor = this.scene?.findActor((a) => a.reputationPublicKey === voter.reputationPublicKey); if (!actor) { throw new Error('Actor not found!'); } await actor.computeDisplayValues(); } await this.dao.computeDisplayValues(); this.scene?.stateToTable(`validation pool ${this.name} complete`); await this.deactivate(); this.state = ValidationPoolStates.RESOLVED; return result; } 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 }); // 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 (outcome === true) { // Distribute awards to author via the forum 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.tokenAddress); // const value = this.dao.reputation.valueOf(this.tokenAddress); // this.actions.transfer.log(this, this.dao.forum, `(${value})`); const result = { pool: this, postId: this.postId, 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'); } }