Compare commits
No commits in common. "main" and "forum-network" have entirely different histories.
main
...
forum-netw
|
@ -0,0 +1,7 @@
|
||||||
|
pages:
|
||||||
|
script:
|
||||||
|
- mkdir public
|
||||||
|
- cp -r forum-network/src/* public/
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- public
|
|
@ -1,7 +1,7 @@
|
||||||
# DAO Governance Framework
|
# Science Publishing DAO
|
||||||
|
|
||||||
## Subprojects
|
## Subprojects
|
||||||
|
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [semantic-scholar-client](./semantic-scholar-client) | Rust utility for reading data from the [Semantic Scholar API](https://api.semanticscholar.org/api-docs) |
|
| [forum-network](./forum-network) | Javascript prototyping forum architecture |
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
es2021: true,
|
||||||
|
},
|
||||||
|
extends: ['airbnb-base'],
|
||||||
|
overrides: [],
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
'import',
|
||||||
|
'html',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'import/extensions': ['error', 'always'],
|
||||||
|
'import/prefer-default-export': ['off'],
|
||||||
|
'import/no-unresolved': ['error', { ignore: ['^http'] }],
|
||||||
|
'import/no-absolute-path': ['off'],
|
||||||
|
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
'max-classes-per-file': ['off'],
|
||||||
|
'no-param-reassign': ['off'],
|
||||||
|
'no-plusplus': ['off'],
|
||||||
|
'no-restricted-syntax': ['off'],
|
||||||
|
'max-len': ['warn', 120],
|
||||||
|
'no-console': ['off'],
|
||||||
|
'no-return-assign': ['off'],
|
||||||
|
'no-multi-assign': ['off'],
|
||||||
|
'no-constant-condition': ['off'],
|
||||||
|
'no-await-in-loop': ['off'],
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,3 @@
|
||||||
|
ssl/
|
||||||
|
node_modules/
|
||||||
|
git/
|
|
@ -0,0 +1,30 @@
|
||||||
|
We've considered implementing this validation pool + forum structure as smart contracts.
|
||||||
|
However, we expect that such contracts would be expensive to run, because the recursive algorithm for distributing reputation via the forum will incur a lot of computation, consuming a lot of gas.
|
||||||
|
|
||||||
|
Can we bake this reputation algorithm into the core protocol of our blockchain?
|
||||||
|
|
||||||
|
The structure seems to be similar to proof-of-stake. A big difference is that what is staked and awarded is reputation rather than currency.
|
||||||
|
The idea with reputation is that it entitles you to a proportional share of revenue earned by the network.
|
||||||
|
So what does that look like in this context?
|
||||||
|
|
||||||
|
Let's say we are extending Ethereum. ETH would continue to be the currency that users must spend in order to execute transactions.
|
||||||
|
So when a user wants to execute a transaction, they must pay a fee.
|
||||||
|
A portion of this fee could then be distributed to reputation holders.
|
||||||
|
|
||||||
|
- https://ethereum.org/en/developers/docs/consensus-mechanisms/pos/
|
||||||
|
- https://ethereum.org/en/developers/docs/nodes-and-clients/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
execution client
|
||||||
|
execution gossip network
|
||||||
|
|
||||||
|
consensus client
|
||||||
|
consensus gossip network
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
cardano -- "dynamic availability"?
|
||||||
|
staking pools -- does it make sense with reputation?
|
||||||
|
what about for governance voting --
|
||||||
|
do we want a representative republic or a democracy?
|
|
@ -0,0 +1,99 @@
|
||||||
|
# Challenges
|
||||||
|
|
||||||
|
- Receiving payments
|
||||||
|
- Distributing payments to participants
|
||||||
|
- Computing updates to forum graph
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Receiving payments
|
||||||
|
|
||||||
|
Business SC will need to implement a financial model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Excerpts from DeSciPubDAOArchit22July19PrintCut.pdf
|
||||||
|
|
||||||
|
> With today’s prices, however, we will begin by programming this all off-chain and simplify the reputation tokens to be less dynamic in their evaluation. Next iteration improves the decentralization commensurate with practical realities.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Validation pool termination
|
||||||
|
|
||||||
|
How do we want to handle this?
|
||||||
|
The validation pool specifies a duration.
|
||||||
|
We don't want to compute results until the end of this duration.
|
||||||
|
We're currently supporting anonymous voting.
|
||||||
|
With anonymous voting, we need to wait until the end of the vote duration,
|
||||||
|
and then have a separate interval in which voters reveal their identities.
|
||||||
|
For now, we can let anonymous voters reveal their identities at any time
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Bench.totalReputation is a very important quantity, isn't it? Basically determines inflation for reputation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Should availability registration encumber reputation?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- Is a particular availability stake amount required?
|
||||||
|
|
||||||
|
Currently we support updating the staked amount.
|
||||||
|
Seems like a soft protocol thing.
|
||||||
|
A given DAO can have a formula for deciding appropriate amounts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The following was a code comment on `Business.submitRequest(fee, ...)`:
|
||||||
|
|
||||||
|
> Fee should be held in escrow.
|
||||||
|
> That means there should be specific conditions under which the fee will be refunded.
|
||||||
|
> That means the submission should include some time value to indicate when it expires.
|
||||||
|
> There could be separate thresholds to indicate the earliest that the job may be cancelled,
|
||||||
|
> and the time at which the job will be automatically cancelled.
|
||||||
|
|
||||||
|
# Implementing forum
|
||||||
|
|
||||||
|
Does the following make sense?
|
||||||
|
We will link the forum to the bench
|
||||||
|
An author of a forum post /_ ? is always? can be? _/ a reputation holder.
|
||||||
|
This is what we call a expert. Let's update that terminology to be `reputationHolder`.
|
||||||
|
That's too long, though. Let's rename it to `expert`.
|
||||||
|
So we want to aim for the situation where the author of a forum post is an expert.
|
||||||
|
For now let's try thinking of them as experts no matter what;
|
||||||
|
The strength of their expertise is meant to be represented by reputation tokens.
|
||||||
|
So each reputation token must be a contract.
|
||||||
|
Minting a reputation token means to construct an instance of such a contract.
|
||||||
|
The reputation contract then has its own lifecycle.
|
||||||
|
We can support dynamic reevaluation if the reputation contract
|
||||||
|
|
||||||
|
- has an interface that allows (securely) updating
|
||||||
|
- Define secure :: passes validation pool
|
||||||
|
- How shall it know the operation is occurring as part of an "official" validation pool?
|
||||||
|
It can verify a signature...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Tokens staked for and against a post.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Token loss ratio
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
parameter q_4 -- what is c_n?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
what is reputation?
|
||||||
|
valuable evidence that you're going to do what you say you'll do in the future
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
for now, combine c2 and c3
|
||||||
|
|
||||||
|
validation pool should compute rewards for author,
|
||||||
|
then send that to the forum to distribute.
|
|
@ -0,0 +1,50 @@
|
||||||
|
Reputation Tokens
|
||||||
|
|
||||||
|
Minting
|
||||||
|
|
||||||
|
Suppose it's possible to mint a reputation token.
|
||||||
|
Say we have a contract that keeps track of all the reputation tokens.
|
||||||
|
Suppose the reputation contract implements ERC720 (NFT).
|
||||||
|
Assume a validation pool always corresponds to a post.
|
||||||
|
A single token could be minted for each validation pool.
|
||||||
|
That token could be subdivided so that each winning voter gets some.
|
||||||
|
Perhaps voters get tokens that are specifically identifiable as governance reputation tokens.
|
||||||
|
Then the main token can be awarded to the post author.
|
||||||
|
Each token should carry a specific value.
|
||||||
|
The forum will update the value of the token for the post author as well as posts affected by its chain of references.
|
||||||
|
|
||||||
|
Then, when participants wish to stake reputation (for voting or for availability),
|
||||||
|
they must specify the amount and the token address which carries that reputation.
|
||||||
|
The token should probably then lock that reputation, preventing it from being staked concurrently for another purpose.
|
||||||
|
|
||||||
|
Perhaps our interface can allow staking reputation from multiple tokens at the same time.
|
||||||
|
And/or we can provide a mechanism for consolidating tokens.
|
||||||
|
|
||||||
|
Or maybe, if reputation is staked via a given token, then the reputation awards should go to that same token.
|
||||||
|
In that case, when should new tokens be minted?
|
||||||
|
|
||||||
|
Maybe a token should be minted for each validation pool, but not subdivided.
|
||||||
|
Voter rewards can just add value to the existing tokens from which reputation was staked.
|
||||||
|
|
||||||
|
Maybe a new token should only be minted if the author did not provide a token from which to stake reputation on their own post.
|
||||||
|
This supports the case of a new author earning their first reputation.
|
||||||
|
In that case the author may need to pay a fee to buy in to the DAO.
|
||||||
|
Or perhaps they can be sponsored by one or more existing reputation token holders.
|
||||||
|
Existing reputation holders could grant some reputation to a new member.
|
||||||
|
Perhaps there could be a contract that allows sponsoring a new member, such that whatever fee is given,
|
||||||
|
that fee will automatically be repaid from the new member's earnings, before the new member starts receiving their share of earnings.
|
||||||
|
This could be a multi-party action, or could just be a generic operation that can be performed multiple times.
|
||||||
|
|
||||||
|
However, this effectively allows buying reputation, which goes against the core concept of reputation as evidence of performance.
|
||||||
|
|
||||||
|
It could make more sense for new members to submit some sort of petition, i.e. to make a post.
|
||||||
|
|
||||||
|
Maybe rather than submitting fees, existing members can grant some of their own reputation to a new member, and receive some sort of compensation if the new member does well.
|
||||||
|
|
||||||
|
So far the only workable model seems to be the idea that a new member must submit a post along with a fee, in order to be considered, and if the post is approved, they gain their first reputation.
|
||||||
|
The amount of this fee can be up to the applicant, and/or can be regulated by soft protocols within the DAO.
|
||||||
|
|
||||||
|
If this is the only scenario in which new rep tokens are minted, and from then on their value is updated as a result of each validation pool,
|
||||||
|
then we probably want each token to store information about the history of its value.
|
||||||
|
At a minimum this can be a list where each item includes the identifier of the corresponding validation pool, and the resulting increment/decrement of token value.
|
||||||
|
Each validation pool can then keep a record of the reputation staked by each voter, and the identifier of the corresponding post.
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "forum-network",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "^8.27.0",
|
||||||
|
"eslint-config-airbnb-base": "^15.0.0",
|
||||||
|
"eslint-plugin-html": "^7.1.0",
|
||||||
|
"eslint-plugin-import": "^2.26.0",
|
||||||
|
"prettier": "^2.7.1",
|
||||||
|
"prettier-eslint": "^15.0.1"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
export class Action {
|
||||||
|
constructor(name, scene) {
|
||||||
|
this.name = name;
|
||||||
|
this.scene = scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
async log(src, dest, msg, obj, symbol = '->>') {
|
||||||
|
const logObj = false;
|
||||||
|
await this.scene.sequence.log(
|
||||||
|
`${src.name} ${symbol} ${dest.name} : ${this.name} ${msg ?? ''} ${
|
||||||
|
logObj && obj ? JSON.stringify(obj) : ''
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
export class Actor {
|
||||||
|
constructor(name, scene) {
|
||||||
|
this.name = name;
|
||||||
|
this.scene = scene;
|
||||||
|
this.callbacks = new Map();
|
||||||
|
this.status = this.scene.addDisplayValue(`${this.name} status`);
|
||||||
|
this.status.set('Created');
|
||||||
|
this.values = new Map();
|
||||||
|
this.active = 0;
|
||||||
|
this.scene.registerActor(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
activate() {
|
||||||
|
this.active += 1;
|
||||||
|
this.scene.sequence.log(`activate ${this.name}`, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivate() {
|
||||||
|
if (!this.active) {
|
||||||
|
throw new Error(`${this.name} is not active, can not deactivate`);
|
||||||
|
}
|
||||||
|
this.active -= 1;
|
||||||
|
await this.scene.sequence.log(`deactivate ${this.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(dest, action, detail) {
|
||||||
|
await action.log(this, dest, detail ? JSON.stringify(detail) : '');
|
||||||
|
await dest.recv(this, action, detail);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async recv(src, action, detail) {
|
||||||
|
const cb = this.callbacks.get(action.name);
|
||||||
|
if (!cb) {
|
||||||
|
throw new Error(
|
||||||
|
`[${this.scene.name} actor ${this.name} does not have a callback registered for ${action.name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await cb(src, detail);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
on(action, cb) {
|
||||||
|
this.callbacks.set(action.name, cb);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(status) {
|
||||||
|
this.status.set(status);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
addValue(label) {
|
||||||
|
this.values.set(label, this.scene.addDisplayValue(`${this.name} ${label}`));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setValue(label, value) {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
value = value.toFixed(2);
|
||||||
|
}
|
||||||
|
let displayValue = this.values.get(label);
|
||||||
|
if (!displayValue) {
|
||||||
|
displayValue = this.scene.addDisplayValue(`${this.name} ${label}`);
|
||||||
|
this.values.set(label, displayValue);
|
||||||
|
}
|
||||||
|
if (value !== displayValue.get()) {
|
||||||
|
await this.scene.sequence.log(`note over ${this.name} : ${label} = ${value}`);
|
||||||
|
}
|
||||||
|
displayValue.set(value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { Action } from './action.js';
|
||||||
|
import { Actor } from './actor.js';
|
||||||
|
import { CryptoUtil } from './crypto.js';
|
||||||
|
|
||||||
|
class Worker {
|
||||||
|
constructor(reputationPublicKey, tokenId, stakeAmount, duration) {
|
||||||
|
this.reputationPublicKey = reputationPublicKey;
|
||||||
|
this.tokenId = tokenId;
|
||||||
|
this.stakeAmount = stakeAmount;
|
||||||
|
this.duration = duration;
|
||||||
|
this.available = true;
|
||||||
|
this.assignedRequestId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purpose: Enable staking reputation to enter the pool of workers
|
||||||
|
*/
|
||||||
|
export class Availability extends Actor {
|
||||||
|
constructor(bench, name, scene) {
|
||||||
|
super(name, scene);
|
||||||
|
this.bench = bench;
|
||||||
|
|
||||||
|
this.actions = {
|
||||||
|
assignWork: new Action('assign work', scene),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.workers = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
register(reputationPublicKey, { stakeAmount, tokenId, duration }) {
|
||||||
|
// TODO: Should be signed by token owner
|
||||||
|
this.bench.reputation.lock(tokenId, stakeAmount, duration);
|
||||||
|
const workerId = CryptoUtil.randomUUID();
|
||||||
|
this.workers.set(workerId, new Worker(reputationPublicKey, tokenId, stakeAmount, duration));
|
||||||
|
return workerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get availableWorkers() {
|
||||||
|
return Array.from(this.workers.values()).filter(({ available }) => !!available);
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignWork(requestId) {
|
||||||
|
const totalAmountStaked = this.availableWorkers
|
||||||
|
.reduce((total, { stakeAmount }) => total += stakeAmount, 0);
|
||||||
|
// Imagine all these amounts layed end-to-end along a number line.
|
||||||
|
// To weight choice by amount staked, pick a stake by choosing a number at random
|
||||||
|
// from within that line segment.
|
||||||
|
const randomChoice = Math.random() * totalAmountStaked;
|
||||||
|
let index = 0;
|
||||||
|
let acc = 0;
|
||||||
|
for (const { stakeAmount } of this.workers.values()) {
|
||||||
|
acc += stakeAmount;
|
||||||
|
if (acc >= randomChoice) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
const worker = this.availableWorkers[index];
|
||||||
|
worker.available = false;
|
||||||
|
worker.assignedRequestId = requestId;
|
||||||
|
|
||||||
|
// TODO: Notify assignee
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAssignedWork(workerId) {
|
||||||
|
const worker = this.workers.get(workerId);
|
||||||
|
return worker.assignedRequestId;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { Actor } from './actor.js';
|
||||||
|
import { ValidationPool } from './validation-pool.js';
|
||||||
|
import params from '../params.js';
|
||||||
|
import { Action } from './action.js';
|
||||||
|
import { ReputationTokenContract } from './reputation-token.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purpose: Keep track of reputation holders
|
||||||
|
*/
|
||||||
|
export class Bench extends Actor {
|
||||||
|
constructor(forum, name, scene) {
|
||||||
|
super(name, scene);
|
||||||
|
this.forum = forum;
|
||||||
|
this.validationPools = new Map();
|
||||||
|
this.voters = new Map();
|
||||||
|
this.reputation = new ReputationTokenContract();
|
||||||
|
|
||||||
|
this.actions = {
|
||||||
|
createValidationPool: new Action('create validation pool', scene),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
listValidationPools() {
|
||||||
|
Array.from(this.validationPools.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
listActiveVoters() {
|
||||||
|
return Array.from(this.voters.values()).filter((voter) => {
|
||||||
|
const hasVoted = !!voter.dateLastVote;
|
||||||
|
const withinThreshold = !params.activeVoterThreshold
|
||||||
|
|| new Date() - voter.dateLastVote >= params.activeVoterThreshold;
|
||||||
|
return hasVoted && withinThreshold;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveReputation() {
|
||||||
|
return this.listActiveVoters()
|
||||||
|
.map(({ reputationPublicKey }) => this.reputation.valueOwnedBy(reputationPublicKey))
|
||||||
|
.reduce((acc, cur) => (acc += cur), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveAvailableReputation() {
|
||||||
|
return this.listActiveVoters()
|
||||||
|
.map(({ reputationPublicKey }) => this.reputation.availableValueOwnedBy(reputationPublicKey))
|
||||||
|
.reduce((acc, cur) => (acc += cur), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initiateValidationPool(poolOptions, stakeOptions) {
|
||||||
|
const validationPoolNumber = this.validationPools.size + 1;
|
||||||
|
const name = `Pool${validationPoolNumber}`;
|
||||||
|
const pool = new ValidationPool(this, this.forum, poolOptions, name, this.scene);
|
||||||
|
this.validationPools.set(pool.id, pool);
|
||||||
|
await this.actions.createValidationPool.log(this, pool);
|
||||||
|
pool.activate();
|
||||||
|
|
||||||
|
if (stakeOptions) {
|
||||||
|
const { reputationPublicKey, tokenId, authorStakeAmount } = stakeOptions;
|
||||||
|
await pool.stake(reputationPublicKey, {
|
||||||
|
tokenId,
|
||||||
|
position: true,
|
||||||
|
amount: authorStakeAmount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { DisplayValue } from './display-value.js';
|
||||||
|
import { CryptoUtil } from './crypto.js';
|
||||||
|
|
||||||
|
export class Box {
|
||||||
|
constructor(name, parentEl, elementType = 'div') {
|
||||||
|
this.name = name;
|
||||||
|
this.el = document.createElement(elementType);
|
||||||
|
this.el.id = `box_${CryptoUtil.randomUUID().replaceAll('-', '').slice(0, 8)}`;
|
||||||
|
this.el.classList.add('box');
|
||||||
|
if (name) {
|
||||||
|
this.el.setAttribute('box-name', name);
|
||||||
|
}
|
||||||
|
if (parentEl) {
|
||||||
|
parentEl.appendChild(this.el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flex() {
|
||||||
|
this.addClass('flex');
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
monospace() {
|
||||||
|
this.addClass('monospace');
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
hidden() {
|
||||||
|
this.addClass('hidden');
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
addClass(className) {
|
||||||
|
this.el.classList.add(className);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
addBox(name, elementType) {
|
||||||
|
const box = new Box(name, this.el, elementType);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
addDisplayValue(value) {
|
||||||
|
const box = this.addBox(value.name).flex();
|
||||||
|
return new DisplayValue(value, box);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInnerHTML(html) {
|
||||||
|
this.el.innerHTML = html;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
getInnerText() {
|
||||||
|
return this.el.innerText;
|
||||||
|
}
|
||||||
|
|
||||||
|
getId() {
|
||||||
|
return this.el.id;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { Action } from './action.js';
|
||||||
|
import { Actor } from './actor.js';
|
||||||
|
import { CryptoUtil } from './crypto.js';
|
||||||
|
import { PostContent } from './post-content.js';
|
||||||
|
|
||||||
|
class Request {
|
||||||
|
constructor(fee, content) {
|
||||||
|
this.id = CryptoUtil.randomUUID();
|
||||||
|
this.fee = fee;
|
||||||
|
this.content = content;
|
||||||
|
this.worker = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purpose: Enable fee-driven work requests, to be completed by workers from the availability pool
|
||||||
|
*/
|
||||||
|
export class Business extends Actor {
|
||||||
|
constructor(bench, forum, availability, name, scene) {
|
||||||
|
super(name, scene);
|
||||||
|
this.bench = bench;
|
||||||
|
this.forum = forum;
|
||||||
|
this.availability = availability;
|
||||||
|
|
||||||
|
this.actions = {
|
||||||
|
assignWork: new Action('assign work', scene),
|
||||||
|
submitPost: new Action('submit post', scene),
|
||||||
|
initiateValidationPool: new Action('initiate validation pool', scene),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.requests = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitRequest(fee, content) {
|
||||||
|
const request = new Request(fee, content);
|
||||||
|
this.requests.set(request.id, request);
|
||||||
|
await this.actions.assignWork.log(this, this.availability);
|
||||||
|
const worker = await this.availability.assignWork(request.id);
|
||||||
|
request.worker = worker;
|
||||||
|
return request.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRequest(requestId) {
|
||||||
|
const request = this.requests.get(requestId);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitWork(reputationPublicKey, requestId, workEvidence, { tokenLossRatio, duration }) {
|
||||||
|
const request = this.requests.get(requestId);
|
||||||
|
if (!request) {
|
||||||
|
throw new Error(`Request not found! id: ${requestId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reputationPublicKey !== request.worker.reputationPublicKey) {
|
||||||
|
throw new Error('Work evidence must be submitted by the assigned worker!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a post representing this submission.
|
||||||
|
const post = new PostContent({
|
||||||
|
requestId,
|
||||||
|
workEvidence,
|
||||||
|
});
|
||||||
|
const requestIndex = Array.from(this.requests.values())
|
||||||
|
.findIndex(({ id }) => id === request.id);
|
||||||
|
post.setTitle(`Work Evidence ${requestIndex + 1}`);
|
||||||
|
|
||||||
|
await this.actions.submitPost.log(this, this.forum);
|
||||||
|
const postId = await this.forum.addPost(reputationPublicKey, post);
|
||||||
|
|
||||||
|
// Initiate a validation pool for this work evidence.
|
||||||
|
await this.actions.initiateValidationPool.log(this, this.bench);
|
||||||
|
const pool = await this.bench.initiateValidationPool({
|
||||||
|
postId,
|
||||||
|
fee: request.fee,
|
||||||
|
duration,
|
||||||
|
tokenLossRatio,
|
||||||
|
}, {
|
||||||
|
reputationPublicKey,
|
||||||
|
authorStakeAmount: request.worker.stakeAmount,
|
||||||
|
tokenId: request.worker.tokenId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the validation pool concludes,
|
||||||
|
// reputation should be awarded and fees should be distributed.
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
export class CryptoUtil {
|
||||||
|
static algorithm = 'RSASSA-PKCS1-v1_5';
|
||||||
|
|
||||||
|
static hash = 'SHA-256';
|
||||||
|
|
||||||
|
static async generateAsymmetricKey() {
|
||||||
|
return window.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: CryptoUtil.algorithm,
|
||||||
|
hash: CryptoUtil.hash,
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicExponent: new Uint8Array([1, 0, 1]),
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
['sign', 'verify'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async sign(content, privateKey) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const encoded = encoder.encode(content);
|
||||||
|
const signature = await window.crypto.subtle.sign(CryptoUtil.algorithm, privateKey, encoded);
|
||||||
|
// Return base64-encoded signature
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
||||||
|
}
|
||||||
|
|
||||||
|
static async verify(content, b64publicKey, b64signature) {
|
||||||
|
// Convert base64 javascript web key to CryptoKey
|
||||||
|
const publicKey = await CryptoUtil.importKey(b64publicKey);
|
||||||
|
// Convert base64 signature to an ArrayBuffer
|
||||||
|
const signature = Uint8Array.from(atob(b64signature), (c) => c.charCodeAt(0));
|
||||||
|
// TODO: make a single TextEncoder instance and reuse it
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const encoded = encoder.encode(content);
|
||||||
|
return window.crypto.subtle.verify(CryptoUtil.algorithm, publicKey, signature, encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async exportKey(publicKey) {
|
||||||
|
// Store public key as base64 javascript web key
|
||||||
|
const jwk = await window.crypto.subtle.exportKey('jwk', publicKey);
|
||||||
|
return btoa(JSON.stringify(jwk));
|
||||||
|
}
|
||||||
|
|
||||||
|
static async importKey(b64jwk) {
|
||||||
|
// Convert base64 javascript web key to CryptoKey
|
||||||
|
const jwk = JSON.parse(atob(b64jwk));
|
||||||
|
return window.crypto.subtle.importKey(
|
||||||
|
'jwk',
|
||||||
|
jwk,
|
||||||
|
{
|
||||||
|
name: CryptoUtil.algorithm,
|
||||||
|
hash: CryptoUtil.hash,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
['verify'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static randomUUID() {
|
||||||
|
return window.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
export class DisplayValue {
|
||||||
|
constructor(name, box) {
|
||||||
|
this.value = undefined;
|
||||||
|
this.name = name;
|
||||||
|
this.box = box;
|
||||||
|
this.nameBox = this.box.addBox(`${this.name}-name`).addClass('name');
|
||||||
|
this.valueBox = this.box.addBox(`${this.name}-value`).addClass('value');
|
||||||
|
this.nameBox.setInnerHTML(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.valueBox.setInnerHTML(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(value) {
|
||||||
|
this.value = value;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
get() {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
/**
|
||||||
|
* ERC-721 Non-Fungible Token Standard
|
||||||
|
* See https://eips.ethereum.org/EIPS/eip-721
|
||||||
|
* and https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC721/ERC721.sol
|
||||||
|
*
|
||||||
|
* This implementation is currently incomplete. It lacks the following:
|
||||||
|
* - Token approvals
|
||||||
|
* - Operator approvals
|
||||||
|
* - Emitting events
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ERC721 /* is ERC165 */ {
|
||||||
|
constructor(name, symbol) {
|
||||||
|
this.name = name;
|
||||||
|
this.symbol = symbol;
|
||||||
|
this.balances = new Map(); // owner address --> token count
|
||||||
|
this.owners = new Map(); // token id --> owner address
|
||||||
|
// this.tokenApprovals = new Map(); // token id --> approved addresses
|
||||||
|
// this.operatorApprovals = new Map(); // owner --> operator approvals
|
||||||
|
|
||||||
|
this.events = {
|
||||||
|
// Transfer: (_from, _to, _tokenId) => {},
|
||||||
|
// Approval: (_owner, _approved, _tokenId) => {},
|
||||||
|
// ApprovalForAll: (_owner, _operator, _approved) => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementBalance(owner, increment) {
|
||||||
|
const balance = this.balances.get(owner) ?? 0;
|
||||||
|
this.balances.set(owner, balance + increment);
|
||||||
|
}
|
||||||
|
|
||||||
|
mint(to, tokenId) {
|
||||||
|
if (this.owners.get(tokenId)) {
|
||||||
|
throw new Error('ERC721: token already minted');
|
||||||
|
}
|
||||||
|
this.incrementBalance(to, 1);
|
||||||
|
this.owners.set(tokenId, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
burn(tokenId) {
|
||||||
|
const owner = this.owners.get(tokenId);
|
||||||
|
this.incrementBalance(owner, -1);
|
||||||
|
this.owners.delete(tokenId);
|
||||||
|
}
|
||||||
|
|
||||||
|
balanceOf(owner) {
|
||||||
|
if (!owner) {
|
||||||
|
throw new Error('ERC721: address zero is not a valid owner');
|
||||||
|
}
|
||||||
|
return this.balances.get(owner) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerOf(tokenId) {
|
||||||
|
const owner = this.owners.get(tokenId);
|
||||||
|
if (!owner) {
|
||||||
|
throw new Error(`ERC721: invalid token ID: ${tokenId}`);
|
||||||
|
}
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
transferFrom(from, to, tokenId) {
|
||||||
|
const owner = this.owners.get(tokenId);
|
||||||
|
if (owner !== from) {
|
||||||
|
throw new Error('ERC721: transfer from incorrect owner');
|
||||||
|
}
|
||||||
|
this.incrementBalance(from, -1);
|
||||||
|
this.incrementBalance(to, 1);
|
||||||
|
this.owners.set(tokenId, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @notice Enable or disable approval for a third party ("operator") to manage
|
||||||
|
/// all of `msg.sender`'s assets
|
||||||
|
/// @dev Emits the ApprovalForAll event. The contract MUST allow
|
||||||
|
/// multiple operators per owner.
|
||||||
|
/// @param _operator Address to add to the set of authorized operators
|
||||||
|
/// @param _approved True if the operator is approved, false to revoke approval
|
||||||
|
// setApprovalForAll(_operator, _approved) {}
|
||||||
|
|
||||||
|
/// @notice Get the approved address for a single NFT
|
||||||
|
/// @dev Throws if `_tokenId` is not a valid NFT.
|
||||||
|
/// @param _tokenId The NFT to find the approved address for
|
||||||
|
/// @return The approved address for this NFT, or the zero address if there is none
|
||||||
|
// getApproved(_tokenId) {}
|
||||||
|
|
||||||
|
/// @notice Query if an address is an authorized operator for another address
|
||||||
|
/// @param _owner The address that owns the NFTs
|
||||||
|
/// @param _operator The address that acts on behalf of the owner
|
||||||
|
/// @return True if `_operator` is an approved operator for `_owner`, false otherwise
|
||||||
|
// isApprovedForAll(_owner, _operator) {}
|
||||||
|
}
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { Action } from './action.js';
|
||||||
|
import { PostMessage } from './message.js';
|
||||||
|
import { CryptoUtil } from './crypto.js';
|
||||||
|
import { ReputationHolder } from './reputation-holder.js';
|
||||||
|
|
||||||
|
export class Expert extends ReputationHolder {
|
||||||
|
constructor(name, scene) {
|
||||||
|
super(undefined, name, scene);
|
||||||
|
this.actions = {
|
||||||
|
submitPostViaNetwork: new Action('submit post via network', scene),
|
||||||
|
submitPost: new Action('submit post', scene),
|
||||||
|
initiateValidationPool: new Action('initiate validation pool', scene),
|
||||||
|
stake: new Action('stake on post', scene),
|
||||||
|
registerAvailability: new Action('register availability', scene),
|
||||||
|
getAssignedWork: new Action('get assigned work', scene),
|
||||||
|
submitWork: new Action('submit work evidence', scene),
|
||||||
|
};
|
||||||
|
this.validationPools = new Map();
|
||||||
|
this.tokens = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
this.reputationKey = await CryptoUtil.generateAsymmetricKey();
|
||||||
|
// this.reputationPublicKey = await CryptoUtil.exportKey(this.reputationKey.publicKey);
|
||||||
|
this.reputationPublicKey = this.name;
|
||||||
|
this.status.set('Initialized');
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitPostViaNetwork(forumNode, post, stake) {
|
||||||
|
// TODO: Include fee
|
||||||
|
const postMessage = new PostMessage({ post, stake });
|
||||||
|
await postMessage.sign(this.reputationKey);
|
||||||
|
await this.actions.submitPostViaNetwork.log(this, forumNode, null, { id: post.id });
|
||||||
|
// For now, directly call forumNode.receiveMessage();
|
||||||
|
await forumNode.receiveMessage(JSON.stringify(postMessage.toJSON()));
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitPost(forum, postContent) {
|
||||||
|
// TODO: Include fee
|
||||||
|
await this.actions.submitPost.log(this, forum);
|
||||||
|
return forum.addPost(this.reputationPublicKey, postContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitPostWithFee(bench, forum, postContent, poolOptions) {
|
||||||
|
await this.actions.submitPost.log(this, forum);
|
||||||
|
const postId = await forum.addPost(this.reputationPublicKey, postContent);
|
||||||
|
const pool = await this.initiateValidationPool(bench, { ...poolOptions, postId });
|
||||||
|
this.tokens.push(pool.tokenId);
|
||||||
|
return { postId, pool };
|
||||||
|
}
|
||||||
|
|
||||||
|
async initiateValidationPool(bench, poolOptions) {
|
||||||
|
// For now, directly call bench.initiateValidationPool();
|
||||||
|
poolOptions.reputationPublicKey = this.reputationPublicKey;
|
||||||
|
await this.actions.initiateValidationPool.log(
|
||||||
|
this,
|
||||||
|
bench,
|
||||||
|
`(fee: ${poolOptions.fee}, stake: ${poolOptions.authorStakeAmount ?? 0})`,
|
||||||
|
);
|
||||||
|
const pool = await bench.initiateValidationPool(poolOptions);
|
||||||
|
this.tokens.push(pool.tokenId);
|
||||||
|
this.validationPools.set(pool.id, poolOptions);
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stake(validationPool, {
|
||||||
|
position, amount, lockingTime,
|
||||||
|
}) {
|
||||||
|
// TODO: encrypt stake
|
||||||
|
// TODO: sign message
|
||||||
|
await this.actions.stake.log(
|
||||||
|
this,
|
||||||
|
validationPool,
|
||||||
|
`(${position ? 'for' : 'against'}, stake: ${amount})`,
|
||||||
|
);
|
||||||
|
return validationPool.stake(this.reputationPublicKey, {
|
||||||
|
position, amount, lockingTime, tokenId: this.tokens[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerAvailability(availability, stakeAmount, duration) {
|
||||||
|
await this.actions.registerAvailability.log(this, availability, `(stake: ${stakeAmount}, duration: ${duration})`);
|
||||||
|
this.workerId = await availability.register(this.reputationPublicKey, {
|
||||||
|
stakeAmount,
|
||||||
|
tokenId: this.tokens[0],
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAssignedWork(availability, business) {
|
||||||
|
const requestId = await availability.getAssignedWork(this.workerId);
|
||||||
|
const request = await business.getRequest(requestId);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitWork(business, requestId, evidence, { tokenLossRatio, duration }) {
|
||||||
|
await this.actions.submitWork.log(this, business);
|
||||||
|
return business.submitWork(this.reputationPublicKey, requestId, evidence, { tokenLossRatio, duration });
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
export class Token {
|
||||||
|
constructor(ownerPublicKey) {
|
||||||
|
this.ownerPublicKey = ownerPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
transfer(newOwnerPublicKey) {
|
||||||
|
// TODO: Current owner must sign this request
|
||||||
|
this.ownerPublicKey = newOwnerPublicKey;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
export class ForumNetwork {
|
||||||
|
constructor() {
|
||||||
|
this.nodes = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
addNode(node) {
|
||||||
|
this.nodes.set(node.keyPair.publicKey, node);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
listNodes() {
|
||||||
|
return Array.from(this.nodes.values());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { Actor } from './actor.js';
|
||||||
|
import { Action } from './action.js';
|
||||||
|
import {
|
||||||
|
Message, PostMessage, PeerMessage, messageFromJSON,
|
||||||
|
} from './message.js';
|
||||||
|
import { CryptoUtil } from './crypto.js';
|
||||||
|
import { ForumView } from './forum-view.js';
|
||||||
|
import { PrioritizedQueue } from './prioritized-queue.js';
|
||||||
|
|
||||||
|
export class ForumNode extends Actor {
|
||||||
|
constructor(name, scene) {
|
||||||
|
super(name, scene);
|
||||||
|
this.forumView = new ForumView();
|
||||||
|
this.queue = new PrioritizedQueue();
|
||||||
|
this.actions = {
|
||||||
|
storePost: new Action('store post', scene),
|
||||||
|
peerMessage: new Action('peer message', scene),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a signing key pair and connect to the network
|
||||||
|
async initialize(forumNetwork) {
|
||||||
|
this.keyPair = await CryptoUtil.generateAsymmetricKey();
|
||||||
|
this.forumNetwork = forumNetwork.addNode(this);
|
||||||
|
this.status.set('Initialized');
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a message to all other nodes in the network
|
||||||
|
async broadcast(message) {
|
||||||
|
await message.sign(this.keyPair);
|
||||||
|
const otherForumNodes = this.forumNetwork
|
||||||
|
.listNodes()
|
||||||
|
.filter((forumNode) => forumNode.keyPair.publicKey !== this.keyPair.publicKey);
|
||||||
|
for (const forumNode of otherForumNodes) {
|
||||||
|
// For now just call receiveMessage on the target node
|
||||||
|
await this.actions.peerMessage.log(this, forumNode, null, message.content);
|
||||||
|
await forumNode.receiveMessage(JSON.stringify(message.toJSON()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform minimal processing to ingest a message.
|
||||||
|
// Enqueue it for further processing.
|
||||||
|
async receiveMessage(messageStr) {
|
||||||
|
const messageJson = JSON.parse(messageStr);
|
||||||
|
const senderReputation = this.forumView.getReputation(messageJson.publicKey) || 0;
|
||||||
|
this.queue.add(messageJson, senderReputation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process next highest priority message in the queue
|
||||||
|
async processNextMessage() {
|
||||||
|
const messageJson = this.queue.pop();
|
||||||
|
if (!messageJson) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.processMessage(messageJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process a message from the queue
|
||||||
|
async processMessage(messageJson) {
|
||||||
|
try {
|
||||||
|
await Message.verify(messageJson);
|
||||||
|
} catch (e) {
|
||||||
|
await this.actions.processMessage.log(this, this, 'invalid signature', messageJson, '-x');
|
||||||
|
console.log(`${this.name}: received message with invalid signature`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { publicKey } = messageJson;
|
||||||
|
const message = messageFromJSON(messageJson);
|
||||||
|
console.log(`${this.name}: processMessage`, message);
|
||||||
|
|
||||||
|
if (message instanceof PostMessage) {
|
||||||
|
await this.processPostMessage(publicKey, message.content);
|
||||||
|
} else if (message instanceof PeerMessage) {
|
||||||
|
await this.processPeerMessage(publicKey, message.content);
|
||||||
|
} else {
|
||||||
|
// Unknown message type
|
||||||
|
// Penalize sender for wasting our time
|
||||||
|
console.log(`${this.name}: penalizing sender for unknown message type ${message.type}`);
|
||||||
|
this.forumView.incrementReputation(message.publicKey, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process an incoming post, received by whatever means
|
||||||
|
async processPost(authorId, post, stake) {
|
||||||
|
if (!post.id) {
|
||||||
|
post.id = CryptoUtil.randomUUID();
|
||||||
|
}
|
||||||
|
await this.actions.storePost.log(this, this, null, { authorId, post, stake });
|
||||||
|
this.forumView.addPost(authorId, post.id, post, stake);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process a post we received in a message
|
||||||
|
async processPostMessage(authorId, { post, stake }) {
|
||||||
|
this.processPost(authorId, post, stake);
|
||||||
|
await this.broadcast(
|
||||||
|
new PeerMessage({
|
||||||
|
posts: [{ authorId, post, stake }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process a message we receive from a peer
|
||||||
|
async processPeerMessage(peerId, { posts }) {
|
||||||
|
// We are trusting that the peer verified the signatures of the posts they're forwarding.
|
||||||
|
// We could instead have the peer forward the signed messages and re-verify them.
|
||||||
|
for (const { authorId, post, stake } of posts) {
|
||||||
|
this.processPost(authorId, post, stake);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { Graph } from './graph.js';
|
||||||
|
|
||||||
|
class Author {
|
||||||
|
constructor() {
|
||||||
|
this.posts = new Map();
|
||||||
|
this.reputation = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PostVertex {
|
||||||
|
constructor(id, author, stake, content, citations) {
|
||||||
|
this.id = id;
|
||||||
|
this.author = author;
|
||||||
|
this.content = content;
|
||||||
|
this.stake = stake;
|
||||||
|
this.citations = citations;
|
||||||
|
this.reputation = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForumView {
|
||||||
|
constructor() {
|
||||||
|
this.reputations = new Map();
|
||||||
|
this.posts = new Graph();
|
||||||
|
this.authors = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
getReputation(id) {
|
||||||
|
return this.reputations.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setReputation(id, reputation) {
|
||||||
|
this.reputations.set(id, reputation);
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementReputation(publicKey, increment, _reason) {
|
||||||
|
const reputation = this.getReputation(publicKey) || 0;
|
||||||
|
return this.reputations.set(publicKey, reputation + increment);
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrInitializeAuthor(authorId) {
|
||||||
|
let author = this.authors.get(authorId);
|
||||||
|
if (!author) {
|
||||||
|
author = new Author(authorId);
|
||||||
|
this.authors.set(authorId, author);
|
||||||
|
}
|
||||||
|
return author;
|
||||||
|
}
|
||||||
|
|
||||||
|
addPost(authorId, postId, postContent, stake) {
|
||||||
|
const { citations = [], content } = postContent;
|
||||||
|
const author = this.getOrInitializeAuthor(authorId);
|
||||||
|
const postVertex = new PostVertex(postId, author, stake, content, citations);
|
||||||
|
console.log('addPost', { id: postId, postContent });
|
||||||
|
this.posts.addVertex(postId, postVertex);
|
||||||
|
for (const citation of citations) {
|
||||||
|
this.posts.addEdge('citation', postId, citation.postId, citation);
|
||||||
|
}
|
||||||
|
this.applyNonbindingReputationEffects(postVertex);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPost(postId) {
|
||||||
|
return this.posts.getVertexData(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPosts() {
|
||||||
|
return this.posts.getVertices();
|
||||||
|
}
|
||||||
|
|
||||||
|
// We'll start with naieve implementations of the computations we need.
|
||||||
|
|
||||||
|
// We want to derive a value -- maybe call it a reputation score -- for each post.
|
||||||
|
// This value is a recursive sum of contributions from citations.
|
||||||
|
|
||||||
|
// There should be a diminishment of effect upon each recursion,
|
||||||
|
// perhaps following a geometric progression.
|
||||||
|
|
||||||
|
// Each post gets some initial score due to the reputation that the author stakes.
|
||||||
|
|
||||||
|
// Citations are weighted, and can be positive or negative.
|
||||||
|
|
||||||
|
// So each post has a reputation score. Each author also has a reputation score.
|
||||||
|
// The value of the author's reputation score could be a factor in the magnitude of the effects of their citations.
|
||||||
|
// Post_rep = (Author_rep * stake);
|
||||||
|
//
|
||||||
|
|
||||||
|
// Options:
|
||||||
|
// - update a state model incrementally with each action in the history (/unfolding present) of the forum,
|
||||||
|
// in order to arrive at the current view.
|
||||||
|
|
||||||
|
// When an author stakes reputation on a post, if it's a non-binding stake, then it merely expresses opinion.
|
||||||
|
// If it's a binding stake, then they may lose the staked reputation as a result of other posts
|
||||||
|
// staking reputation against theirs.
|
||||||
|
|
||||||
|
citationFraction = 0.3;
|
||||||
|
|
||||||
|
applyNonbindingReputationEffects(newPost) {
|
||||||
|
this.distributeNonbindingReputation(newPost, newPost, newPost.stake);
|
||||||
|
}
|
||||||
|
|
||||||
|
distributeNonbindingReputation(newPost, post, amount, depth = 0) {
|
||||||
|
console.log('distributeNonbindingReputation', { post, amount, depth });
|
||||||
|
// Some of the incoming reputation goes to this post
|
||||||
|
post.reputation += amount * (1 - this.citationFraction);
|
||||||
|
this.setReputation(post.id, post.reputation);
|
||||||
|
// Some of the incoming reputation gets distributed among cited posts
|
||||||
|
const distributeAmongCitations = amount * this.citationFraction;
|
||||||
|
|
||||||
|
// citation weights can be interpreted as a ratio, or we can somehow constrain the input
|
||||||
|
// to add up to some specified total.
|
||||||
|
// It's easy enough to let them be on any arbitrary scale and just compute the ratios here.
|
||||||
|
const totalWeight = post.citations
|
||||||
|
?.map(({ weight }) => weight)
|
||||||
|
.reduce((acc, cur) => (acc += cur), 0);
|
||||||
|
|
||||||
|
post.citations?.forEach((citation) => {
|
||||||
|
const citedPost = this.getPost(citation.postId);
|
||||||
|
if (!citedPost) {
|
||||||
|
// TODO: Here is where we may want to engage our peer protocol to query for possible missing records
|
||||||
|
throw new Error(`Post ${post.postId} cites unknown post ${citation.postId}`);
|
||||||
|
}
|
||||||
|
this.distributeNonbindingReputation(
|
||||||
|
newPost,
|
||||||
|
citedPost,
|
||||||
|
(citation.weight / totalWeight) * distributeAmongCitations,
|
||||||
|
depth + 1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { Actor } from './actor.js';
|
||||||
|
import { Graph } from './graph.js';
|
||||||
|
import { Action } from './action.js';
|
||||||
|
import { CryptoUtil } from './crypto.js';
|
||||||
|
import params from '../params.js';
|
||||||
|
import { ReputationHolder } from './reputation-holder.js';
|
||||||
|
|
||||||
|
class Post extends Actor {
|
||||||
|
constructor(forum, authorPublicKey, postContent) {
|
||||||
|
const index = forum.posts.countVertices();
|
||||||
|
const name = `Post${index + 1}`;
|
||||||
|
super(name, forum.scene);
|
||||||
|
this.id = postContent.id ?? `post_${CryptoUtil.randomUUID()}`;
|
||||||
|
this.authorPublicKey = authorPublicKey;
|
||||||
|
this.value = 0;
|
||||||
|
this.citations = postContent.citations;
|
||||||
|
this.title = postContent.title;
|
||||||
|
const revaluationTotal = this.citations.reduce((total, { weight }) => total += Math.abs(weight), 0);
|
||||||
|
if (revaluationTotal > params.revaluationLimit) {
|
||||||
|
throw new Error('Post revaluation total exceeds revaluation limit '
|
||||||
|
+ `(${revaluationTotal} > ${params.revaluationLimit})`);
|
||||||
|
}
|
||||||
|
if (this.citations.some(({ weight }) => Math.abs(weight) > 1)) {
|
||||||
|
throw new Error('Each citation weight must be in the range [-1, 1]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purpose: Maintain a directed, acyclic, weighted graph of posts referencing other posts
|
||||||
|
*/
|
||||||
|
export class Forum extends ReputationHolder {
|
||||||
|
constructor(name, scene) {
|
||||||
|
super(`forum_${CryptoUtil.randomUUID()}`, name, scene);
|
||||||
|
this.id = this.reputationPublicKey;
|
||||||
|
this.posts = new Graph(scene);
|
||||||
|
this.actions = {
|
||||||
|
addPost: new Action('add post', scene),
|
||||||
|
propagateValue: new Action('propagate value', this.scene),
|
||||||
|
transfer: new Action('transfer', this.scene),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPost(authorId, postContent) {
|
||||||
|
const post = new Post(this, authorId, postContent);
|
||||||
|
await this.actions.addPost.log(this, post);
|
||||||
|
this.posts.addVertex(post.id, post, post.title);
|
||||||
|
if (this.scene.flowchart) {
|
||||||
|
this.scene.flowchart.log(`${post.id} -- value --> ${post.id}_value[0]`);
|
||||||
|
}
|
||||||
|
for (const { postId: citedPostId, weight } of post.citations) {
|
||||||
|
this.posts.addEdge('citation', post.id, citedPostId, { weight });
|
||||||
|
if (this.scene.flowchart) {
|
||||||
|
this.scene.flowchart.log(`${post.id} -- ${weight} --> ${citedPostId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return post.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPost(postId) {
|
||||||
|
return this.posts.getVertexData(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPosts() {
|
||||||
|
return this.posts.getVerticesData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPostValue(post, value) {
|
||||||
|
post.value = value;
|
||||||
|
await post.setValue('value', value);
|
||||||
|
if (this.scene.flowchart) {
|
||||||
|
this.scene.flowchart.log(`${post.id}_value[${value}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getTotalValue() {
|
||||||
|
return this.getPosts().reduce((total, { value }) => total += value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onValidate({
|
||||||
|
bench, pool, postId, tokenId,
|
||||||
|
}) {
|
||||||
|
this.activate();
|
||||||
|
const initialValue = bench.reputation.valueOf(tokenId);
|
||||||
|
|
||||||
|
if (this.scene.flowchart) {
|
||||||
|
this.scene.flowchart.log(`${postId}_initial_value[${initialValue}] -- initial value --> ${postId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = this.getPost(postId);
|
||||||
|
post.setStatus('Validated');
|
||||||
|
|
||||||
|
// Store a reference to the reputation token associated with this post,
|
||||||
|
// so that its value can be updated by future validated posts.
|
||||||
|
post.tokenId = tokenId;
|
||||||
|
|
||||||
|
// Compute rewards
|
||||||
|
const rewardsAccumulator = new Map();
|
||||||
|
await this.propagateValue(rewardsAccumulator, pool, post, initialValue);
|
||||||
|
|
||||||
|
// Apply computed rewards to update values of tokens
|
||||||
|
for (const [id, value] of rewardsAccumulator) {
|
||||||
|
bench.reputation.transferValueFrom(post.tokenId, id, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer ownership of the minted/staked token, from the forum to the post author
|
||||||
|
bench.reputation.transferFrom(this.id, post.authorPublicKey, post.tokenId);
|
||||||
|
const toActor = this.scene.findActor((actor) => actor.reputationPublicKey === post.authorPublicKey);
|
||||||
|
const value = bench.reputation.valueOf(post.tokenId);
|
||||||
|
this.actions.transfer.log(this, toActor, `(value: ${value})`);
|
||||||
|
this.deactivate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async propagateValue(rewardsAccumulator, fromActor, post, increment, depth = 0) {
|
||||||
|
if (params.referenceChainLimit >= 0 && depth > params.referenceChainLimit) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.actions.propagateValue.log(fromActor, post, `(${increment})`);
|
||||||
|
|
||||||
|
// Recursively distribute reputation to citations, according to weights
|
||||||
|
let totalOutboundAmount = 0;
|
||||||
|
let refundFromOutbound = 0;
|
||||||
|
for (const { postId: citedPostId, weight } of post.citations) {
|
||||||
|
const citedPost = this.getPost(citedPostId);
|
||||||
|
const outboundAmount = weight * increment;
|
||||||
|
totalOutboundAmount += outboundAmount;
|
||||||
|
refundFromOutbound += await this.propagateValue(rewardsAccumulator, post, citedPost, outboundAmount, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply leaching value
|
||||||
|
const incrementAfterLeaching = increment - (totalOutboundAmount - refundFromOutbound) * params.leachingValue;
|
||||||
|
|
||||||
|
// Prevent value from decreasing below zero
|
||||||
|
const rawNewValue = post.value + incrementAfterLeaching;
|
||||||
|
const newValue = Math.max(0, rawNewValue);
|
||||||
|
// We "refund" the amount that could not be applied.
|
||||||
|
// Note that this will always be a negative quantity, because this situation only arises when increment is negative.
|
||||||
|
const refundToInbound = rawNewValue - newValue;
|
||||||
|
const appliedIncrement = newValue - post.value;
|
||||||
|
|
||||||
|
// Award reputation to post author
|
||||||
|
rewardsAccumulator.set(post.tokenId, appliedIncrement);
|
||||||
|
|
||||||
|
// Increment the value of the post
|
||||||
|
await this.setPostValue(post, newValue);
|
||||||
|
|
||||||
|
return refundToInbound;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,105 @@
|
||||||
|
export class Vertex {
|
||||||
|
constructor(data) {
|
||||||
|
this.data = data;
|
||||||
|
this.edges = {
|
||||||
|
from: [],
|
||||||
|
to: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getEdges(label, away) {
|
||||||
|
return this.edges[away ? 'from' : 'to'].filter(
|
||||||
|
(edge) => edge.label === label,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Edge {
|
||||||
|
constructor(label, from, to, data) {
|
||||||
|
this.from = from;
|
||||||
|
this.to = to;
|
||||||
|
this.label = label;
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CategorizedEdges {}
|
||||||
|
|
||||||
|
export class Graph {
|
||||||
|
constructor(scene) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.vertices = new Map();
|
||||||
|
this.edgeLabels = new Map();
|
||||||
|
this.nextVertexId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
addVertex(id, data, label) {
|
||||||
|
// Support simple case of auto-incremented numeric ids
|
||||||
|
if (typeof id === 'object') {
|
||||||
|
data = id;
|
||||||
|
id = this.nextVertexId++;
|
||||||
|
}
|
||||||
|
if (this.vertices.has(id)) {
|
||||||
|
throw new Error(`Vertex already exists with id: ${id}`);
|
||||||
|
}
|
||||||
|
const vertex = new Vertex(data);
|
||||||
|
this.vertices.set(id, vertex);
|
||||||
|
if (this.scene.flowchart) {
|
||||||
|
this.scene.flowchart.log(`${id}[${label ?? id}]`);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
getVertex(id) {
|
||||||
|
return this.vertices.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getVertexData(id) {
|
||||||
|
return this.getVertex(id)?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
getVerticesData() {
|
||||||
|
return Array.from(this.vertices.values()).map(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getEdge(label, from, to) {
|
||||||
|
const edges = this.edgeLabels.get(label);
|
||||||
|
return edges?.get(JSON.stringify({ from, to }));
|
||||||
|
}
|
||||||
|
|
||||||
|
setEdge(label, from, to, edge) {
|
||||||
|
let edges = this.edgeLabels.get(label);
|
||||||
|
if (!edges) {
|
||||||
|
edges = new Map();
|
||||||
|
this.edgeLabels.set(label, edges);
|
||||||
|
}
|
||||||
|
edges.set(JSON.stringify({ from, to }), edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEdge(label, from, to, data) {
|
||||||
|
if (this.getEdge(label, from, to)) {
|
||||||
|
throw new Error(`Edge ${label} from ${from} to ${to} already exists`);
|
||||||
|
}
|
||||||
|
const edge = new Edge(label, from, to, data);
|
||||||
|
this.setEdge(label, from, to, edge);
|
||||||
|
this.getVertex(from).edges.from.push(edge);
|
||||||
|
this.getVertex(to).edges.to.push(edge);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEdges(label, from, to) {
|
||||||
|
const edgeLabels = label ? [label] : Array.from(this.edgeLabels.keys());
|
||||||
|
return edgeLabels.flatMap((edgeLabel) => {
|
||||||
|
const edges = this.edgeLabels.get(edgeLabel);
|
||||||
|
return Array.from(edges?.values() || []).filter((edge) => {
|
||||||
|
const matchFrom = from === null || from === undefined || from === edge.from;
|
||||||
|
const matchTo = to === null || to === undefined || to === edge.to;
|
||||||
|
return matchFrom && matchTo;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
countVertices() {
|
||||||
|
return this.vertices.size;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { CryptoUtil } from './crypto.js';
|
||||||
|
import { PostContent } from './post-content.js';
|
||||||
|
|
||||||
|
export class Message {
|
||||||
|
constructor(content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sign({ publicKey, privateKey }) {
|
||||||
|
this.publicKey = await CryptoUtil.exportKey(publicKey);
|
||||||
|
// Call toJSON before signing, to match what we'll later send
|
||||||
|
this.signature = await CryptoUtil.sign(this.contentToJSON(), privateKey);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async verify({ content, publicKey, signature }) {
|
||||||
|
return CryptoUtil.verify(content, publicKey, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
contentToJSON() {
|
||||||
|
return this.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
content: this.contentToJSON(),
|
||||||
|
publicKey: this.publicKey,
|
||||||
|
signature: this.signature,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PostMessage extends Message {
|
||||||
|
type = 'post';
|
||||||
|
|
||||||
|
constructor({ post, stake }) {
|
||||||
|
super({
|
||||||
|
post: PostContent.fromJSON(post),
|
||||||
|
stake,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
contentToJSON() {
|
||||||
|
return {
|
||||||
|
post: this.content.post.toJSON(),
|
||||||
|
stakeAmount: this.content.stake,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PeerMessage extends Message {
|
||||||
|
type = 'peer';
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageTypes = new Map([
|
||||||
|
['post', PostMessage],
|
||||||
|
['peer', PeerMessage],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const messageFromJSON = ({ type, content }) => {
|
||||||
|
const MessageType = messageTypes.get(type) || Message;
|
||||||
|
// const messageContent = MessageType.contentFromJSON(content);
|
||||||
|
return new MessageType(content);
|
||||||
|
};
|
|
@ -0,0 +1,54 @@
|
||||||
|
export class Citation {
|
||||||
|
constructor(postId, weight) {
|
||||||
|
this.postId = postId;
|
||||||
|
this.weight = weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
postId: this.postId,
|
||||||
|
weight: this.weight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON({ postId, weight }) {
|
||||||
|
return new Citation(postId, weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PostContent {
|
||||||
|
constructor(content) {
|
||||||
|
this.content = content;
|
||||||
|
this.citations = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
addCitation(postId, weight) {
|
||||||
|
const citation = new Citation(postId, weight);
|
||||||
|
this.citations.push(citation);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTitle(title) {
|
||||||
|
this.title = title;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
content: this.content,
|
||||||
|
citations: this.citations.map((citation) => citation.toJSON()),
|
||||||
|
...(this.id ? { id: this.id } : {}),
|
||||||
|
title: this.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON({
|
||||||
|
id, content, citations, title,
|
||||||
|
}) {
|
||||||
|
const post = new PostContent(content);
|
||||||
|
post.citations = citations.map((citation) => Citation.fromJSON(citation));
|
||||||
|
post.id = id;
|
||||||
|
post.title = title;
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
export class PrioritizedQueue {
|
||||||
|
constructor() {
|
||||||
|
this.buffer = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add an item to the buffer, ahead of the next lowest priority item
|
||||||
|
add(message, priority) {
|
||||||
|
const idx = this.buffer.findIndex((item) => item.priority < priority);
|
||||||
|
if (idx < 0) {
|
||||||
|
this.buffer.push({ message, priority });
|
||||||
|
} else {
|
||||||
|
this.buffer.splice(idx, 0, { message, priority });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the highest priority item in the buffer
|
||||||
|
pop() {
|
||||||
|
if (!this.buffer.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const item = this.buffer.shift();
|
||||||
|
return item.message;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Action } from './action.js';
|
||||||
|
import { Actor } from './actor.js';
|
||||||
|
|
||||||
|
export class Public extends Actor {
|
||||||
|
constructor(name, scene) {
|
||||||
|
super(name, scene);
|
||||||
|
this.actions = {
|
||||||
|
submitRequest: new Action('submit work request', scene),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitRequest(business, { fee }, content) {
|
||||||
|
this.actions.submitRequest.log(this, business, `(fee: ${fee})`);
|
||||||
|
return business.submitRequest(fee, content);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { Actor } from './actor.js';
|
||||||
|
|
||||||
|
export class ReputationHolder extends Actor {
|
||||||
|
constructor(reputationPublicKey, name, scene) {
|
||||||
|
super(name, scene);
|
||||||
|
this.reputationPublicKey = reputationPublicKey;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { ERC721 } from './erc721.js';
|
||||||
|
import { CryptoUtil } from './crypto.js';
|
||||||
|
|
||||||
|
const EPSILON = 2.23e-16;
|
||||||
|
|
||||||
|
class Lock {
|
||||||
|
constructor(tokenId, amount, duration) {
|
||||||
|
this.dateCreated = new Date();
|
||||||
|
this.tokenId = tokenId;
|
||||||
|
this.amount = amount;
|
||||||
|
this.duration = duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReputationTokenContract extends ERC721 {
|
||||||
|
constructor() {
|
||||||
|
super('Reputation', 'REP');
|
||||||
|
this.histories = new Map(); // token id --> {increment, context (i.e. validation pool id)}
|
||||||
|
this.values = new Map(); // token id --> current value
|
||||||
|
this.locks = new Set(); // {tokenId, amount, start, duration}
|
||||||
|
}
|
||||||
|
|
||||||
|
mint(to, value, context) {
|
||||||
|
const tokenId = `token_${CryptoUtil.randomUUID()}`;
|
||||||
|
super.mint(to, tokenId);
|
||||||
|
this.values.set(tokenId, value);
|
||||||
|
this.histories.set(tokenId, [{ increment: value, context }]);
|
||||||
|
return tokenId;
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementValue(tokenId, increment, context) {
|
||||||
|
const value = this.values.get(tokenId);
|
||||||
|
const newValue = value + increment;
|
||||||
|
const history = this.histories.get(tokenId) || [];
|
||||||
|
|
||||||
|
if (newValue < -EPSILON) {
|
||||||
|
throw new Error(`Token value can not become negative. Attempted to set value = ${newValue}`);
|
||||||
|
}
|
||||||
|
this.values.set(tokenId, newValue);
|
||||||
|
history.push({ increment, context });
|
||||||
|
this.histories.set(tokenId, history);
|
||||||
|
}
|
||||||
|
|
||||||
|
transferValueFrom(fromTokenId, toTokenId, amount) {
|
||||||
|
if (amount === undefined) {
|
||||||
|
throw new Error('Transfer value: amount is undefined!');
|
||||||
|
}
|
||||||
|
const sourceAvailable = this.availableValueOf(fromTokenId);
|
||||||
|
const targetAvailable = this.availableValueOf(toTokenId);
|
||||||
|
if (sourceAvailable < amount - EPSILON) {
|
||||||
|
throw new Error('Token value transfer: source has insufficient available value. '
|
||||||
|
+ `Needs ${amount}; has ${sourceAvailable}.`);
|
||||||
|
}
|
||||||
|
if (targetAvailable < -amount + EPSILON) {
|
||||||
|
throw new Error('Token value transfer: target has insufficient available value. '
|
||||||
|
+ `Needs ${-amount}; has ${targetAvailable}.`);
|
||||||
|
}
|
||||||
|
this.incrementValue(fromTokenId, -amount);
|
||||||
|
this.incrementValue(toTokenId, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
lock(tokenId, amount, duration) {
|
||||||
|
const lock = new Lock(tokenId, amount, duration);
|
||||||
|
this.locks.add(lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
historyOf(tokenId) {
|
||||||
|
return this.histories.get(tokenId);
|
||||||
|
}
|
||||||
|
|
||||||
|
valueOf(tokenId) {
|
||||||
|
return this.values.get(tokenId);
|
||||||
|
}
|
||||||
|
|
||||||
|
availableValueOf(tokenId) {
|
||||||
|
const amountLocked = Array.from(this.locks.values())
|
||||||
|
.filter(({ tokenId: lockTokenId }) => lockTokenId === tokenId)
|
||||||
|
.filter(({ dateCreated, duration }) => new Date() - dateCreated < duration)
|
||||||
|
.reduce((total, { amount }) => total += amount, 0);
|
||||||
|
|
||||||
|
return this.valueOf(tokenId) - amountLocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
valueOwnedBy(ownerId) {
|
||||||
|
return Array.from(this.owners.entries())
|
||||||
|
.filter(([__, owner]) => owner === ownerId)
|
||||||
|
.map(([tokenId, __]) => this.valueOf(tokenId))
|
||||||
|
.reduce((total, value) => total += value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
availableValueOwnedBy(ownerId) {
|
||||||
|
return Array.from(this.owners.entries())
|
||||||
|
.filter(([__, owner]) => owner === ownerId)
|
||||||
|
.map(([tokenId, __]) => this.availableValueOf(tokenId))
|
||||||
|
.reduce((total, value) => total += value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTotal() {
|
||||||
|
return Array.from(this.values.values()).reduce((total, value) => total += value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTotalAvailable() {
|
||||||
|
const amountLocked = Array.from(this.locks.values())
|
||||||
|
.filter(({ dateCreated, duration }) => new Date() - dateCreated < duration)
|
||||||
|
.reduce((total, { amount }) => total += amount, 0);
|
||||||
|
|
||||||
|
return this.getTotal() - amountLocked;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,88 @@
|
||||||
|
class Lock {
|
||||||
|
constructor(tokens, duration) {
|
||||||
|
this.dateCreated = new Date();
|
||||||
|
this.tokens = tokens;
|
||||||
|
this.duration = duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Reputation {
|
||||||
|
constructor() {
|
||||||
|
this.tokens = 0;
|
||||||
|
this.locks = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
addTokens(tokens) {
|
||||||
|
if (this.tokens + tokens < 0) {
|
||||||
|
throw new Error('Token balance can not become negative');
|
||||||
|
}
|
||||||
|
this.tokens += tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
lockTokens(tokens, duration) {
|
||||||
|
if (tokens > this.getAvailableTokens()) {
|
||||||
|
throw new Error('Can not lock more tokens than are available');
|
||||||
|
}
|
||||||
|
const lock = new Lock(tokens, duration);
|
||||||
|
this.locks.add(lock);
|
||||||
|
// TODO: Prune locks once expired
|
||||||
|
}
|
||||||
|
|
||||||
|
getTokens() {
|
||||||
|
return this.tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAvailableTokens() {
|
||||||
|
const now = new Date();
|
||||||
|
const tokensLocked = Array.from(this.locks.values())
|
||||||
|
.filter(({ dateCreated, duration }) => now - dateCreated < duration)
|
||||||
|
.reduce((acc, cur) => acc += cur.tokens, 0);
|
||||||
|
if (tokensLocked > this.tokens) {
|
||||||
|
throw new Error('Assertion failure. tokensLocked > tokens');
|
||||||
|
}
|
||||||
|
return this.tokens - tokensLocked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Reputations extends Map {
|
||||||
|
getTokens(reputationPublicKey) {
|
||||||
|
const reputation = this.get(reputationPublicKey);
|
||||||
|
if (!reputation) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return reputation.getTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
getAvailableTokens(reputationPublicKey) {
|
||||||
|
const reputation = this.get(reputationPublicKey);
|
||||||
|
if (!reputation) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return reputation.getAvailableTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
addTokens(reputationPublicKey, tokens) {
|
||||||
|
const reputation = this.get(reputationPublicKey) ?? new Reputation();
|
||||||
|
reputation.addTokens(tokens);
|
||||||
|
this.set(reputationPublicKey, reputation);
|
||||||
|
}
|
||||||
|
|
||||||
|
lockTokens(reputationPublicKey, tokens, duration) {
|
||||||
|
if (!tokens || !duration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reputation = this.get(reputationPublicKey);
|
||||||
|
if (!reputation) {
|
||||||
|
throw new Error(`${reputationPublicKey} has no tokens to lock`);
|
||||||
|
}
|
||||||
|
reputation.lockTokens(tokens, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTotal() {
|
||||||
|
return Array.from(this.values()).reduce((acc, cur) => acc += cur.getTokens(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTotalAvailable() {
|
||||||
|
return Array.from(this.values()).reduce((acc, cur) => acc += cur.getAvailableTokens(), 0);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,133 @@
|
||||||
|
import mermaid from 'https://unpkg.com/mermaid@9.2.2/dist/mermaid.esm.min.mjs';
|
||||||
|
import { Actor } from './actor.js';
|
||||||
|
import { Action } from './action.js';
|
||||||
|
import { debounce, hexToRGB } from '../util.js';
|
||||||
|
|
||||||
|
class MermaidDiagram {
|
||||||
|
constructor(box, logBox) {
|
||||||
|
this.box = box;
|
||||||
|
this.container = this.box.addBox('Container');
|
||||||
|
this.element = this.box.addBox('Element');
|
||||||
|
this.renderBox = this.box.addBox('Render');
|
||||||
|
this.box.addBox('Spacer').setInnerHTML(' ');
|
||||||
|
this.logBox = logBox;
|
||||||
|
this.inSection = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async log(msg, render = true) {
|
||||||
|
this.logBox.addBox().setInnerHTML(msg).monospace();
|
||||||
|
if (render) {
|
||||||
|
await this.render();
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
const render = async () => {
|
||||||
|
let innerText = this.logBox.getInnerText();
|
||||||
|
for (let i = 0; i < this.inSection; i++) {
|
||||||
|
innerText += '\nend';
|
||||||
|
}
|
||||||
|
const graph = await mermaid.mermaidAPI.render(
|
||||||
|
this.element.getId(),
|
||||||
|
innerText,
|
||||||
|
);
|
||||||
|
this.renderBox.setInnerHTML(graph);
|
||||||
|
};
|
||||||
|
await debounce(render, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Scene {
|
||||||
|
constructor(name, rootBox) {
|
||||||
|
this.name = name;
|
||||||
|
this.box = rootBox.addBox(name);
|
||||||
|
this.titleBox = this.box.addBox('Title').setInnerHTML(name);
|
||||||
|
this.box.addBox('Spacer').setInnerHTML(' ');
|
||||||
|
this.topSection = this.box.addBox('Top section').flex();
|
||||||
|
this.displayValuesBox = this.topSection.addBox('Values');
|
||||||
|
this.box.addBox('Spacer').setInnerHTML(' ');
|
||||||
|
this.actors = new Set();
|
||||||
|
|
||||||
|
mermaid.mermaidAPI.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
theme: 'base',
|
||||||
|
themeVariables: {
|
||||||
|
darkMode: true,
|
||||||
|
primaryColor: '#2a5b6c',
|
||||||
|
primaryTextColor: '#b6b6b6',
|
||||||
|
// lineColor: '#349cbd',
|
||||||
|
lineColor: '#57747d',
|
||||||
|
signalColor: '#57747d',
|
||||||
|
// signalColor: '#349cbd',
|
||||||
|
noteBkgColor: '#516f77',
|
||||||
|
noteTextColor: '#cecece',
|
||||||
|
activationBkgColor: '#1d3f49',
|
||||||
|
activationBorderColor: '#569595',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
withSequenceDiagram() {
|
||||||
|
const box = this.box.addBox('Sequence diagram');
|
||||||
|
this.box.addBox('Spacer').setInnerHTML(' ');
|
||||||
|
const logBox = this.box.addBox('Sequence diagram text');
|
||||||
|
this.sequence = new MermaidDiagram(box, logBox);
|
||||||
|
this.sequence.log('sequenceDiagram', false);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withFlowchart(direction = 'BT') {
|
||||||
|
const box = this.topSection.addBox('Flowchart');
|
||||||
|
this.box.addBox('Spacer').setInnerHTML(' ');
|
||||||
|
const logBox = this.box.addBox('Flowchart text');
|
||||||
|
this.flowchart = new MermaidDiagram(box, logBox);
|
||||||
|
this.flowchart.log(`graph ${direction}`, false);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addActor(name) {
|
||||||
|
const actor = new Actor(name, this);
|
||||||
|
if (this.sequence) {
|
||||||
|
await this.scene.sequence.log(`participant ${name}`);
|
||||||
|
}
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerActor(actor) {
|
||||||
|
this.actors.add(actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
findActor(fn) {
|
||||||
|
return Array.from(this.actors.values()).find(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
addAction(name) {
|
||||||
|
const action = new Action(name, this);
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
addDisplayValue(name) {
|
||||||
|
const dv = this.displayValuesBox.addDisplayValue(name);
|
||||||
|
return dv;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivateAll() {
|
||||||
|
for (const actor of this.actors.values()) {
|
||||||
|
while (actor.active) {
|
||||||
|
await actor.deactivate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startSection(color = '#08252c') {
|
||||||
|
const { r, g, b } = hexToRGB(color);
|
||||||
|
this.sequence.inSection++;
|
||||||
|
this.sequence.log(`rect rgb(${r}, ${g}, ${b})`, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async endSection() {
|
||||||
|
this.sequence.inSection--;
|
||||||
|
this.sequence.log('end');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import params from '../params.js';
|
||||||
|
|
||||||
|
export class Stake {
|
||||||
|
constructor({
|
||||||
|
tokenId, position, amount, lockingTime,
|
||||||
|
}) {
|
||||||
|
this.tokenId = tokenId;
|
||||||
|
this.position = position;
|
||||||
|
this.amount = amount;
|
||||||
|
this.lockingTime = lockingTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStakeValue() {
|
||||||
|
return this.amount * this.lockingTime ** params.lockingTimeExponent;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,270 @@
|
||||||
|
import { CryptoUtil } from './crypto.js';
|
||||||
|
import { ReputationHolder } from './reputation-holder.js';
|
||||||
|
import { Stake } from './stake.js';
|
||||||
|
import { Voter } from './voter.js';
|
||||||
|
import params from '../params.js';
|
||||||
|
import { Action } from './action.js';
|
||||||
|
|
||||||
|
const ValidationPoolStates = Object.freeze({
|
||||||
|
OPEN: 'OPEN',
|
||||||
|
CLOSED: 'CLOSED',
|
||||||
|
RESOLVED: 'RESOLVED',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purpose: Enable voting
|
||||||
|
*/
|
||||||
|
export class ValidationPool extends ReputationHolder {
|
||||||
|
constructor(
|
||||||
|
bench,
|
||||||
|
forum,
|
||||||
|
{
|
||||||
|
postId,
|
||||||
|
reputationPublicKey,
|
||||||
|
fee,
|
||||||
|
duration,
|
||||||
|
tokenLossRatio,
|
||||||
|
contentiousDebate = false,
|
||||||
|
},
|
||||||
|
name,
|
||||||
|
scene,
|
||||||
|
) {
|
||||||
|
super(`pool_${CryptoUtil.randomUUID()}`, name, scene);
|
||||||
|
this.id = this.reputationPublicKey;
|
||||||
|
|
||||||
|
this.actions = {
|
||||||
|
reward: new Action('reward', scene),
|
||||||
|
transfer: new Action('transfer', 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 Set();
|
||||||
|
this.dateStart = new Date();
|
||||||
|
this.authorReputationPublicKey = reputationPublicKey;
|
||||||
|
this.fee = fee;
|
||||||
|
this.duration = duration;
|
||||||
|
this.tokenLossRatio = tokenLossRatio;
|
||||||
|
this.contentiousDebate = contentiousDebate;
|
||||||
|
this.mintedValue = fee * params.mintingRatio();
|
||||||
|
this.tokenId = this.bench.reputation.mint(this.id, this.mintedValue);
|
||||||
|
// 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(this.id, {
|
||||||
|
position: true,
|
||||||
|
amount: this.mintedValue * params.stakeForAuthor,
|
||||||
|
tokenId: this.tokenId,
|
||||||
|
});
|
||||||
|
this.stake(this.id, {
|
||||||
|
position: false,
|
||||||
|
amount: this.mintedValue * (1 - params.stakeForAuthor),
|
||||||
|
tokenId: this.tokenId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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} outcome: null --> all entries. Otherwise filters to position === outcome.
|
||||||
|
* @param {boolean} options.excludeSystem: Whether to exclude votes cast during pool initialization
|
||||||
|
* @returns stake[]
|
||||||
|
*/
|
||||||
|
getStakes(outcome, { excludeSystem }) {
|
||||||
|
return Array.from(this.stakes.values())
|
||||||
|
.filter(({ tokenId }) => !excludeSystem || tokenId !== this.tokenId)
|
||||||
|
.filter(({ position }) => outcome === null || position === outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} outcome: null --> all entries. Otherwise filters to position === outcome.
|
||||||
|
* @returns number
|
||||||
|
*/
|
||||||
|
getTotalStakedOnPost(outcome) {
|
||||||
|
return this.getStakes(outcome, { excludeSystem: false })
|
||||||
|
.map((stake) => stake.getStakeValue())
|
||||||
|
.reduce((acc, cur) => (acc += cur), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} outcome: null --> all entries. Otherwise filters to position === outcome.
|
||||||
|
* @returns number
|
||||||
|
*/
|
||||||
|
getTotalValueOfStakesForOutcome(outcome) {
|
||||||
|
return this.getStakes(outcome, { excludeSystem: false })
|
||||||
|
.reduce((total, { amount }) => (total += amount), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: This can be handled as a hook on receipt of reputation token transfer
|
||||||
|
async stake(reputationPublicKey, {
|
||||||
|
tokenId, position, amount, lockingTime = 0,
|
||||||
|
}) {
|
||||||
|
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.bench.reputation.ownerOf(tokenId)) {
|
||||||
|
throw new Error('Reputation may only be staked by its owner!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stake = new Stake({
|
||||||
|
tokenId, position, amount, lockingTime,
|
||||||
|
});
|
||||||
|
this.stakes.add(stake);
|
||||||
|
|
||||||
|
// Transfer staked amount from the sender to the validation pool
|
||||||
|
this.bench.reputation.transferValueFrom(tokenId, this.tokenId, amount);
|
||||||
|
|
||||||
|
// Keep a record of voters and their votes
|
||||||
|
if (tokenId !== this.tokenId) {
|
||||||
|
const voter = this.bench.voters.get(reputationPublicKey) ?? new Voter(reputationPublicKey);
|
||||||
|
voter.addVoteRecord(this);
|
||||||
|
this.bench.voters.set(reputationPublicKey, voter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTokenLocking() {
|
||||||
|
// Before evaluating the winning conditions,
|
||||||
|
// we need to make sure any staked tokens are locked for the
|
||||||
|
// specified amounts of time.
|
||||||
|
for (const { tokenId, amount, lockingTime } of this.stakes.values()) {
|
||||||
|
this.bench.reputation.lock(tokenId, 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');
|
||||||
|
|
||||||
|
const upvoteValue = this.getTotalValueOfStakesForOutcome(true);
|
||||||
|
const downvoteValue = this.getTotalValueOfStakesForOutcome(false);
|
||||||
|
const activeAvailableReputation = this.bench.getActiveAvailableReputation();
|
||||||
|
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 winningEntries = this.getStakes(votePasses, { excludeSystem: true });
|
||||||
|
const totalValueOfStakesForWin = this.getTotalValueOfStakesForOutcome(votePasses);
|
||||||
|
|
||||||
|
// Compute rewards for the winning voters, in proportion to the value of their stakes.
|
||||||
|
for (const stake of winningEntries) {
|
||||||
|
const { tokenId, amount } = stake;
|
||||||
|
const value = stake.getStakeValue();
|
||||||
|
const reward = tokensForWinners * (value / totalValueOfStakesForWin);
|
||||||
|
// Also return each winning voter their staked amount
|
||||||
|
const reputationPublicKey = this.bench.reputation.ownerOf(tokenId);
|
||||||
|
console.log(`reward for winning stake by ${reputationPublicKey}: ${reward}`);
|
||||||
|
this.bench.reputation.transferValueFrom(this.tokenId, tokenId, reward + amount);
|
||||||
|
const toActor = this.scene.findActor((actor) => actor.reputationPublicKey === reputationPublicKey);
|
||||||
|
this.actions.reward.log(this, toActor, `(${reward})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (votePasses && !!this.forum) {
|
||||||
|
// Distribute awards to author via the forum
|
||||||
|
// const tokensForAuthor = this.mintedValue * params.stakeForAuthor + rewards.get(this.tokenId);
|
||||||
|
console.log(`sending reward for author stake to forum: ${this.bench.reputation.valueOf(this.tokenId)}`);
|
||||||
|
|
||||||
|
// Transfer ownership of the minted token, from the pool to the forum
|
||||||
|
this.bench.reputation.transferFrom(this.id, this.forum.id, this.tokenId);
|
||||||
|
const value = this.bench.reputation.valueOf(this.tokenId);
|
||||||
|
this.actions.transfer.log(this, this.forum, `(value: ${value})`);
|
||||||
|
|
||||||
|
// Recurse through forum to determine reputation effects
|
||||||
|
await this.forum.onValidate({
|
||||||
|
bench: this.bench,
|
||||||
|
pool: this,
|
||||||
|
postId: this.postId,
|
||||||
|
tokenId: this.tokenId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('pool complete');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
export class Voter {
|
||||||
|
constructor(reputationPublicKey) {
|
||||||
|
this.reputationPublicKey = reputationPublicKey;
|
||||||
|
this.voteHistory = [];
|
||||||
|
this.dateLastVote = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
addVoteRecord(stake) {
|
||||||
|
this.voteHistory.push(stake);
|
||||||
|
if (!this.dateLastVote || stake.dateStart > this.dateLastVote) {
|
||||||
|
this.dateLastVote = stake.dateStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 3.5 KiB |
|
@ -0,0 +1,38 @@
|
||||||
|
body {
|
||||||
|
background-color: #09343f;
|
||||||
|
color: #b6b6b6;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 8pt;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #c6f4ff;
|
||||||
|
}
|
||||||
|
a:visited {
|
||||||
|
color: #85b7c3;
|
||||||
|
}
|
||||||
|
.box {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.box .name {
|
||||||
|
width: 15em;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: right;
|
||||||
|
margin-right: 6pt;
|
||||||
|
}
|
||||||
|
.box .value {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.monospace {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 8pt;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
width: 800px;
|
||||||
|
}
|
||||||
|
.hidden {
|
||||||
|
/* display: none; */
|
||||||
|
/* visibility: hidden; */
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<title>Forum Network</title>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Tests</h2>
|
||||||
|
<h3>Primary</h3>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/tests/validation-pool.html">Validation Pool</a></li>
|
||||||
|
<li><a href="/tests/availability.html">Availability + Business</a></li>
|
||||||
|
<li><a href="/tests/forum.html">Forum</a></li>
|
||||||
|
</ul>
|
||||||
|
<h3>Secondary</h3>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/tests/forum-network.html">Forum Network</a></li>
|
||||||
|
</ul>
|
||||||
|
<h3>Tertiary</h3>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/tests/basic.html">Basic</a></li>
|
||||||
|
<li><a href="/tests/mermaid.html">Mermaid</a></li>
|
||||||
|
<li><a href="/tests/graph.html">Graph</a></li>
|
||||||
|
<li><a href="/tests/debounce.html">Debounce</a></li>
|
||||||
|
<li><a href="/tests/flowchart.html">Flowchart</a></li>
|
||||||
|
</ul>
|
||||||
|
</body>
|
|
@ -0,0 +1,28 @@
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
export default params;
|
|
@ -0,0 +1,189 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<title>Availability test</title>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="availability-test"></div>
|
||||||
|
</body>
|
||||||
|
<script type="module">
|
||||||
|
import { Box } from '../classes/box.js';
|
||||||
|
import { Scene } from '../classes/scene.js';
|
||||||
|
import { Expert } from '../classes/expert.js';
|
||||||
|
import { Bench } from '../classes/bench.js';
|
||||||
|
import { Business } from '../classes/business.js';
|
||||||
|
import { Availability } from '../classes/availability.js';
|
||||||
|
import { delay } from '../util.js';
|
||||||
|
import { Forum } from '../classes/forum.js';
|
||||||
|
import { Public } from '../classes/public.js';
|
||||||
|
import { PostContent } from '../classes/post-content.js';
|
||||||
|
|
||||||
|
const DELAY_INTERVAL = 500;
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('availability-test');
|
||||||
|
const rootBox = new Box('rootBox', rootElement).flex();
|
||||||
|
|
||||||
|
const scene = (window.scene = new Scene('Availability test', rootBox));
|
||||||
|
scene.withSequenceDiagram();
|
||||||
|
scene.withFlowchart();
|
||||||
|
|
||||||
|
const experts = (window.experts = []);
|
||||||
|
const newExpert = async () => {
|
||||||
|
const index = experts.length;
|
||||||
|
const name = `Expert${index + 1}`;
|
||||||
|
const expert = await new Expert(name, scene).initialize();
|
||||||
|
experts.push(expert);
|
||||||
|
return expert;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expert1 = await newExpert();
|
||||||
|
const expert2 = await newExpert();
|
||||||
|
const forum = (window.forum = new Forum('Forum', scene));
|
||||||
|
const bench = (window.bench = new Bench(forum, 'Bench', scene));
|
||||||
|
const availability = (window.availability = new Availability(
|
||||||
|
bench,
|
||||||
|
'Availability',
|
||||||
|
scene,
|
||||||
|
));
|
||||||
|
const business = (window.business = new Business(
|
||||||
|
bench,
|
||||||
|
forum,
|
||||||
|
availability,
|
||||||
|
'Business',
|
||||||
|
scene,
|
||||||
|
));
|
||||||
|
const requestor = new Public('Public', scene);
|
||||||
|
|
||||||
|
const updateDisplayValues = async () => {
|
||||||
|
for (const expert of experts) {
|
||||||
|
await expert.setValue(
|
||||||
|
'rep',
|
||||||
|
bench.reputation.valueOwnedBy(expert.reputationPublicKey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await bench.setValue('total rep', bench.reputation.getTotal());
|
||||||
|
await scene.sequence.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDisplayValuesAndDelay = async (delayMs = DELAY_INTERVAL) => {
|
||||||
|
await updateDisplayValues();
|
||||||
|
await delay(delayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActiveWorker = async () => {
|
||||||
|
let worker;
|
||||||
|
let request;
|
||||||
|
for (const expert of experts) {
|
||||||
|
request = await expert.getAssignedWork(availability, business);
|
||||||
|
if (request) {
|
||||||
|
worker = expert;
|
||||||
|
await worker.actions.getAssignedWork.log(worker, availability);
|
||||||
|
worker.activate();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { worker, request };
|
||||||
|
};
|
||||||
|
|
||||||
|
const voteForWorkEvidence = async (worker, pool) => {
|
||||||
|
for (const expert of experts) {
|
||||||
|
if (expert !== worker) {
|
||||||
|
await expert.stake(pool, {
|
||||||
|
position: true,
|
||||||
|
amount: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateDisplayValuesAndDelay();
|
||||||
|
|
||||||
|
// Experts gain initial reputation by submitting a post with fee
|
||||||
|
const { postId: postId1, pool: pool1 } = await expert1.submitPostWithFee(
|
||||||
|
bench,
|
||||||
|
forum,
|
||||||
|
new PostContent({ hello: 'there' }).setTitle('Post 1'),
|
||||||
|
{
|
||||||
|
fee: 10,
|
||||||
|
duration: 1000,
|
||||||
|
tokenLossRatio: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await updateDisplayValuesAndDelay(1000);
|
||||||
|
|
||||||
|
await pool1.evaluateWinningConditions();
|
||||||
|
await updateDisplayValuesAndDelay();
|
||||||
|
|
||||||
|
const { pool: pool2 } = await expert2.submitPostWithFee(
|
||||||
|
bench,
|
||||||
|
forum,
|
||||||
|
new PostContent({ hello: 'to you as well' })
|
||||||
|
.setTitle('Post 2')
|
||||||
|
.addCitation(postId1, 0.5),
|
||||||
|
{
|
||||||
|
fee: 10,
|
||||||
|
duration: 1000,
|
||||||
|
tokenLossRatio: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await updateDisplayValuesAndDelay(1000);
|
||||||
|
|
||||||
|
await pool2.evaluateWinningConditions();
|
||||||
|
await updateDisplayValuesAndDelay();
|
||||||
|
|
||||||
|
// Populate availability pool
|
||||||
|
await expert1.registerAvailability(availability, 1, 10000);
|
||||||
|
await expert2.registerAvailability(availability, 1, 10000);
|
||||||
|
await updateDisplayValuesAndDelay();
|
||||||
|
|
||||||
|
// Submit work request
|
||||||
|
await requestor.submitRequest(
|
||||||
|
business,
|
||||||
|
{ fee: 100 },
|
||||||
|
{ please: 'do some work' },
|
||||||
|
);
|
||||||
|
await updateDisplayValuesAndDelay();
|
||||||
|
|
||||||
|
// Receive work request
|
||||||
|
const { worker, request } = await getActiveWorker();
|
||||||
|
|
||||||
|
// Submit work evidence
|
||||||
|
const pool = await worker.submitWork(
|
||||||
|
business,
|
||||||
|
request.id,
|
||||||
|
{
|
||||||
|
here: 'is some evidence of work product',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tokenLossRatio: 1,
|
||||||
|
duration: 1000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
worker.deactivate();
|
||||||
|
await updateDisplayValuesAndDelay();
|
||||||
|
|
||||||
|
// Stake on work evidence
|
||||||
|
await voteForWorkEvidence(worker, pool);
|
||||||
|
await updateDisplayValuesAndDelay();
|
||||||
|
|
||||||
|
// Wait for validation pool duration to elapse
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
// Distribute reputation awards and fees
|
||||||
|
await pool.evaluateWinningConditions();
|
||||||
|
await updateDisplayValuesAndDelay();
|
||||||
|
|
||||||
|
// This should throw an exception since the pool is already resolved
|
||||||
|
try {
|
||||||
|
await pool.evaluateWinningConditions();
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.match(/Validation pool has already been resolved/)) {
|
||||||
|
console.log(
|
||||||
|
'Caught expected error: Validation pool has already been resolved',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error('Unexpected error');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,203 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<title>Forum Network</title>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="basic"></div>
|
||||||
|
</body>
|
||||||
|
<script type="module">
|
||||||
|
import { Box } from '../classes/box.js';
|
||||||
|
import { Scene } from '../classes/scene.js';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('basic');
|
||||||
|
const rootBox = new Box('rootBox', rootElement).flex();
|
||||||
|
|
||||||
|
function randomDelay(min, max) {
|
||||||
|
const delayMs = min + Math.random() * max;
|
||||||
|
return delayMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(min, max = min) {
|
||||||
|
const delayMs = min + Math.random() * (max - min);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, delayMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true) {
|
||||||
|
const scene = new Scene('Scene 1', rootBox);
|
||||||
|
const webClientStatus = scene.addDisplayValue('WebClient Status');
|
||||||
|
const node1Status = scene.addDisplayValue('Node 1 Status');
|
||||||
|
const blockchainStatus = scene.addDisplayValue('Blockchain Status');
|
||||||
|
|
||||||
|
const webClient = scene.addActor('web client');
|
||||||
|
const node1 = scene.addActor('node 1');
|
||||||
|
const blockchain = scene.addActor('blockchain');
|
||||||
|
const requestForumPage = scene.addAction('requestForumPage');
|
||||||
|
const readBlockchainData = scene.addAction('readBlockchainData');
|
||||||
|
const blockchainData = scene.addAction('blockchainData');
|
||||||
|
const forumPage = scene.addAction('forumPage');
|
||||||
|
|
||||||
|
webClientStatus.set('Initialized');
|
||||||
|
node1Status.set('Idle');
|
||||||
|
blockchainStatus.set('Idle');
|
||||||
|
|
||||||
|
node1.on(requestForumPage, (src, detail) => {
|
||||||
|
node1Status.set('Processing request');
|
||||||
|
node1.on(blockchainData, (_src, data) => {
|
||||||
|
node1Status.set('Processing response');
|
||||||
|
setTimeout(() => {
|
||||||
|
node1.send(src, forumPage, data);
|
||||||
|
node1Status.set('Idle');
|
||||||
|
}, randomDelay(500, 1000));
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
node1.send(blockchain, readBlockchainData, detail);
|
||||||
|
}, randomDelay(500, 1500));
|
||||||
|
});
|
||||||
|
|
||||||
|
blockchain.on(readBlockchainData, (src, _detail) => {
|
||||||
|
blockchainStatus.set('Processing request');
|
||||||
|
setTimeout(() => {
|
||||||
|
blockchain.send(src, blockchainData, {});
|
||||||
|
blockchainStatus.set('Idle');
|
||||||
|
}, randomDelay(500, 1500));
|
||||||
|
});
|
||||||
|
|
||||||
|
webClient.on(forumPage, (_src, _detail) => {
|
||||||
|
webClientStatus.set('Received forum page');
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
webClient.send(node1, requestForumPage);
|
||||||
|
webClientStatus.set('Requested forum page');
|
||||||
|
}, randomDelay(6000, 12000));
|
||||||
|
}
|
||||||
|
|
||||||
|
(async function run() {
|
||||||
|
const scene = new Scene('Scene 2', rootBox);
|
||||||
|
|
||||||
|
const webClient = scene.addActor('webClient');
|
||||||
|
|
||||||
|
const nodes = [];
|
||||||
|
const memories = [];
|
||||||
|
const storages = [];
|
||||||
|
|
||||||
|
function addNode() {
|
||||||
|
const idx = nodes.length;
|
||||||
|
const node = scene.addActor(`node${idx}`);
|
||||||
|
const memory = scene.addActor(`memory${idx}`);
|
||||||
|
const storage = scene.addActor(`storage${idx}`);
|
||||||
|
node.memory = memory;
|
||||||
|
node.storage = storage;
|
||||||
|
nodes.push(node);
|
||||||
|
memories.push(memory);
|
||||||
|
storages.push(storage);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPeer(node) {
|
||||||
|
const peers = nodes.filter((peer) => peer !== node);
|
||||||
|
const idx = Math.floor(Math.random() * peers.length);
|
||||||
|
return peers[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
addNode();
|
||||||
|
addNode();
|
||||||
|
|
||||||
|
const [
|
||||||
|
seekTruth,
|
||||||
|
considerInfo,
|
||||||
|
evaluateConfidence,
|
||||||
|
chooseResponse,
|
||||||
|
qualifiedOpinions,
|
||||||
|
requestMemoryData,
|
||||||
|
memoryData,
|
||||||
|
requestStorageData,
|
||||||
|
storageData,
|
||||||
|
] = [
|
||||||
|
'seek truth',
|
||||||
|
'consider available information',
|
||||||
|
'evaluate confidence',
|
||||||
|
'choose response',
|
||||||
|
'qualified opinions',
|
||||||
|
'request in-memory data',
|
||||||
|
'in-memory data',
|
||||||
|
'request storage data',
|
||||||
|
'storage data',
|
||||||
|
].map((name) => scene.addAction(name));
|
||||||
|
|
||||||
|
memories.forEach((memory) => {
|
||||||
|
memory.setStatus('Idle');
|
||||||
|
memory.on(requestMemoryData, async (src, _detail) => {
|
||||||
|
memory.setStatus('Retrieving data');
|
||||||
|
await delay(1000);
|
||||||
|
memory.send(src, memoryData, {});
|
||||||
|
memory.setStatus('Idle');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
storages.forEach((storage) => {
|
||||||
|
storage.setStatus('Idle');
|
||||||
|
storage.on(requestStorageData, async (src, _detail) => {
|
||||||
|
storage.setStatus('Retrieving data');
|
||||||
|
await delay(1000);
|
||||||
|
storage.send(src, storageData, {});
|
||||||
|
storage.setStatus('Idle');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
node.setStatus('Idle');
|
||||||
|
node.on(seekTruth, async (seeker, detail) => {
|
||||||
|
node.setStatus('Processing request');
|
||||||
|
|
||||||
|
node.on(chooseResponse, async (_src, _info) => {
|
||||||
|
node.setStatus('Choosing response');
|
||||||
|
await delay(1000);
|
||||||
|
node.send(seeker, qualifiedOpinions, {});
|
||||||
|
node.setStatus('Idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
node.on(evaluateConfidence, async (_src, _info) => {
|
||||||
|
node.setStatus('Evaluating confidence');
|
||||||
|
await delay(1000);
|
||||||
|
node.send(node, chooseResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
node.on(considerInfo, async (_src, _info) => {
|
||||||
|
node.setStatus('Considering info');
|
||||||
|
await delay(1000);
|
||||||
|
node.send(node, evaluateConfidence);
|
||||||
|
});
|
||||||
|
|
||||||
|
node.on(memoryData, (_src, _data) => {
|
||||||
|
node.on(storageData, (__src, __data) => {
|
||||||
|
if (detail?.readConcern === 'single') {
|
||||||
|
node.send(node, considerInfo, {});
|
||||||
|
} else {
|
||||||
|
const peer = getPeer(node);
|
||||||
|
node.on(qualifiedOpinions, (___src, info) => {
|
||||||
|
node.send(node, considerInfo, info);
|
||||||
|
});
|
||||||
|
node.send(peer, seekTruth, { readConcern: 'single' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
node.send(node.storage, requestStorageData);
|
||||||
|
});
|
||||||
|
|
||||||
|
await delay(1000);
|
||||||
|
node.send(node.memory, requestMemoryData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
webClient.on(qualifiedOpinions, (_src, _detail) => {
|
||||||
|
webClient.setStatus('Received opinions and qualifications');
|
||||||
|
});
|
||||||
|
|
||||||
|
await delay(1000);
|
||||||
|
webClient.setStatus('Seek truth');
|
||||||
|
webClient.send(nodes[0], seekTruth);
|
||||||
|
}());
|
||||||
|
</script>
|
|
@ -0,0 +1,33 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<title>Debounce test</title>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="debounce-test"></div>
|
||||||
|
</body>
|
||||||
|
<script type="module">
|
||||||
|
import { Box } from '../classes/box.js';
|
||||||
|
import { Scene } from '../classes/scene.js';
|
||||||
|
import { debounce, delay } from '../util.js';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('debounce-test');
|
||||||
|
const rootBox = new Box('rootBox', rootElement).flex();
|
||||||
|
|
||||||
|
window.scene = new Scene('Debounce test', rootBox);
|
||||||
|
|
||||||
|
let eventCount = 0;
|
||||||
|
const event = () => {
|
||||||
|
eventCount++;
|
||||||
|
console.log(`event ${eventCount}`);
|
||||||
|
};
|
||||||
|
await debounce(event, 500);
|
||||||
|
await debounce(event, 500);
|
||||||
|
await delay(500);
|
||||||
|
await debounce(event, 500);
|
||||||
|
await debounce(event, 500);
|
||||||
|
if (eventCount !== 2) {
|
||||||
|
throw new Error(`Expected 2 events, got ${eventCount}`);
|
||||||
|
}
|
||||||
|
console.log(`eventCount: ${eventCount}`);
|
||||||
|
</script>
|
|
@ -0,0 +1,38 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<title>Flowchart test</title>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="flowchart-test"></div>
|
||||||
|
</body>
|
||||||
|
<script type="module">
|
||||||
|
import { Box } from '../classes/box.js';
|
||||||
|
import { Scene } from '../classes/scene.js';
|
||||||
|
import { Actor } from '../classes/actor.js';
|
||||||
|
import { Action } from '../classes/action.js';
|
||||||
|
import { delay } from '../util.js';
|
||||||
|
|
||||||
|
const DEFAULT_DELAY_INTERVAL = 500;
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('flowchart-test');
|
||||||
|
const rootBox = new Box('rootBox', rootElement).flex();
|
||||||
|
|
||||||
|
const scene = (window.scene = new Scene('Flowchart test', rootBox));
|
||||||
|
scene.withSequenceDiagram();
|
||||||
|
|
||||||
|
const actor1 = new Actor('A', scene);
|
||||||
|
const actor2 = new Actor('B', scene);
|
||||||
|
const action1 = new Action('Action 1', scene);
|
||||||
|
await action1.log(actor1, actor2);
|
||||||
|
await actor1.setValue('value', 1);
|
||||||
|
|
||||||
|
await scene.withFlowchart();
|
||||||
|
await scene.flowchart.log('A --> B');
|
||||||
|
|
||||||
|
await delay(DEFAULT_DELAY_INTERVAL);
|
||||||
|
action1.log(actor1, actor2);
|
||||||
|
|
||||||
|
await delay(DEFAULT_DELAY_INTERVAL);
|
||||||
|
await scene.flowchart.log('A --> C');
|
||||||
|
</script>
|
|
@ -0,0 +1,73 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<title>Forum Network test</title>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="forum-network"></div>
|
||||||
|
</body>
|
||||||
|
<script type="module">
|
||||||
|
import { Box } from '../classes/box.js';
|
||||||
|
import { Scene } from '../classes/scene.js';
|
||||||
|
import { PostContent } from '../classes/post-content.js';
|
||||||
|
import { Expert } from '../classes/expert.js';
|
||||||
|
import { ForumNode } from '../classes/forum-node.js';
|
||||||
|
import { ForumNetwork } from '../classes/forum-network.js';
|
||||||
|
import { CryptoUtil } from '../classes/crypto.js';
|
||||||
|
import { delay } from '../util.js';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('forum-network');
|
||||||
|
const rootBox = new Box('rootBox', rootElement).flex();
|
||||||
|
|
||||||
|
window.scene = new Scene('Forum Network test', rootBox).log(
|
||||||
|
'sequenceDiagram',
|
||||||
|
);
|
||||||
|
|
||||||
|
window.author1 = await new Expert('author1', window.scene).initialize();
|
||||||
|
window.author2 = await new Expert('author2', window.scene).initialize();
|
||||||
|
|
||||||
|
window.forumNetwork = new ForumNetwork();
|
||||||
|
|
||||||
|
window.forumNode1 = await new ForumNode('node1', window.scene).initialize(
|
||||||
|
window.forumNetwork,
|
||||||
|
);
|
||||||
|
window.forumNode2 = await new ForumNode('node2', window.scene).initialize(
|
||||||
|
window.forumNetwork,
|
||||||
|
);
|
||||||
|
window.forumNode3 = await new ForumNode('node3', window.scene).initialize(
|
||||||
|
window.forumNetwork,
|
||||||
|
);
|
||||||
|
|
||||||
|
const processInterval = setInterval(async () => {
|
||||||
|
await window.forumNode1.processNextMessage();
|
||||||
|
await window.forumNode2.processNextMessage();
|
||||||
|
await window.forumNode3.processNextMessage();
|
||||||
|
|
||||||
|
await window.scene.sequence.render();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// const blockchain = new Blockchain();
|
||||||
|
|
||||||
|
window.post1 = new PostContent({ message: 'hi' });
|
||||||
|
window.post1.id = CryptoUtil.randomUUID();
|
||||||
|
window.post2 = new PostContent({ message: 'hello' }).addCitation(
|
||||||
|
window.post1.id,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
await delay(1000);
|
||||||
|
await window.author1.submitPostViaNetwork(
|
||||||
|
window.forumNode1,
|
||||||
|
window.post1,
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
await delay(1000);
|
||||||
|
await window.author2.submitPostViaNetwork(
|
||||||
|
window.forumNode2,
|
||||||
|
window.post2,
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
|
await delay(1000);
|
||||||
|
clearInterval(processInterval);
|
||||||
|
</script>
|
|
@ -0,0 +1,134 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<title>Forum test</title>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="forum-test"></div>
|
||||||
|
</body>
|
||||||
|
<script type="module">
|
||||||
|
import { Box } from '../classes/box.js';
|
||||||
|
import { Scene } from '../classes/scene.js';
|
||||||
|
import { Expert } from '../classes/expert.js';
|
||||||
|
import { Bench } from '../classes/bench.js';
|
||||||
|
import { delay } from '../util.js';
|
||||||
|
import { Forum } from '../classes/forum.js';
|
||||||
|
import { PostContent } from '../classes/post-content.js';
|
||||||
|
import params from '../params.js';
|
||||||
|
|
||||||
|
const DEFAULT_DELAY_INTERVAL = 500;
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('forum-test');
|
||||||
|
const rootBox = new Box('rootBox', rootElement).flex();
|
||||||
|
|
||||||
|
const scene = (window.scene = new Scene('Forum test', rootBox));
|
||||||
|
scene.withSequenceDiagram();
|
||||||
|
scene.withFlowchart();
|
||||||
|
|
||||||
|
scene.addDisplayValue('c3. stakeForAuthor').set(params.stakeForAuthor);
|
||||||
|
scene.addDisplayValue('q2. revaluationLimit').set(params.revaluationLimit);
|
||||||
|
scene
|
||||||
|
.addDisplayValue('q3. referenceChainLimit')
|
||||||
|
.set(params.referenceChainLimit);
|
||||||
|
scene.addDisplayValue('q4. leachingValue').set(params.leachingValue);
|
||||||
|
scene.addDisplayValue(' ');
|
||||||
|
|
||||||
|
const experts = (window.experts = []);
|
||||||
|
const newExpert = async () => {
|
||||||
|
const index = experts.length;
|
||||||
|
const name = `Expert${index + 1}`;
|
||||||
|
const expert = await new Expert(name, scene).initialize();
|
||||||
|
experts.push(expert);
|
||||||
|
return expert;
|
||||||
|
};
|
||||||
|
|
||||||
|
const forum = (window.forum = new Forum('Forum', scene));
|
||||||
|
const bench = (window.bench = new Bench(forum, 'Bench', scene));
|
||||||
|
const expert1 = await newExpert();
|
||||||
|
const expert2 = await newExpert();
|
||||||
|
const expert3 = await newExpert();
|
||||||
|
|
||||||
|
const updateDisplayValues = async () => {
|
||||||
|
for (const expert of experts) {
|
||||||
|
await expert.setValue(
|
||||||
|
'rep',
|
||||||
|
bench.reputation.valueOwnedBy(expert.reputationPublicKey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await bench.setValue('total rep', bench.reputation.getTotal());
|
||||||
|
await forum.setValue('total value', forum.getTotalValue());
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDisplayValuesAndDelay = async (delayMs) => {
|
||||||
|
await updateDisplayValues();
|
||||||
|
await delay(delayMs ?? DEFAULT_DELAY_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateDisplayValuesAndDelay();
|
||||||
|
|
||||||
|
await scene.startSection();
|
||||||
|
|
||||||
|
const { postId: postId1, pool: pool1 } = await expert1.submitPostWithFee(
|
||||||
|
bench,
|
||||||
|
forum,
|
||||||
|
new PostContent({ hello: 'there' }).setTitle('Post 1'),
|
||||||
|
{
|
||||||
|
fee: 10,
|
||||||
|
duration: 1000,
|
||||||
|
tokenLossRatio: 1,
|
||||||
|
// authorStakeAmount: 10,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await updateDisplayValuesAndDelay(1000);
|
||||||
|
|
||||||
|
await pool1.evaluateWinningConditions();
|
||||||
|
await updateDisplayValuesAndDelay();
|
||||||
|
|
||||||
|
await scene.endSection();
|
||||||
|
await scene.startSection();
|
||||||
|
|
||||||
|
const { postId: postId2, pool: pool2 } = await expert2.submitPostWithFee(
|
||||||
|
bench,
|
||||||
|
forum,
|
||||||
|
new PostContent({ hello: 'to you as well' })
|
||||||
|
.setTitle('Post 2')
|
||||||
|
.addCitation(postId1, 0.5),
|
||||||
|
{
|
||||||
|
fee: 10,
|
||||||
|
duration: 1000,
|
||||||
|
tokenLossRatio: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await updateDisplayValuesAndDelay(1000);
|
||||||
|
|
||||||
|
// await expert1.stake(pool2, { position: true, amount 1});
|
||||||
|
// await updateDisplayValuesAndDelay();
|
||||||
|
|
||||||
|
await pool2.evaluateWinningConditions();
|
||||||
|
await updateDisplayValuesAndDelay();
|
||||||
|
|
||||||
|
await scene.endSection();
|
||||||
|
await scene.startSection();
|
||||||
|
|
||||||
|
const { pool: pool3 } = await expert3.submitPostWithFee(
|
||||||
|
bench,
|
||||||
|
forum,
|
||||||
|
new PostContent({ hello: "y'all" })
|
||||||
|
.setTitle('Post 3')
|
||||||
|
.addCitation(postId2, -0.5),
|
||||||
|
{
|
||||||
|
fee: 100,
|
||||||
|
duration: 1000,
|
||||||
|
tokenLossRatio: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await updateDisplayValuesAndDelay(1000);
|
||||||
|
|
||||||
|
// await expert1.stake(pool3, { position: true, amount 1});
|
||||||
|
// await updateDisplayValuesAndDelay();
|
||||||
|
|
||||||
|
await pool3.evaluateWinningConditions();
|
||||||
|
await updateDisplayValuesAndDelay();
|
||||||
|
|
||||||
|
await scene.endSection();
|
||||||
|
</script>
|
|
@ -0,0 +1,33 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<title>Forum Graph</title>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="graph-test"></div>
|
||||||
|
</body>
|
||||||
|
<script type="module">
|
||||||
|
import { Box } from '../classes/box.js';
|
||||||
|
import { Scene } from '../classes/scene.js';
|
||||||
|
import { Graph } from '../classes/graph.js';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('graph-test');
|
||||||
|
const rootBox = new Box('rootBox', rootElement).flex();
|
||||||
|
|
||||||
|
window.scene = new Scene('Graph test', rootBox);
|
||||||
|
|
||||||
|
window.graph = new Graph();
|
||||||
|
|
||||||
|
window.v = [];
|
||||||
|
function addVertex() {
|
||||||
|
const vertex = window.graph.addVertex({ seq: window.v.length });
|
||||||
|
window.v.push(vertex);
|
||||||
|
}
|
||||||
|
addVertex();
|
||||||
|
addVertex();
|
||||||
|
addVertex();
|
||||||
|
addVertex();
|
||||||
|
addVertex();
|
||||||
|
|
||||||
|
window.graph.addEdge('e1', 0, 1);
|
||||||
|
</script>
|
|
@ -0,0 +1,32 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<title>Reputation test</title>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="test"></div>
|
||||||
|
</body>
|
||||||
|
<script type="module">
|
||||||
|
import { Box } from '../classes/box.js';
|
||||||
|
import { Scene } from '../classes/scene.js';
|
||||||
|
import { ValidationPool } from '../classes/validation-pool.js';
|
||||||
|
import { TokenHolder } from '../classes/token-holder.js';
|
||||||
|
import { ReputationToken } from '../classes/reputation-token.js';
|
||||||
|
import { delay } from '../util.js';
|
||||||
|
|
||||||
|
const DEFAULT_DELAY_INTERVAL = 500;
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('forum-test');
|
||||||
|
const rootBox = new Box('rootBox', rootElement).flex();
|
||||||
|
|
||||||
|
const scene = (window.scene = new Scene('Forum test', rootBox));
|
||||||
|
scene.withSequenceDiagram();
|
||||||
|
scene.withFlowchart();
|
||||||
|
|
||||||
|
const pool = new ValidationPool();
|
||||||
|
const repToken = new ReputationToken();
|
||||||
|
|
||||||
|
// const tokenMinter = new TokenHolder('TokenMinter', scene);
|
||||||
|
|
||||||
|
await delay(DEFAULT_DELAY_INTERVAL);
|
||||||
|
</script>
|
|
@ -0,0 +1,125 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<title>Validation Pool test</title>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="validation-pool"></div>
|
||||||
|
</body>
|
||||||
|
<script type="module">
|
||||||
|
import { Box } from '../classes/box.js';
|
||||||
|
import { Scene } from '../classes/scene.js';
|
||||||
|
import { Expert } from '../classes/expert.js';
|
||||||
|
import { Bench } from '../classes/bench.js';
|
||||||
|
import { Forum } from '../classes/forum.js';
|
||||||
|
import { PostContent } from '../classes/post-content.js';
|
||||||
|
import { delay } from '../util.js';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('validation-pool');
|
||||||
|
const rootBox = new Box('rootBox', rootElement).flex();
|
||||||
|
|
||||||
|
const scene = (window.scene = new Scene('Validation Pool test', rootBox));
|
||||||
|
await scene.withSequenceDiagram();
|
||||||
|
const expert1 = (window.expert1 = await new Expert(
|
||||||
|
'Expert1',
|
||||||
|
scene,
|
||||||
|
).initialize());
|
||||||
|
const expert2 = (window.expert2 = await new Expert(
|
||||||
|
'Expert2',
|
||||||
|
scene,
|
||||||
|
).initialize());
|
||||||
|
const forum = (window.forum = new Forum('Forum', scene));
|
||||||
|
const bench = (window.bench = new Bench(forum, 'Bench', scene));
|
||||||
|
|
||||||
|
const updateDisplayValues = async () => {
|
||||||
|
await expert1.setValue(
|
||||||
|
'rep',
|
||||||
|
bench.reputation.valueOwnedBy(expert1.reputationPublicKey),
|
||||||
|
);
|
||||||
|
await expert2.setValue(
|
||||||
|
'rep',
|
||||||
|
bench.reputation.valueOwnedBy(expert2.reputationPublicKey),
|
||||||
|
);
|
||||||
|
await bench.setValue('total rep', bench.reputation.getTotal());
|
||||||
|
// With params.lockingTimeExponent = 0 and params.activeVoterThreshold = null,
|
||||||
|
// these next 3 propetries are all equal to total rep
|
||||||
|
// await bench.setValue('available rep', bench.reputation.getTotalAvailable());
|
||||||
|
// await bench.setValue('active rep', bench.getActiveReputation());
|
||||||
|
// await bench.setValue('active available rep', bench.getActiveAvailableReputation());
|
||||||
|
await scene.sequence.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDisplayValues();
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
// First expert can self-approve
|
||||||
|
{
|
||||||
|
const { pool } = await expert1.submitPostWithFee(bench, forum, new PostContent(), {
|
||||||
|
fee: 7,
|
||||||
|
duration: 1000,
|
||||||
|
tokenLossRatio: 1,
|
||||||
|
});
|
||||||
|
// Attempting to evaluate winning conditions before the duration has expired
|
||||||
|
// should result in an exception
|
||||||
|
try {
|
||||||
|
await pool.evaluateWinningConditions();
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.match(/Validation pool duration has not yet elapsed/)) {
|
||||||
|
console.log(
|
||||||
|
'Caught expected error: Validation pool duration has not yet elapsed',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error('Unexpected error');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await delay(1000);
|
||||||
|
await pool.evaluateWinningConditions(); // Vote passes
|
||||||
|
await updateDisplayValues();
|
||||||
|
await delay(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure example: second expert can not self-approve
|
||||||
|
try {
|
||||||
|
const { pool } = await expert2.submitPostWithFee(bench, forum, new PostContent(), {
|
||||||
|
fee: 1,
|
||||||
|
duration: 1000,
|
||||||
|
tokenLossRatio: 1,
|
||||||
|
});
|
||||||
|
await delay(1000);
|
||||||
|
await pool.evaluateWinningConditions(); // Quorum not met!
|
||||||
|
await updateDisplayValues();
|
||||||
|
await delay(1000);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.match(/Quorum is not met/)) {
|
||||||
|
console.log('Caught expected error: Quorum not met');
|
||||||
|
} else {
|
||||||
|
console.error('Unexpected error');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second expert must be approved by first expert
|
||||||
|
{
|
||||||
|
const { pool } = await expert2.submitPostWithFee(bench, forum, new PostContent(), {
|
||||||
|
fee: 1,
|
||||||
|
duration: 1000,
|
||||||
|
tokenLossRatio: 1,
|
||||||
|
});
|
||||||
|
await expert1.stake(pool, {
|
||||||
|
position: true,
|
||||||
|
amount: 4,
|
||||||
|
lockingTime: 0,
|
||||||
|
});
|
||||||
|
await delay(1000);
|
||||||
|
await pool.evaluateWinningConditions(); // Stake passes
|
||||||
|
await updateDisplayValues();
|
||||||
|
await delay(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateDisplayValues();
|
||||||
|
|
||||||
|
scene.deactivateAll();
|
||||||
|
|
||||||
|
await updateDisplayValues();
|
||||||
|
</script>
|
|
@ -0,0 +1,30 @@
|
||||||
|
const timers = new Map();
|
||||||
|
|
||||||
|
export const debounce = async (fn, delayMs) => {
|
||||||
|
const timer = timers.get(fn);
|
||||||
|
if (timer) {
|
||||||
|
return timer.result;
|
||||||
|
}
|
||||||
|
const result = await fn();
|
||||||
|
timers.set(fn, { result });
|
||||||
|
setTimeout(() => {
|
||||||
|
timers.delete(fn);
|
||||||
|
}, delayMs);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const delay = async (delayMs) => {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, delayMs);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hexToRGB = (input) => {
|
||||||
|
if (input.startsWith('#')) {
|
||||||
|
input = input.slice(1);
|
||||||
|
}
|
||||||
|
const r = parseInt(`${input[0]}${input[1]}`, 16);
|
||||||
|
const g = parseInt(`${input[2]}${input[3]}`, 16);
|
||||||
|
const b = parseInt(`${input[4]}${input[5]}`, 16);
|
||||||
|
return { r, g, b };
|
||||||
|
};
|
|
@ -1 +0,0 @@
|
||||||
SEMANTIC_SCHOLAR_API_KEY=
|
|
|
@ -1,2 +0,0 @@
|
||||||
/target
|
|
||||||
.env
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,17 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "semantic-scholar-client"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
default-run = "import"
|
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
async-recursion = "1.0.0"
|
|
||||||
clap = { version = "3.2.11", features = ["derive"] }
|
|
||||||
dotenv = "0.15.0"
|
|
||||||
mongodb = "2.2.2"
|
|
||||||
reqwest = { version = "0.11.11", features = ["json"] }
|
|
||||||
serde = { version = "1.0.139", features = ["derive"] }
|
|
||||||
serde_json = "1.0.82"
|
|
||||||
tokio = { version = "1.20.0", features = ["full"] }
|
|
|
@ -1,25 +0,0 @@
|
||||||
#`semantic-scholar-client`
|
|
||||||
|
|
||||||
This utility is able to fetch data from Semantic Scholar API.
|
|
||||||
|
|
||||||
Initial proof of concept here writes the result to stdout.
|
|
||||||
|
|
||||||
Work in progress to pipe this data into an operating database.
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
* (Optional) Copy `.env.example` to `.env` and set the value of `SEMANTIC_SCHOLAR_API_KEY`
|
|
||||||
* Run the program
|
|
||||||
|
|
||||||
cargo run -- --paper-id <paper_id> --depth <depth>
|
|
||||||
|
|
||||||
* `paper_id` values are in accordance with [Semantic Scholar API](https://api.semanticscholar.org/api-docs/).
|
|
||||||
* `depth` is the number of citations to traverse, from the starting paper.
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
|
|
||||||
Ideas for followup work:
|
|
||||||
- Consider strategies for deciding where to terminate a given traversal
|
|
||||||
- Provide an HTTP/WebSocket interface that can be used to talk to this process during its operation.
|
|
||||||
This can enable us to pipe the data to other tasks, to monitor, to start/stop, and even to make configuration changes.
|
|
||||||
- Rate limit requests
|
|
|
@ -1,153 +0,0 @@
|
||||||
// During development, allowing dead code
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use async_recursion::async_recursion;
|
|
||||||
use clap::Parser;
|
|
||||||
use dotenv::dotenv;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use std::cmp::min;
|
|
||||||
use std::error::Error;
|
|
||||||
use std::fmt::Write;
|
|
||||||
|
|
||||||
type DataResult<T> = Result<T, Box<dyn Error>>;
|
|
||||||
|
|
||||||
const BASE_URL: &str = "https://api.semanticscholar.org/graph/v1";
|
|
||||||
const MAX_DEPTH: u32 = 3;
|
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
|
||||||
#[clap(author, version, about, long_about = None)]
|
|
||||||
struct Args {
|
|
||||||
/// How deep to traverse citation graph from the starting paper
|
|
||||||
#[clap(short, long, value_parser)]
|
|
||||||
depth: u32,
|
|
||||||
|
|
||||||
/// Starting paper. We will traverse papers that cite this one
|
|
||||||
#[clap(short, long, value_parser)]
|
|
||||||
paper_id: String,
|
|
||||||
// Write the results to MongoDB
|
|
||||||
// #[clap(short, long, value_parser)]
|
|
||||||
// write_to_mongo: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Author {
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
type Authors = Vec<Author>;
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct Paper {
|
|
||||||
paper_id: String,
|
|
||||||
title: Option<String>,
|
|
||||||
citations: Vec<Citation>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Occurs within Citation struct
|
|
||||||
*/
|
|
||||||
#[derive(Deserialize, Debug)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct CitingPaper {
|
|
||||||
paper_id: Option<String>,
|
|
||||||
title: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct Citation {
|
|
||||||
citing_paper: CitingPaper,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
code: Option<String>,
|
|
||||||
* Generic struct to wrap the common API response pattern {data: [...]}
|
|
||||||
*/
|
|
||||||
#[derive(Deserialize, Debug)]
|
|
||||||
struct ApiListResponse<T> {
|
|
||||||
data: Option<Vec<T>>,
|
|
||||||
message: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Cache results in a (separate but local) database such as Redis
|
|
||||||
// TODO: Store results in a (separate but local) database such as Postgres
|
|
||||||
#[async_recursion]
|
|
||||||
async fn get_citations(
|
|
||||||
client: &reqwest::Client,
|
|
||||||
paper_id: String,
|
|
||||||
depth: u32,
|
|
||||||
authors: &mut Vec<Author>,
|
|
||||||
) -> DataResult<Vec<Citation>> {
|
|
||||||
// Bound recursion to some depth
|
|
||||||
if depth > MAX_DEPTH {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the URL
|
|
||||||
let mut url = String::new();
|
|
||||||
write!(&mut url, "{}/paper/{}/citations", BASE_URL, paper_id)?;
|
|
||||||
|
|
||||||
let mut req = client.get(url);
|
|
||||||
let api_key = std::env::var("SEMANTIC_SCHOLAR_API_KEY");
|
|
||||||
if api_key.is_ok() {
|
|
||||||
req = req.header("x-api-key", api_key.unwrap());
|
|
||||||
}
|
|
||||||
let resp = req.send().await?.text().await?;
|
|
||||||
|
|
||||||
let resp_deserialized_attempt =
|
|
||||||
serde_json::from_str::<ApiListResponse<Citation>>(resp.as_str());
|
|
||||||
|
|
||||||
if let Err(err) = resp_deserialized_attempt {
|
|
||||||
println!("depth {} paper {} error {}", depth, paper_id, err);
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let resp_deserialized: ApiListResponse<Citation> = resp_deserialized_attempt.unwrap();
|
|
||||||
|
|
||||||
if resp_deserialized.message.is_some() {
|
|
||||||
println!(
|
|
||||||
"depth {} paper {} error {}",
|
|
||||||
depth,
|
|
||||||
paper_id,
|
|
||||||
resp_deserialized.message.unwrap()
|
|
||||||
);
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
for Citation {
|
|
||||||
citing_paper:
|
|
||||||
CitingPaper {
|
|
||||||
paper_id: citing_paper_id,
|
|
||||||
title,
|
|
||||||
},
|
|
||||||
} in resp_deserialized.data.unwrap()
|
|
||||||
{
|
|
||||||
if let (Some(citing_paper_id), Some(title)) = (citing_paper_id, title) {
|
|
||||||
let short_len = min(50, title.len());
|
|
||||||
let (short_title, _) = title.split_at(short_len);
|
|
||||||
println!(
|
|
||||||
"depth {} paper {} cites {} title {}",
|
|
||||||
depth, citing_paper_id, paper_id, short_title
|
|
||||||
);
|
|
||||||
|
|
||||||
get_citations(&client, citing_paper_id, depth + 1, authors).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(vec![])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<(), Box<dyn Error>> {
|
|
||||||
let Args { depth, paper_id } = Args::parse();
|
|
||||||
|
|
||||||
dotenv().ok();
|
|
||||||
|
|
||||||
let mut authors = Authors::new();
|
|
||||||
|
|
||||||
let client: reqwest::Client = reqwest::Client::new();
|
|
||||||
|
|
||||||
get_citations(&client, paper_id, depth, &mut authors).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
|
@ -1,59 +0,0 @@
|
||||||
|
|
||||||
use mongodb::{Client, options::ClientOptions};
|
|
||||||
|
|
||||||
const MONGO_DB_ADDRESS: &str = "mongodb://docker:mongopw@localhost:55000";
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
// Parse a connection string into an options struct.
|
|
||||||
let client_options = ClientOptions::parse(MONGO_DB_ADDRESS).await?;
|
|
||||||
|
|
||||||
// Get a handle to the deployment.
|
|
||||||
let client = Client::with_options(client_options)?;
|
|
||||||
|
|
||||||
// Try creating a collection
|
|
||||||
{
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct Book {
|
|
||||||
title: String,
|
|
||||||
author: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reference a (new) database
|
|
||||||
let db = client.database("db2");
|
|
||||||
|
|
||||||
// Get a handle to a collection of `Book`.
|
|
||||||
let typed_collection = db.collection::<Book>("books");
|
|
||||||
|
|
||||||
let books = vec![
|
|
||||||
Book {
|
|
||||||
title: "The Grapes of Wrath".to_string(),
|
|
||||||
author: "John Steinbeck".to_string(),
|
|
||||||
},
|
|
||||||
Book {
|
|
||||||
title: "To Kill a Mockingbird".to_string(),
|
|
||||||
author: "Harper Lee".to_string(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Insert the books into "mydb.books" collection, no manual conversion to BSON necessary.
|
|
||||||
typed_collection.insert_many(books, None).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// List the names of the databases in that deployment.
|
|
||||||
for db_name in client.list_database_names(None, None).await? {
|
|
||||||
println!("{}", db_name);
|
|
||||||
// Get a handle to a database.
|
|
||||||
let db = client.database(db_name.as_str());
|
|
||||||
|
|
||||||
// List the names of the collections in that database.
|
|
||||||
for collection_name in db.list_collection_names(None).await? {
|
|
||||||
println!("- {}", collection_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
Loading…
Reference in New Issue