Compare commits

..

No commits in common. "main" and "forum-network" have entirely different histories.

57 changed files with 5110 additions and 2198 deletions

7
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,7 @@
pages:
script:
- mkdir public
- cp -r forum-network/src/* public/
artifacts:
paths:
- public

View File

@ -1,7 +1,7 @@
# DAO Governance Framework
# Science Publishing DAO
## Subprojects
| 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 |

View File

@ -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'],
},
};

3
forum-network/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
ssl/
node_modules/
git/

View File

@ -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?

View File

@ -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 todays 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.

View File

@ -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.

1917
forum-network/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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) : ''
}`,
);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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) {}
}

View File

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

View File

@ -0,0 +1,10 @@
export class Token {
constructor(ownerPublicKey) {
this.ownerPublicKey = ownerPublicKey;
}
transfer(newOwnerPublicKey) {
// TODO: Current owner must sign this request
this.ownerPublicKey = newOwnerPublicKey;
}
}

View File

@ -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());
}
}

View File

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

View File

@ -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,
);
});
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

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

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

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

View File

@ -0,0 +1,8 @@
import { Actor } from './actor.js';
export class ReputationHolder extends Actor {
constructor(reputationPublicKey, name, scene) {
super(name, scene);
this.reputationPublicKey = reputationPublicKey;
}
}

View File

@ -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;
}
}

View File

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

View File

@ -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('&nbsp;');
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('&nbsp;');
this.topSection = this.box.addBox('Top section').flex();
this.displayValuesBox = this.topSection.addBox('Values');
this.box.addBox('Spacer').setInnerHTML('&nbsp;');
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('&nbsp;');
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('&nbsp;');
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');
}
}

View File

@ -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;
}
}

View File

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

View File

@ -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

View File

@ -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; */
}

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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('&nbsp;');
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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

30
forum-network/src/util.js Normal file
View File

@ -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 };
};

View File

@ -1 +0,0 @@
SEMANTIC_SCHOLAR_API_KEY=

View File

@ -1,2 +0,0 @@
/target
.env

File diff suppressed because it is too large Load Diff

View File

@ -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"] }

View File

@ -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

View File

@ -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(())
}

View File

@ -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(())
}