395 lines
14 KiB
JavaScript
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');
|
|
}
|
|
}
|