brainchain-worker 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claim.js +3 -3
- package/dist/cli.js +30 -1
- package/dist/mint.js +3 -3
- package/dist/network.js +11 -2
- package/dist/propose.js +318 -0
- package/package.json +1 -1
package/dist/claim.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createPublicClient, createWalletClient, formatEther,
|
|
1
|
+
import { createPublicClient, createWalletClient, formatEther, parseAbi, parseEventLogs } from "viem";
|
|
2
2
|
import { RECEIPT_MS } from "./chain.js";
|
|
3
|
-
import { proofsChainOf } from "./network.js";
|
|
3
|
+
import { proofsChainOf, proofsTransport } from "./network.js";
|
|
4
4
|
export const proofsIncomeAbi = parseAbi([
|
|
5
5
|
"function due(uint256 tokenId) view returns (uint256)",
|
|
6
6
|
"function claimMany(uint256[] tokenIds)",
|
|
@@ -12,7 +12,7 @@ export const proofsIncomeAbi = parseAbi([
|
|
|
12
12
|
export const MAX_CLAIM = 200;
|
|
13
13
|
export function createIncome(proofs, income, account) {
|
|
14
14
|
const chain = proofsChainOf(proofs);
|
|
15
|
-
const transport =
|
|
15
|
+
const transport = proofsTransport(proofs);
|
|
16
16
|
const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
|
|
17
17
|
const wallet = createWalletClient({ account, chain, transport });
|
|
18
18
|
const router = { address: income, abi: proofsIncomeAbi };
|
package/dist/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createChain, faucetReopens, reason, unhealthy } from "./chain.js";
|
|
|
5
5
|
import { claim, createIncome } from "./claim.js";
|
|
6
6
|
import { createProofMinter, createProofsChain, MAX_MINT, mint } from "./mint.js";
|
|
7
7
|
import { resolveNetwork } from "./network.js";
|
|
8
|
+
import { buildCall, caseURI, createProposals, proposal, propose } from "./propose.js";
|
|
8
9
|
import { hireable, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
|
|
9
10
|
import { openStore } from "./state.js";
|
|
10
11
|
import { createWork, shellCommand, stopLeftover, stopper } from "./work.js";
|
|
@@ -30,6 +31,12 @@ Usage:
|
|
|
30
31
|
--count, at 0.001 WETH each on Robinhood Chain. --dry sends nothing.
|
|
31
32
|
brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
|
|
32
33
|
--dry lists what is due and sends nothing.
|
|
34
|
+
brainchain-worker propose --target board|executor|registry --call <function> --args '<JSON array>'
|
|
35
|
+
--title <text> --rationale <text> [--current <text>] [--proposed <text>] [--dry]
|
|
36
|
+
Bond the stake token and propose a change to BrainChain for Jev to
|
|
37
|
+
decide. --data <hex> gives the call as raw calldata instead of
|
|
38
|
+
--call and --args. --dry checks the call and sends nothing.
|
|
39
|
+
brainchain-worker proposal <id> Where a proposal stands: waiting, adopted and when it runs, or kept.
|
|
33
40
|
brainchain-worker leave Start unstaking (3-day cooldown).
|
|
34
41
|
brainchain-worker withdraw Withdraw stake once the cooldown has passed.
|
|
35
42
|
brainchain-worker --version Print the version.
|
|
@@ -59,8 +66,10 @@ const FLAGS = {
|
|
|
59
66
|
withdraw: {},
|
|
60
67
|
claim: { dry: true },
|
|
61
68
|
mint: { count: false, dry: true },
|
|
69
|
+
propose: { target: false, call: false, args: false, data: false, title: false, rationale: false, current: false, proposed: false, dry: true },
|
|
70
|
+
proposal: {},
|
|
62
71
|
};
|
|
63
|
-
const POSITIONAL = { price: 1 };
|
|
72
|
+
const POSITIONAL = { price: 1, proposal: 1 };
|
|
64
73
|
export function parse(command, args) {
|
|
65
74
|
const known = FLAGS[command];
|
|
66
75
|
if (known === undefined)
|
|
@@ -332,7 +341,27 @@ export async function main(argv) {
|
|
|
332
341
|
}
|
|
333
342
|
return mint(network, createProofsChain(network.proofs, signer().account), { count: count === undefined ? undefined : Number(count), dry: switches.has("dry"), log: console.log });
|
|
334
343
|
}
|
|
344
|
+
if (command === "proposal") {
|
|
345
|
+
const id = positional[0];
|
|
346
|
+
if (id === undefined || !/^\d{1,18}$/.test(id) || id === "0")
|
|
347
|
+
throw new UsageError("Usage: brainchain-worker proposal <id>, such as proposal 2");
|
|
348
|
+
// Reading a proposal needs no key, so any address stands in for the signer.
|
|
349
|
+
const reader = process.env.BRAINCHAIN_WORKER_KEY?.trim() ? signer().account : privateKeyToAccount(generatePrivateKey());
|
|
350
|
+
return proposal(network, createProposals(network, reader), BigInt(id), console.log);
|
|
351
|
+
}
|
|
335
352
|
const worker = signer();
|
|
353
|
+
if (command === "propose") {
|
|
354
|
+
if (!values.has("target"))
|
|
355
|
+
throw new UsageError("--target is required: board, executor or registry");
|
|
356
|
+
const call = buildCall(network, worker.account.address, {
|
|
357
|
+
target: values.get("target"),
|
|
358
|
+
call: values.get("call"),
|
|
359
|
+
args: values.get("args"),
|
|
360
|
+
data: values.get("data"),
|
|
361
|
+
});
|
|
362
|
+
const uri = caseURI(call, { title: values.get("title") ?? "", rationale: values.get("rationale") ?? "", current: values.get("current"), proposed: values.get("proposed") });
|
|
363
|
+
return propose(network, createProposals(network, worker.account), call, uri, { dry: switches.has("dry"), log: console.log });
|
|
364
|
+
}
|
|
336
365
|
if (command === "run")
|
|
337
366
|
return run(network, worker, values, switches.has("once"), !switches.has("no-mint"));
|
|
338
367
|
const chain = createChain(network, worker.account);
|
package/dist/mint.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createPublicClient, createWalletClient, formatEther,
|
|
1
|
+
import { createPublicClient, createWalletClient, formatEther, parseAbi, parseEventLogs, zeroAddress, } from "viem";
|
|
2
2
|
import { reason, RECEIPT_MS } from "./chain.js";
|
|
3
|
-
import { proofsChainOf } from "./network.js";
|
|
3
|
+
import { proofsChainOf, proofsTransport } from "./network.js";
|
|
4
4
|
import { when } from "./standing.js";
|
|
5
5
|
export const collectionAbi = parseAbi([
|
|
6
6
|
"function PRICE_QUOTE() view returns (uint256)",
|
|
@@ -38,7 +38,7 @@ export function mintedToken(logs, collection, to) {
|
|
|
38
38
|
}
|
|
39
39
|
export function createProofsChain(proofs, account) {
|
|
40
40
|
const chain = proofsChainOf(proofs);
|
|
41
|
-
const transport =
|
|
41
|
+
const transport = proofsTransport(proofs);
|
|
42
42
|
const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
|
|
43
43
|
const wallet = createWalletClient({ account, chain, transport });
|
|
44
44
|
const weth = { address: proofs.weth, abi: wethAbi };
|
package/dist/network.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineChain, zeroAddress } from "viem";
|
|
1
|
+
import { defineChain, fallback, http, zeroAddress } from "viem";
|
|
2
2
|
/** BrainChain testnet, release 3.1.0. */
|
|
3
3
|
export const TESTNET = {
|
|
4
4
|
id: "testnet",
|
|
@@ -15,6 +15,7 @@ export const TESTNET = {
|
|
|
15
15
|
chainId: 46630,
|
|
16
16
|
name: "Robinhood Chain testnet",
|
|
17
17
|
rpc: "https://rpc.testnet.chain.robinhood.com",
|
|
18
|
+
rpcFallbacks: ["https://robinhood-sepolia-rpc.publicnode.com", "https://robinhood-testnet.drpc.org"],
|
|
18
19
|
collection: "0x324B52EE2182641ec29652076ab541bc9F9907d2",
|
|
19
20
|
weth: "0x1F852eF3Afa13156b807204d838BbB46B4b452eb",
|
|
20
21
|
faucet: true,
|
|
@@ -25,6 +26,7 @@ export const TESTNET = {
|
|
|
25
26
|
staking: "0xbdC08799D865DA79FE44a469de1FeaE84bb45059",
|
|
26
27
|
bonds: "0x574060D1A7CA8f3786d2D14fe1A782cc4638CB1e",
|
|
27
28
|
executor: "0x420484B139001ED5B1DA330e689Cc2f4814001Fb",
|
|
29
|
+
registry: "0x297AA5fb95Ef9785Da6DC0cA00121060606e2C63",
|
|
28
30
|
stakeToken: "0xc717546497343911750768335b649b86d0a6eCcf",
|
|
29
31
|
bountyToken: "0xC4ec72794bF3Ce9ba1DE4B38085464c2eb0cDCf2",
|
|
30
32
|
},
|
|
@@ -54,6 +56,7 @@ export const MAINNET = {
|
|
|
54
56
|
chainId: 4663,
|
|
55
57
|
name: "Robinhood Chain",
|
|
56
58
|
rpc: "https://rpc.mainnet.chain.robinhood.com",
|
|
59
|
+
rpcFallbacks: ["https://rpc-robinhood.globalstake.io", "https://robinhood.api.pocket.network"],
|
|
57
60
|
collection: "0xE6E6B7DBF1a66dFB25c6F6A87a916B3186113169",
|
|
58
61
|
weth: "0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73",
|
|
59
62
|
gas: "Send it a little ETH there for gas",
|
|
@@ -64,6 +67,7 @@ export const MAINNET = {
|
|
|
64
67
|
staking: MAINNET_WORKER_STAKING,
|
|
65
68
|
bonds: MAINNET_PROPOSAL_BONDS,
|
|
66
69
|
executor: MAINNET_DECISION_EXECUTOR,
|
|
70
|
+
registry: "0x65B71706bE8a92dda39dDfdb6ABA8CEA64A33865",
|
|
67
71
|
stakeToken: BRAIN,
|
|
68
72
|
bountyToken: "0x8eBFEce96c0AeF7Fd23425D846a302a552c8c451",
|
|
69
73
|
},
|
|
@@ -112,7 +116,12 @@ export function proofsChainOf(proofs) {
|
|
|
112
116
|
id: proofs.chainId,
|
|
113
117
|
name: proofs.name,
|
|
114
118
|
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
|
115
|
-
rpcUrls: { default: { http: [proofs.rpc] } },
|
|
119
|
+
rpcUrls: { default: { http: [proofs.rpc, ...(proofs.rpcFallbacks ?? [])] } },
|
|
116
120
|
contracts: { multicall3: { address: MULTICALL3 } },
|
|
117
121
|
});
|
|
118
122
|
}
|
|
123
|
+
/** The Proofs chain's RPCs in order; the next answers when one fails to, a revert being an answer. */
|
|
124
|
+
export function proofsTransport(proofs) {
|
|
125
|
+
const urls = [proofs.rpc, ...(proofs.rpcFallbacks ?? [])];
|
|
126
|
+
return fallback(urls.map((url) => http(url, { timeout: 20_000, retryCount: urls.length > 1 ? 1 : 3 })), { rank: false, retryCount: 0 });
|
|
127
|
+
}
|
package/dist/propose.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { BaseError, createPublicClient, createWalletClient, decodeFunctionData, encodeAbiParameters, encodeFunctionData, getAddress, http, isAddress, isHex, keccak256, parseAbi, parseEventLogs, size, } from "viem";
|
|
2
|
+
import { bondsAbi, erc20Abi, executorAbi, RECEIPT_MS } from "./chain.js";
|
|
3
|
+
import { chainOf } from "./network.js";
|
|
4
|
+
import { stake, when } from "./standing.js";
|
|
5
|
+
/** Every call the executor can be asked to make, by the contract it calls. */
|
|
6
|
+
export const GOVERNED = {
|
|
7
|
+
board: parseAbi([
|
|
8
|
+
"struct Config { uint16 hireThresholdBps; uint16 gradeThresholdBps; uint16 ruleThresholdBps; uint64 minDuration; uint64 maxDuration; uint64 gradeWindow; uint64 disputeWindow; uint64 ruleWindow; uint128 minBounty; }",
|
|
9
|
+
"function setConfig(Config config)",
|
|
10
|
+
"function setFee(uint16 feeBps, address feeRouter)",
|
|
11
|
+
"function setPaused(bool paused)",
|
|
12
|
+
"function setWorkerAllowed(address account, bool allowed)",
|
|
13
|
+
]),
|
|
14
|
+
executor: parseAbi([
|
|
15
|
+
"function setParameters(uint16 changeThresholdBps, uint64 timelock, uint32 houseTasksPerDay, uint128 maxHouseBounty, uint128 houseSpendPerDay)",
|
|
16
|
+
"function transfer(address to, uint256 amount)",
|
|
17
|
+
]),
|
|
18
|
+
registry: parseAbi(["function setModel(bytes32 id, address account, uint128 price, bool active)"]),
|
|
19
|
+
};
|
|
20
|
+
const TARGETS = Object.keys(GOVERNED);
|
|
21
|
+
/** What the brain reads from a proposal's URI, cut to the lengths it keeps. */
|
|
22
|
+
export const LIMITS = { title: 120, rationale: 1_500, current: 300, proposed: 300 };
|
|
23
|
+
const proposalsAbi = parseAbi([
|
|
24
|
+
"event Expired(uint256 indexed id)",
|
|
25
|
+
"function decideWithin() view returns (uint64)",
|
|
26
|
+
]);
|
|
27
|
+
const executorReadsAbi = parseAbi([
|
|
28
|
+
"function vetoedCall(bytes32) view returns (bool)",
|
|
29
|
+
"function changeThresholdBps() view returns (uint16)",
|
|
30
|
+
]);
|
|
31
|
+
/** Mirrors ProposalBonds.State. */
|
|
32
|
+
const State = { None: 0, Open: 1, Returned: 2, Burned: 3 };
|
|
33
|
+
export class ProposalError extends Error {
|
|
34
|
+
}
|
|
35
|
+
function functions(target) {
|
|
36
|
+
return GOVERNED[target].filter((item) => item.type === "function").map((item) => item.name);
|
|
37
|
+
}
|
|
38
|
+
/** A JSON value as the ABI parameter it is passed for: integers become bigints, tuples objects. */
|
|
39
|
+
export function coerce(param, value) {
|
|
40
|
+
const name = param.name ?? param.type;
|
|
41
|
+
const fail = (expected) => new ProposalError(`${name} must be ${expected}, not ${JSON.stringify(value)}`);
|
|
42
|
+
if (/^u?int\d*$/.test(param.type)) {
|
|
43
|
+
const signed = param.type.startsWith("int");
|
|
44
|
+
if (typeof value === "number" && Number.isSafeInteger(value))
|
|
45
|
+
return BigInt(value);
|
|
46
|
+
if (typeof value === "string" && (signed ? /^-?\d+$/ : /^\d+$/).test(value))
|
|
47
|
+
return BigInt(value);
|
|
48
|
+
throw fail(`a whole number${signed ? "" : " from 0"}, as a JSON number or a string of digits`);
|
|
49
|
+
}
|
|
50
|
+
if (param.type === "bool") {
|
|
51
|
+
if (typeof value === "boolean")
|
|
52
|
+
return value;
|
|
53
|
+
throw fail("true or false");
|
|
54
|
+
}
|
|
55
|
+
if (param.type === "address") {
|
|
56
|
+
if (typeof value === "string" && isAddress(value, { strict: false }))
|
|
57
|
+
return getAddress(value);
|
|
58
|
+
throw fail("an address");
|
|
59
|
+
}
|
|
60
|
+
const bytes = /^bytes(\d+)$/.exec(param.type);
|
|
61
|
+
if (bytes) {
|
|
62
|
+
if (typeof value === "string" && isHex(value) && size(value) === Number(bytes[1]))
|
|
63
|
+
return value.toLowerCase();
|
|
64
|
+
throw fail(`${bytes[1]} bytes of hex`);
|
|
65
|
+
}
|
|
66
|
+
if (param.type === "tuple" && "components" in param) {
|
|
67
|
+
const parts = param.components;
|
|
68
|
+
if (Array.isArray(value) && value.length === parts.length) {
|
|
69
|
+
return Object.fromEntries(parts.map((part, i) => [part.name, coerce(part, value[i])]));
|
|
70
|
+
}
|
|
71
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
72
|
+
const given = value;
|
|
73
|
+
const extra = Object.keys(given).filter((key) => !parts.some((part) => part.name === key));
|
|
74
|
+
if (extra.length > 0)
|
|
75
|
+
throw new ProposalError(`${name} has no field ${extra[0]}; its fields are ${parts.map((p) => p.name).join(", ")}`);
|
|
76
|
+
return Object.fromEntries(parts.map((part) => {
|
|
77
|
+
if (!(part.name in given))
|
|
78
|
+
throw new ProposalError(`${name} is missing ${part.name}; give every field: ${parts.map((p) => p.name).join(", ")}`);
|
|
79
|
+
return [part.name, coerce(part, given[part.name])];
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
throw fail(`an object with ${parts.map((p) => p.name).join(", ")}`);
|
|
83
|
+
}
|
|
84
|
+
throw fail(param.type);
|
|
85
|
+
}
|
|
86
|
+
const show = (value) => {
|
|
87
|
+
if (typeof value === "bigint")
|
|
88
|
+
return value.toString();
|
|
89
|
+
if (Array.isArray(value))
|
|
90
|
+
return `(${value.map(show).join(", ")})`;
|
|
91
|
+
if (typeof value === "object" && value !== null)
|
|
92
|
+
return `{${Object.entries(value).map(([k, v]) => `${k}: ${show(v)}`).join(", ")}}`;
|
|
93
|
+
return String(value);
|
|
94
|
+
};
|
|
95
|
+
function resolveTarget(network, target) {
|
|
96
|
+
const addresses = { board: network.contracts.taskBoard, executor: network.contracts.executor, registry: network.contracts.registry };
|
|
97
|
+
const named = TARGETS.find((t) => t === target || addresses[t].toLowerCase() === target.toLowerCase());
|
|
98
|
+
if (!named)
|
|
99
|
+
throw new ProposalError(`--target is board, executor or registry, not "${target}"`);
|
|
100
|
+
return { target: named, address: addresses[named] };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The call a proposal asks the executor to make, from --call and --args or
|
|
104
|
+
* from raw --data, refused here when the executor would refuse it anyway.
|
|
105
|
+
*/
|
|
106
|
+
export function buildCall(network, proposer, input) {
|
|
107
|
+
const { target, address } = resolveTarget(network, input.target);
|
|
108
|
+
const abi = GOVERNED[target];
|
|
109
|
+
const offered = `The ${target} takes ${functions(target).join(", ")}`;
|
|
110
|
+
let data;
|
|
111
|
+
if (input.data !== undefined) {
|
|
112
|
+
if (input.call !== undefined || input.args !== undefined)
|
|
113
|
+
throw new ProposalError("Give either --data or --call with --args, not both");
|
|
114
|
+
if (!isHex(input.data) || input.data.length < 10)
|
|
115
|
+
throw new ProposalError("--data must be the call's hex calldata, starting 0x");
|
|
116
|
+
data = input.data.toLowerCase();
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
if (!input.call)
|
|
120
|
+
throw new ProposalError(`--call is required: the function to call. ${offered}.`);
|
|
121
|
+
const fn = abi.find((item) => item.type === "function" && item.name === input.call);
|
|
122
|
+
if (!fn || fn.type !== "function")
|
|
123
|
+
throw new ProposalError(`${offered}, not ${input.call}. setGuardian and setOwner are never queued.`);
|
|
124
|
+
let args;
|
|
125
|
+
try {
|
|
126
|
+
args = JSON.parse(input.args ?? "[]");
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new ProposalError(`--args must be a JSON array, such as '[300, "0x…"]'`);
|
|
130
|
+
}
|
|
131
|
+
if (!Array.isArray(args) || args.length !== fn.inputs.length) {
|
|
132
|
+
throw new ProposalError(`${fn.name} takes ${fn.inputs.length} argument${fn.inputs.length === 1 ? "" : "s"} (${fn.inputs.map((p) => p.name).join(", ")}), given as a JSON array in --args`);
|
|
133
|
+
}
|
|
134
|
+
data = encodeFunctionData({ abi, functionName: fn.name, args: fn.inputs.map((param, i) => coerce(param, args[i])) });
|
|
135
|
+
}
|
|
136
|
+
let decoded;
|
|
137
|
+
try {
|
|
138
|
+
decoded = decodeFunctionData({ abi, data });
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new ProposalError(`${offered}; the selector ${data.slice(0, 10)} is none of them`);
|
|
142
|
+
}
|
|
143
|
+
if (encodeFunctionData({ abi, functionName: decoded.functionName, args: decoded.args }).toLowerCase() !== data) {
|
|
144
|
+
throw new ProposalError("--data has bytes beyond the call's arguments");
|
|
145
|
+
}
|
|
146
|
+
const args = decoded.args ?? [];
|
|
147
|
+
if (target === "board" && decoded.functionName === "setWorkerAllowed" && args[1] === true && String(args[0]).toLowerCase() === proposer.toLowerCase()) {
|
|
148
|
+
throw new ProposalError("Allowing your own wallet to work is an application: run join, which stakes and applies in one go");
|
|
149
|
+
}
|
|
150
|
+
return { target, address, data, text: `${decoded.functionName}(${args.map(show).join(", ")})` };
|
|
151
|
+
}
|
|
152
|
+
/** The proposal's URI: base64 JSON the brain shows Jev as the proposer's case. */
|
|
153
|
+
export function caseURI(call, input) {
|
|
154
|
+
const title = input.title.trim();
|
|
155
|
+
const rationale = input.rationale.trim();
|
|
156
|
+
if (!title)
|
|
157
|
+
throw new ProposalError(`--title is required, up to ${LIMITS.title} characters`);
|
|
158
|
+
if (!rationale)
|
|
159
|
+
throw new ProposalError(`--rationale is required: why the change is better, up to ${LIMITS.rationale} characters. Jev reads it to decide.`);
|
|
160
|
+
const body = { title, rationale, current: input.current?.trim() ?? "", proposed: input.proposed?.trim() || call.text };
|
|
161
|
+
for (const key of Object.keys(LIMITS)) {
|
|
162
|
+
if (body[key].length > LIMITS[key])
|
|
163
|
+
throw new ProposalError(`--${key} is at most ${LIMITS[key]} characters`);
|
|
164
|
+
}
|
|
165
|
+
return `data:application/json;base64,${Buffer.from(JSON.stringify(body)).toString("base64")}`;
|
|
166
|
+
}
|
|
167
|
+
const LOG_RANGE = 50000n;
|
|
168
|
+
export function createProposals(network, account) {
|
|
169
|
+
const chain = chainOf(network);
|
|
170
|
+
const transport = http(network.rpc, { timeout: 20_000, retryCount: 3 });
|
|
171
|
+
const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
|
|
172
|
+
const wallet = createWalletClient({ account, chain, transport });
|
|
173
|
+
const { bonds, executor, stakeToken } = network.contracts;
|
|
174
|
+
const proposals = { address: bonds, abi: [...bondsAbi, ...proposalsAbi] };
|
|
175
|
+
async function scan(fetch) {
|
|
176
|
+
const to = await client.getBlockNumber();
|
|
177
|
+
const all = [];
|
|
178
|
+
for (let start = network.fromBlock; start <= to; start += LOG_RANGE) {
|
|
179
|
+
all.push(...(await fetch(start, start + LOG_RANGE - 1n < to ? start + LOG_RANGE - 1n : to)));
|
|
180
|
+
}
|
|
181
|
+
return all;
|
|
182
|
+
}
|
|
183
|
+
async function send(request) {
|
|
184
|
+
const hash = await wallet.writeContract(request);
|
|
185
|
+
const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
|
|
186
|
+
if (receipt.status !== "success")
|
|
187
|
+
throw new Error(`Transaction ${hash} reverted`);
|
|
188
|
+
return receipt;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
account: account.address,
|
|
192
|
+
now: async () => (await client.getBlock()).timestamp,
|
|
193
|
+
ethBalance: () => client.getBalance({ address: account.address }),
|
|
194
|
+
stakeBalance: () => client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
|
|
195
|
+
bond: () => client.readContract({ ...proposals, functionName: "bond" }),
|
|
196
|
+
decideWithin: () => client.readContract({ ...proposals, functionName: "decideWithin" }),
|
|
197
|
+
changeThresholdBps: async () => BigInt(await client.readContract({ address: executor, abi: executorReadsAbi, functionName: "changeThresholdBps" })),
|
|
198
|
+
timelock: () => client.readContract({ address: executor, abi: executorAbi, functionName: "timelock" }),
|
|
199
|
+
vetoed: (call) => client.readContract({
|
|
200
|
+
address: executor,
|
|
201
|
+
abi: executorReadsAbi,
|
|
202
|
+
functionName: "vetoedCall",
|
|
203
|
+
args: [keccak256(encodeAbiParameters([{ type: "address" }, { type: "bytes" }], [call.address, call.data]))],
|
|
204
|
+
}),
|
|
205
|
+
async rehearse(call) {
|
|
206
|
+
try {
|
|
207
|
+
await client.call({ account: executor, to: call.address, data: call.data });
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
return error instanceof BaseError ? error.shortMessage : String(error);
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
async propose(call, uri, bond) {
|
|
215
|
+
const allowance = await client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "allowance", args: [account.address, bonds] });
|
|
216
|
+
if (allowance < bond)
|
|
217
|
+
await send({ address: stakeToken, abi: erc20Abi, functionName: "approve", args: [bonds, bond] });
|
|
218
|
+
const receipt = await send({ ...proposals, functionName: "propose", args: [call.address, call.data, uri] });
|
|
219
|
+
const [log] = parseEventLogs({ abi: bondsAbi, eventName: "Proposed", logs: receipt.logs }).filter((l) => l.address.toLowerCase() === bonds.toLowerCase());
|
|
220
|
+
if (!log)
|
|
221
|
+
throw new Error(`The proposal landed in ${receipt.transactionHash} but logged no id`);
|
|
222
|
+
return { id: log.args.id, tx: receipt.transactionHash };
|
|
223
|
+
},
|
|
224
|
+
async get(id) {
|
|
225
|
+
const p = await client.readContract({ ...proposals, functionName: "get", args: [id] });
|
|
226
|
+
return { id, proposer: p.proposer, bond: p.bond, createdAt: p.createdAt, state: p.state };
|
|
227
|
+
},
|
|
228
|
+
async outcome(id) {
|
|
229
|
+
const [settled, expired] = await Promise.all([
|
|
230
|
+
scan((fromBlock, toBlock) => client.getContractEvents({ ...proposals, eventName: "Settled", args: { id }, fromBlock, toBlock })),
|
|
231
|
+
scan((fromBlock, toBlock) => client.getContractEvents({ ...proposals, eventName: "Expired", args: { id }, fromBlock, toBlock })),
|
|
232
|
+
]);
|
|
233
|
+
if (settled[0])
|
|
234
|
+
return { decisionId: settled[0].args.decisionId, adopted: settled[0].args.adopted };
|
|
235
|
+
return expired[0] ? "expired" : undefined;
|
|
236
|
+
},
|
|
237
|
+
async queued(decisionId) {
|
|
238
|
+
const [log] = await scan((fromBlock, toBlock) => client.getContractEvents({ address: executor, abi: executorAbi, eventName: "Queued", args: { decisionId }, fromBlock, toBlock }));
|
|
239
|
+
if (!log)
|
|
240
|
+
return undefined;
|
|
241
|
+
const change = await client.readContract({ address: executor, abi: executorAbi, functionName: "getChange", args: [log.args.changeId] });
|
|
242
|
+
return { changeId: log.args.changeId, eta: change.eta, executed: change.executed, vetoed: change.vetoed };
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const hours = (seconds) => `${Number(seconds) / 3600} hours`;
|
|
247
|
+
const days = (seconds) => `${Number(seconds) / 86_400} days`;
|
|
248
|
+
const percent = (bps) => `${Number(bps) / 100}%`;
|
|
249
|
+
export async function propose(network, chain, call, uri, options) {
|
|
250
|
+
const { log } = options;
|
|
251
|
+
const [bond, balance, gas, within, threshold, timelock, vetoed, revert] = await Promise.all([
|
|
252
|
+
chain.bond(),
|
|
253
|
+
chain.stakeBalance(),
|
|
254
|
+
chain.ethBalance(),
|
|
255
|
+
chain.decideWithin(),
|
|
256
|
+
chain.changeThresholdBps(),
|
|
257
|
+
chain.timelock(),
|
|
258
|
+
chain.vetoed(call),
|
|
259
|
+
chain.rehearse(call),
|
|
260
|
+
]);
|
|
261
|
+
if (vetoed)
|
|
262
|
+
throw new ProposalError(`The guardian vetoed ${call.text} on the ${call.target} before, so the executor never queues it again`);
|
|
263
|
+
if (revert)
|
|
264
|
+
throw new ProposalError(`${call.text} would fail if the executor made it now (${revert}), so the bond would be burned for nothing`);
|
|
265
|
+
log(`Proposal on ${network.name}
|
|
266
|
+
Call ${call.text} on the ${call.target} (${call.address})
|
|
267
|
+
Data ${call.data}
|
|
268
|
+
Bond ${stake(network, bond)}, returned if Jev adopts the change with at least ${percent(threshold)} confidence, burned otherwise
|
|
269
|
+
Decision within ${days(within)}; an adopted change runs ${hours(timelock)} later`);
|
|
270
|
+
if (options.dry) {
|
|
271
|
+
log("Dry run, nothing sent.");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (balance < bond) {
|
|
275
|
+
const buy = network.buyStake ? ` ${network.buyStake}` : "";
|
|
276
|
+
throw new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, bond)} bond.${buy}`);
|
|
277
|
+
}
|
|
278
|
+
if (gas === 0n)
|
|
279
|
+
throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}; 0.001 ETH is plenty.`);
|
|
280
|
+
log(`Bonding ${stake(network, bond)} and proposing...`);
|
|
281
|
+
const { id, tx } = await chain.propose(call, uri, bond);
|
|
282
|
+
log(`Proposal ${id} is waiting for Jev (${tx}). Follow it with \`npx brainchain-worker proposal ${id}\`.`);
|
|
283
|
+
}
|
|
284
|
+
export async function proposal(network, chain, id, log) {
|
|
285
|
+
const p = await chain.get(id);
|
|
286
|
+
if (p.state === State.None)
|
|
287
|
+
throw new ProposalError(`There is no proposal ${id} on ${network.name}`);
|
|
288
|
+
const head = `Proposal ${id} by ${p.proposer}, bond ${stake(network, p.bond)}`;
|
|
289
|
+
if (p.state === State.Open) {
|
|
290
|
+
const [within, now] = await Promise.all([chain.decideWithin(), chain.now()]);
|
|
291
|
+
const closes = p.createdAt + within;
|
|
292
|
+
const after = now > closes ? `its window closed at ${when(closes)}, so anyone can return the bond with expire` : `Jev decides by ${when(closes)}`;
|
|
293
|
+
log(`${head}\n Waiting for Jev: ${after}.`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const outcome = await chain.outcome(id);
|
|
297
|
+
if (outcome === "expired") {
|
|
298
|
+
log(`${head}\n Not decided in its window; the bond was returned.`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (outcome === undefined) {
|
|
302
|
+
log(`${head}\n ${p.state === State.Returned ? "Settled, bond returned" : "Settled, bond burned"}.`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (!outcome.adopted) {
|
|
306
|
+
log(`${head}\n Jev kept the current setting (decision ${outcome.decisionId}); the bond was burned.`);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const change = await chain.queued(outcome.decisionId);
|
|
310
|
+
const state = !change
|
|
311
|
+
? "not queued yet"
|
|
312
|
+
: change.executed
|
|
313
|
+
? `change ${change.changeId} has run`
|
|
314
|
+
: change.vetoed
|
|
315
|
+
? `change ${change.changeId} was vetoed by the guardian`
|
|
316
|
+
: `change ${change.changeId} runs from ${when(change.eta)}`;
|
|
317
|
+
log(`${head}\n Jev adopted it (decision ${outcome.decisionId}) and the bond was returned; ${state}.`);
|
|
318
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainchain-worker",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Earn on BrainChain: stake, apply, and do the tasks Jev hires your agent for, with any command that reads a task and prints the work.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "BrainChain <hello@usebrainchain.com>",
|