dao-governance-framework/forum-network/src/classes/validation-pool.js

284 lines
9.4 KiB
JavaScript

import { CryptoUtil } from './crypto.js';
import { Stake } from './stake.js';
import { Voter } from './voter.js';
import { Actor } from './actor.js';
import params from '../params.js';
const ValidationPoolStates = Object.freeze({
OPEN: 'OPEN',
CLOSED: 'CLOSED',
RESOLVED: 'RESOLVED',
});
/**
* Purpose: Enable voting
*/
export class ValidationPool extends Actor {
constructor(
bench,
forum,
{
postId,
signingPublicKey,
fee,
duration,
tokenLossRatio,
contentiousDebate = false,
authorStake = 0,
anonymous = true,
},
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.bench = bench;
this.forum = forum;
this.postId = postId;
this.state = ValidationPoolStates.OPEN;
this.setStatus('Open');
this.stakes = new Map();
this.voters = new Map();
this.id = CryptoUtil.randomUUID();
this.dateStart = new Date();
this.authorSigningPublicKey = signingPublicKey;
this.anonymous = anonymous;
this.fee = fee;
this.duration = duration;
this.tokenLossRatio = tokenLossRatio;
this.contentiousDebate = contentiousDebate;
this.tokensMinted = fee * params.mintingRatio();
// 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(signingPublicKey, {
position: true,
amount: this.tokensMinted * params.stakeForAuthor + authorStake,
anonymous,
});
this.stake(this.id, {
position: false,
amount: this.tokensMinted * (1 - params.stakeForAuthor),
});
}
async stake(signingPublicKey, {
position, amount, lockingTime = 0, anonymous = false,
}) {
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`,
);
}
const stake = new Stake(position, amount, lockingTime);
this.stakes.set(signingPublicKey, stake);
console.log('new stake', stake);
if (!anonymous) {
await this.revealIdentity(signingPublicKey, signingPublicKey);
}
}
async revealIdentity(signingPublicKey, reputationPublicKey) {
if (!this.stakes.get(signingPublicKey)) {
throw new Error('Must stake 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);
}
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.stakes) {
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.
}
}
/**
* @param {boolean} outcome: null --> all entries. Otherwise filters to position === outcome.
* @param {object} getStakeEntries options
* @param {boolean} options.excludeSystem: Whether to exclude votes cast during pool initialization
* @returns [signingPublicKey, stake][]
*/
getStakeEntries(outcome, options = {}) {
const { excludeSystem = false } = options;
const entries = Array.from(this.stakes.entries());
// console.log('entries', entries);
return entries
.filter(([signingPublicKey, __]) => !excludeSystem || signingPublicKey !== this.id)
.filter(([__, { position }]) => outcome === null || position === outcome);
}
/**
* @param {boolean} outcome: null --> all entries. Otherwise filters to position === outcome.
* @param {object} getStakeEntries options
* @returns number
*/
getTotalStakedOnPost(outcome, options) {
return this.getStakeEntries(outcome, options)
.map(([__, stake]) => stake.getStakeValue())
.reduce((acc, cur) => (acc += cur), 0);
}
/**
* @param {boolean} outcome: null --> all entries. Otherwise filters to position === outcome.
* @param {object} getStakeEntries options
* @returns number
*/
getTotalValueOfStakesForOutcome(outcome, options) {
return this.getStakeEntries(outcome, options)
.reduce((total, [__, { amount }]) => (total += amount), 0);
}
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.`);
}
if (this.voters.size < this.stakes.size) {
throw new Error('Not all voters have revealed their reputation public keys!');
}
// Now we can evaluate winning conditions
this.state = ValidationPoolStates.CLOSED;
this.setStatus('Closed');
const upvoteValue = this.getTotalValueOfStakesForOutcome(true);
const downvoteValue = this.getTotalValueOfStakesForOutcome(false);
const activeAvailableReputation = this.bench.getTotalActiveAvailableReputation();
const votePasses = upvoteValue >= params.winningRatio * downvoteValue;
const quorumMet = upvoteValue + downvoteValue >= params.quorum * activeAvailableReputation;
const result = {
votePasses,
upvoteValue,
downvoteValue,
};
if (quorumMet) {
this.setStatus(`Resolved - ${votePasses ? 'Won' : 'Lost'}`);
this.scene.sequence.log(`note over ${this.name} : ${votePasses ? 'Win' : 'Lose'}`);
this.applyTokenLocking();
await this.distributeReputation({ votePasses });
// TODO: distribute fees
} else {
this.setStatus('Resolved - Quorum not met');
this.scene.sequence.log(`note over ${this.name} : Quorum not met`);
}
this.deactivate();
this.state = ValidationPoolStates.RESOLVED;
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
// In a tightly binding validation pool, losing voter stakes are transferred to winning voters.
const tokensForWinners = this.getTotalStakedOnPost(!votePasses);
const winningVotes = this.getStakeEntries(votePasses, { excludeSystem: true });
const totalValueOfStakesForWin = this.getTotalValueOfStakesForOutcome(votePasses);
// Compute rewards for the winning voters, in proportion to the value of their stakes.
const rewards = new Map();
for (const [signingPublicKey, stake] of winningVotes) {
const { reputationPublicKey } = this.voters.get(signingPublicKey);
const value = stake.getStakeValue();
const reward = tokensForWinners * (value / totalValueOfStakesForWin);
rewards.set(reputationPublicKey, reward);
}
console.log('rewards for stakes', rewards);
const authorReputationPublicKey = this.voters.get(this.authorSigningPublicKey).reputationPublicKey;
// Distribute awards to voters other than the author
for (const [reputationPublicKey, amount] of rewards) {
if (reputationPublicKey !== authorReputationPublicKey) {
this.bench.reputations.addTokens(reputationPublicKey, amount);
console.log(`reward for stake by ${reputationPublicKey}:`, amount);
}
}
if (votePasses) {
// Distribute awards to author via the forum
const tokensForAuthor = this.tokensMinted * params.stakeForAuthor + rewards.get(authorReputationPublicKey);
console.log('sending reward for author stake to forum', { tokensForAuthor });
if (votePasses && !!this.forum) {
// Recurse through forum to determine reputation effects
await this.forum.onValidate(
this.bench,
this,
this.postId,
tokensForAuthor,
);
}
}
console.log('pool complete');
}
}