import { CryptoUtil } from './crypto.js'; import { Vote } from './vote.js'; import { Voter } from './voter.js'; import { Actor } from './actor.js'; import params from './params.js'; const ValidationPoolStates = Object.freeze({ OPEN: 'OPEN', CLOSED: 'CLOSED', }); /** * Purpose: Enable voting */ export class ValidationPool extends Actor { constructor( bench, authorId, { postId, signingPublicKey, fee, duration, tokenLossRatio, contentiousDebate = false, }, name, scene, ) { super(name, scene); // 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.postId = postId; this.state = ValidationPoolStates.OPEN; this.setStatus('Open'); this.votes = new Map(); this.voters = new Map(); this.bench = bench; this.id = CryptoUtil.randomUUID(); this.dateStart = new Date(); this.authorId = authorId; this.fee = fee; this.duration = duration; this.tokenLossRatio = tokenLossRatio; this.contentiousDebate = contentiousDebate; this.tokens = { for: fee * params.mintingRatio * params.stakeForWin, against: fee * params.mintingRatio * (1 - params.stakeForWin), // author: fee * params.mintingRatio * params.stakeForAuthor, }; // TODO: Consider availability stakes this.castVote(signingPublicKey, true, this.tokens.for, 0); } castVote(signingPublicKey, position, stake, lockingTime) { const vote = new Vote(position, stake, lockingTime); 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`, ); } this.votes.set(signingPublicKey, vote); } listVotes(position) { return new Map( Array.from(this.votes.entries()).filter( ([_, vote]) => vote.position === position, ), ); } revealIdentity(signingPublicKey, reputationPublicKey) { if (!this.votes.get(signingPublicKey)) { throw new Error('Must vote before revealing identity'); } const voter = this.bench.voters.get(reputationPublicKey) ?? new Voter(reputationPublicKey); voter.addVoteRecord(this); this.bench.voters.set(reputationPublicKey, voter); this.voters.set(signingPublicKey, voter); if (this.votes.size === this.voters.size) { // All voters have revealed their reputation public keys // Now we can evaluate winning conditions this.state = ValidationPoolStates.CLOSED; this.setStatus('Closed'); const result = this.evaluateWinningConditions(); if (result === null) { this.setStatus('Closed - Quorum not met'); this.scene.log(`note over ${this.name} : Quorum not met`); } else { this.setStatus(`Closed - ${result ? 'Won' : 'Lost'}`); this.scene.log(`note over ${this.name} : ${result ? 'Win' : 'Lose'}`); this.applyTokenLocking(); this.distributeTokens(result); } this.deactivate(); } } 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); } applyTokenLocking() { // Before evaluating the winning conditions, // we need to make sure any staked tokens are locked for the // specified amounts of time. for (const [ signingPublicKey, { stake, lockingTime }, ] of this.votes.entries()) { const voter = this.voters.get(signingPublicKey); this.bench.reputations.lockTokens( voter.reputationPublicKey, stake, lockingTime, ); // TODO: If there is an exception here, the voter may have voted incorrectly. Consider penalties. } } evaluateWinningConditions() { const getVoteValue = ({ stake, lockingTime }) => stake * lockingTime ** params.lockingTimeExponent; const getTotalValue = (position) => Array.from(this.listVotes(position).values()) .map(getVoteValue) .reduce((acc, cur) => (acc += cur), 0); const upvoteValue = getTotalValue(true); const downvoteValue = getTotalValue(false); const activeAvailableReputation = this.bench.getTotalActiveAvailableReputation(); const votePasses = upvoteValue >= params.winningRatio * downvoteValue; const quorumMet = upvoteValue + downvoteValue >= params.quorum * activeAvailableReputation; return quorumMet ? votePasses : null; } distributeTokens(result) { // Reward the author // TODO: If the vote fails, distribute tokens.author among winning voters if (result === true) { this.bench.reputations.addTokens(this.authorId, this.tokens.for); // Reward the vote winners, in proportion to their stakes const tokensForWinners = this.tokens.against; const winningVotes = this.listVotes(result); const totalStakes = Array.from(winningVotes.values()) .map(({ stake }) => stake) .reduce((acc, cur) => (acc += cur), 0); if (!totalStakes) { return; } for (const [signingPublicKey, { stake }] of winningVotes.entries()) { const { reputationPublicKey } = this.voters.get(signingPublicKey); const reward = (tokensForWinners * stake) / totalStakes; this.bench.reputations.addTokens(reputationPublicKey, reward); } } } }