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", }); export class ValidationPool extends Actor { constructor(bench, authorId, {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.state = ValidationPoolStates.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 } 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; const result = this.evaluateWinningConditions(); 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 * Math.pow(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; // TODO: If quorum is not met, what should happen? if (!quorumMet) { this.deactivate(); throw new Error("Quorum is not met"); } return votePasses && quorumMet; } distributeTokens(result) { // Reward the author // TODO: If the vote fails, distribute tokens.author among winning voters this.bench.reputations.addTokens(this.authorId, this.tokens.author); // Reward the vote winners, in proportion to their stakes const tokensForWinners = result ? this.tokens.for : 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); } } }