forum-logic/src/classes/dao/validation-pool.js

395 lines
14 KiB
JavaScript

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