brainchain-worker 0.18.3 → 0.19.0
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/README.md +60 -45
- package/dist/bond.js +65 -30
- package/dist/chain.js +66 -7
- package/dist/cli.js +114 -63
- package/dist/credit.js +4 -1
- package/dist/exit.js +82 -19
- package/dist/key.js +24 -10
- package/dist/mint.js +8 -5
- package/dist/network.js +16 -10
- package/dist/propose.js +29 -32
- package/dist/relay.js +18 -12
- package/dist/solana.js +177 -36
- package/dist/state.js +14 -1
- package/dist/watch.js +80 -23
- package/dist/work.js +11 -1
- package/package.json +1 -1
package/dist/exit.js
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
// `exit`: the worker's USDG on BrainChain paid back within a minute instead of the bridge's seven days. The desk pays
|
|
2
2
|
// it on Robinhood Chain, or, with `--to-solana`, as USDC on Solana through Relay: the transfer to the desk carries the
|
|
3
|
-
// Solana address after its arguments, so nothing else is signed and nothing is needed on Robinhood Chain.
|
|
3
|
+
// Solana address after its arguments, so nothing else is signed and nothing is needed on Robinhood Chain. The token and
|
|
4
|
+
// the desk the service names are checked against this release before anything is signed.
|
|
5
|
+
import { PublicKey } from "@solana/web3.js";
|
|
4
6
|
import bs58 from "bs58";
|
|
5
|
-
import { createPublicClient, createWalletClient, encodeFunctionData, erc20Abi, formatUnits, http, parseUnits } from "viem";
|
|
6
|
-
import { RECEIPT_MS } from "./chain.js";
|
|
7
|
+
import { createPublicClient, createWalletClient, encodeFunctionData, erc20Abi, formatUnits, http, isAddress, parseUnits } from "viem";
|
|
8
|
+
import { RECEIPT_MS, reason } from "./chain.js";
|
|
7
9
|
import { chainOf } from "./network.js";
|
|
8
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Keys no one can spend USDC from. Program ids are often on the curve, so the
|
|
12
|
+
* curve alone does not catch them: the all-zero address, which the desk also
|
|
13
|
+
* reads as no destination, the token programs, USDC's mint and Relay's depository.
|
|
14
|
+
*/
|
|
15
|
+
const NOT_WALLETS = new Set([
|
|
16
|
+
"11111111111111111111111111111111",
|
|
17
|
+
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
|
|
18
|
+
"TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
|
|
19
|
+
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
|
|
20
|
+
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
|
|
21
|
+
"99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2",
|
|
22
|
+
]);
|
|
23
|
+
/** A Solana address a wallet can spend from: 32 bytes in base58, on the ed25519 curve. A program-derived address is off it, and USDC sent there is stranded. */
|
|
9
24
|
export function isSolanaAddress(value) {
|
|
10
|
-
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(value))
|
|
25
|
+
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(value) || NOT_WALLETS.has(value))
|
|
11
26
|
return false;
|
|
12
27
|
try {
|
|
13
|
-
|
|
28
|
+
const bytes = bs58.decode(value);
|
|
29
|
+
return bytes.length === 32 && PublicKey.isOnCurve(bytes);
|
|
14
30
|
}
|
|
15
31
|
catch {
|
|
16
32
|
return false;
|
|
@@ -27,13 +43,39 @@ export function exitFee(amount, terms) {
|
|
|
27
43
|
const fee = proportional > floor ? proportional : floor;
|
|
28
44
|
return fee > amount ? amount : fee;
|
|
29
45
|
}
|
|
46
|
+
const units = (value) => typeof value === "string" && /^\d{1,30}$/.test(value);
|
|
47
|
+
/** What the service answers for its exit terms, refused here when it is not the shape the rest of `exit` reads. */
|
|
30
48
|
export async function exitTerms(service, fetchImpl = fetch) {
|
|
31
|
-
const
|
|
49
|
+
const host = new URL(service).host;
|
|
50
|
+
const response = await fetchImpl(`${service}/exits`, { signal: AbortSignal.timeout(20_000) }).catch(() => {
|
|
51
|
+
throw new Error(`Could not reach the BrainChain service at ${host} for the exit terms. Nothing was sent.`);
|
|
52
|
+
});
|
|
32
53
|
if (response.status === 404)
|
|
33
54
|
throw new Error("Fast exits are off on this network");
|
|
34
55
|
if (!response.ok)
|
|
35
|
-
throw new Error(
|
|
36
|
-
|
|
56
|
+
throw new Error(`The BrainChain service at ${host} answered ${response.status} when asked for the exit terms. Nothing was sent.`);
|
|
57
|
+
const terms = (await response.json().catch(() => undefined));
|
|
58
|
+
const readable = typeof terms?.desk === "string" &&
|
|
59
|
+
isAddress(terms.desk, { strict: false }) &&
|
|
60
|
+
typeof terms.token === "string" &&
|
|
61
|
+
isAddress(terms.token, { strict: false }) &&
|
|
62
|
+
Number.isInteger(terms.feeBps) &&
|
|
63
|
+
terms.feeBps >= 0 &&
|
|
64
|
+
[terms.minFeeUnits, terms.minUnits, terms.maxUnits].every(units);
|
|
65
|
+
if (!readable)
|
|
66
|
+
throw new Error(`The BrainChain service at ${host} named the exit terms in a form exit cannot read. Nothing was sent.`);
|
|
67
|
+
return terms;
|
|
68
|
+
}
|
|
69
|
+
/** Why the terms cannot be signed for: a transfer goes to whatever token and desk they name, and only this release says which those are. */
|
|
70
|
+
export function termsProblem(network, terms) {
|
|
71
|
+
const host = new URL(network.service).host;
|
|
72
|
+
if (terms.token.toLowerCase() !== network.contracts.bountyToken.toLowerCase()) {
|
|
73
|
+
return `The BrainChain service at ${host} names the token ${terms.token} for an exit, and ${network.name}'s ${network.bountySymbol} is ${network.contracts.bountyToken}. Nothing was sent.`;
|
|
74
|
+
}
|
|
75
|
+
if (terms.desk.toLowerCase() !== network.exitDesk.toLowerCase()) {
|
|
76
|
+
return `The BrainChain service at ${host} names the desk ${terms.desk}, and this release of brainchain-worker pays only ${network.exitDesk}. Nothing was sent.`;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
37
79
|
}
|
|
38
80
|
export async function exitRecord(service, tx, fetchImpl = fetch) {
|
|
39
81
|
const response = await fetchImpl(`${service}/exits/${tx}`, { signal: AbortSignal.timeout(20_000) });
|
|
@@ -43,15 +85,22 @@ export async function exitRecord(service, tx, fetchImpl = fetch) {
|
|
|
43
85
|
throw new Error(`${service}/exits answered ${response.status}`);
|
|
44
86
|
return (await response.json()).exits[0];
|
|
45
87
|
}
|
|
46
|
-
export function createExitChain(network, account) {
|
|
47
|
-
const read = createPublicClient({ chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000 }) });
|
|
88
|
+
export function createExitChain(network, account, receiptMs = RECEIPT_MS) {
|
|
89
|
+
const read = createPublicClient({ chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000 }), pollingInterval: 1_000 });
|
|
48
90
|
const wallet = createWalletClient({ account, chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000 }) });
|
|
49
91
|
return {
|
|
50
92
|
account: account.address,
|
|
51
93
|
bountyBalance: (token) => read.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
|
|
94
|
+
async unmined() {
|
|
95
|
+
const [pending, latest] = await Promise.all([read.getTransactionCount({ address: account.address, blockTag: "pending" }), read.getTransactionCount({ address: account.address })]);
|
|
96
|
+
return pending - latest;
|
|
97
|
+
},
|
|
52
98
|
async sendToDesk(token, desk, amount, solana) {
|
|
53
99
|
const hash = await wallet.sendTransaction({ to: token, data: exitCalldata(desk, amount, solana) });
|
|
54
|
-
|
|
100
|
+
// Sent is not lost: the transfer can still land, so another run would pay the desk twice.
|
|
101
|
+
const receipt = await read.waitForTransactionReceipt({ hash, timeout: receiptMs }).catch((error) => {
|
|
102
|
+
throw new Error(`The transfer to the desk was sent (${hash}) and could not be confirmed (${reason(error)}). Follow it at ${network.service}/exits/${hash} before running exit again: another run sends another transfer.`);
|
|
103
|
+
});
|
|
55
104
|
if (receipt.status !== "success")
|
|
56
105
|
throw new Error(`the transfer to the desk reverted (${hash})`);
|
|
57
106
|
return hash;
|
|
@@ -64,11 +113,14 @@ export async function exit(network, chain, options) {
|
|
|
64
113
|
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
65
114
|
if (options.toSolana !== undefined) {
|
|
66
115
|
if (!isSolanaAddress(options.toSolana))
|
|
67
|
-
throw new Error(`"${options.toSolana}" is not a Solana
|
|
116
|
+
throw new Error(`"${options.toSolana}" is not the address of a Solana wallet, so USDC sent there could not be spent`);
|
|
68
117
|
if (network.id !== "mainnet")
|
|
69
118
|
throw new Error("--to-solana is mainnet only: Relay connects Solana with Robinhood Chain mainnet, not the testnets");
|
|
70
119
|
}
|
|
71
120
|
const terms = await exitTerms(network.service, fetchImpl);
|
|
121
|
+
const refused = termsProblem(network, terms);
|
|
122
|
+
if (refused)
|
|
123
|
+
throw new Error(refused);
|
|
72
124
|
const balance = await chain.bountyBalance(terms.token);
|
|
73
125
|
const amount = options.amount === undefined ? balance : parseUnits(options.amount, 6);
|
|
74
126
|
const min = BigInt(terms.minUnits);
|
|
@@ -82,34 +134,45 @@ export async function exit(network, chain, options) {
|
|
|
82
134
|
if (amount < min)
|
|
83
135
|
throw new Error(`An exit is at least ${show(min)}`);
|
|
84
136
|
if (amount > max)
|
|
85
|
-
throw new Error(`An exit is at most ${show(max)}
|
|
137
|
+
throw new Error(`An exit is at most ${show(max)}, and nothing was sent. Give --amount up to that, and send the rest in another exit.`);
|
|
86
138
|
const fee = exitFee(amount, terms);
|
|
87
139
|
const net = amount - fee;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
140
|
+
const paid = options.toSolana === undefined
|
|
141
|
+
? `${show(net)} arrives on the parent chain (chain ${terms.payoutChainId}) after a ${show(fee)} fee.`
|
|
142
|
+
: `${show(net)} after a ${show(fee)} fee is paid as USDC on Solana to ${options.toSolana}, less Relay's own fee. The desk prices that move once the transfer arrives; one that would cost more than 3% (or 0.50 USD) is paid in ${symbol} on the parent chain (chain ${terms.payoutChainId}) instead.`;
|
|
143
|
+
log(`Exit ${show(amount)} from ${network.name}: ${paid}`);
|
|
91
144
|
if (dry) {
|
|
92
145
|
log("Dry run, nothing sent.");
|
|
93
146
|
return;
|
|
94
147
|
}
|
|
148
|
+
const unmined = await chain.unmined();
|
|
149
|
+
if (unmined > 0)
|
|
150
|
+
throw new Error(`This wallet has a transaction on ${network.name} that is not mined yet, which may be an earlier exit, and another run would send another transfer. Nothing was sent; run exit again once it is mined.`);
|
|
95
151
|
const tx = await chain.sendToDesk(terms.token, terms.desk, amount, options.toSolana);
|
|
152
|
+
const follow = `${network.service}/exits/${tx}`;
|
|
96
153
|
log(`Sent to the desk on ${network.name} (${tx}). Waiting for the payout, usually under a minute.`);
|
|
97
154
|
const end = Date.now() + (options.waitMs ?? 1_800_000);
|
|
98
155
|
let last = "";
|
|
99
156
|
let record;
|
|
100
157
|
for (;;) {
|
|
101
158
|
record = await exitRecord(network.service, tx, fetchImpl).catch(() => undefined);
|
|
102
|
-
if (record?.state === "paid" || record?.state === "returned")
|
|
159
|
+
if (record?.state === "paid" || record?.state === "returned" || record?.state === "held")
|
|
103
160
|
break;
|
|
104
161
|
if (record?.reason && record.reason !== last)
|
|
105
162
|
log(`Waiting: ${record.reason}.`);
|
|
106
163
|
last = record?.reason ?? last;
|
|
107
164
|
if (Date.now() > end) {
|
|
108
|
-
|
|
165
|
+
const within = options.toSolana === undefined ? "The desk pays it or sends it back within a day" : "Within a day the desk pays it or sends it back, or holds it for a person if Relay has not settled the move";
|
|
166
|
+
log(`Still waiting after ${Math.round((options.waitMs ?? 1_800_000) / 60_000)} minutes. ${within}; follow it at ${follow}.`);
|
|
109
167
|
return;
|
|
110
168
|
}
|
|
111
169
|
await sleep(options.pollMs ?? 10_000);
|
|
112
170
|
}
|
|
171
|
+
if (record.state === "held") {
|
|
172
|
+
log(`The desk holds this exit for a person: ${record.reason ?? "no reason given"}. Nothing more is sent for it until it is settled; follow it at ${follow}.`);
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
113
176
|
if (record.state === "returned") {
|
|
114
177
|
log(`The desk sent it back on ${network.name}: ${record.reason ?? "no reason given"} (${record.returnTx}).`);
|
|
115
178
|
process.exitCode = 1;
|
package/dist/key.js
CHANGED
|
@@ -6,7 +6,7 @@ import { stateDir } from "./state.js";
|
|
|
6
6
|
/** Names the file a run passes its key through when it restarts itself without the key in its environment. */
|
|
7
7
|
const HANDOFF = "BRAINCHAIN_WORKER_KEY_HANDOFF";
|
|
8
8
|
const HANDOFF_NAME = /^brainchain-worker-key-[0-9a-f]{32}$/;
|
|
9
|
-
const expandHome = (path) => (path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path);
|
|
9
|
+
export const expandHome = (path) => (path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path);
|
|
10
10
|
/** Whether the environment names a key, without reading it: a key file can be a pipe, which reads only once. */
|
|
11
11
|
export const keyConfigured = (env = process.env) => Boolean(env[HANDOFF] || env.BRAINCHAIN_WORKER_KEY?.trim() || env.BRAINCHAIN_WORKER_KEY_FILE?.trim());
|
|
12
12
|
/** What is wrong with a key file's text, in words that never repeat any of it. */
|
|
@@ -85,19 +85,33 @@ function handoffDir() {
|
|
|
85
85
|
return stateDir();
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
+
/** A variable that holds a key or says where one is: the worker's and the Solana wallet's, which `run` never uses. */
|
|
89
|
+
const KEY_VARIABLE = /^BRAINCHAIN_(WORKER|SOLANA)_KEY(_FILE)?$/;
|
|
90
|
+
/** A key file that is a pipe has been read and holds nothing, so its name can stay: a restart would put a key piped in to stay out of files in one for a moment. */
|
|
91
|
+
function namesOnlyAPipe(names, env) {
|
|
92
|
+
if (names.length !== 1 || names[0] !== "BRAINCHAIN_WORKER_KEY_FILE")
|
|
93
|
+
return false;
|
|
94
|
+
try {
|
|
95
|
+
return !statSync(expandHome(env.BRAINCHAIN_WORKER_KEY_FILE.trim())).isFile();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
88
101
|
/**
|
|
89
|
-
* Restarts this process in place without
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
102
|
+
* Restarts this process in place without any key variable in its environment.
|
|
103
|
+
* Deleting them would not do: `ps eww` and /proc read the block the process
|
|
104
|
+
* started with, and a command run by `run` can reach both. The pid, the output
|
|
105
|
+
* and the supervisor's view all stay as they were. Returns when there is
|
|
106
|
+
* nothing to clear, or when it could not be cleared.
|
|
94
107
|
*/
|
|
95
108
|
export function clearKeyFromEnvironment(key, warn, env = process.env, execve = process.execve) {
|
|
96
|
-
|
|
109
|
+
const names = Object.keys(env).filter((name) => KEY_VARIABLE.test(name));
|
|
110
|
+
if (names.length === 0 || namesOnlyAPipe(names, env))
|
|
97
111
|
return;
|
|
98
112
|
if (typeof execve !== "function")
|
|
99
|
-
return warn(`warning: Node.js ${process.version} cannot clear the key from this process's environment; Node.js 22.15 or later can`);
|
|
100
|
-
const
|
|
113
|
+
return warn(`warning: Node.js ${process.version} cannot clear the key variables from this process's environment; Node.js 22.15 or later can`);
|
|
114
|
+
const rest = Object.fromEntries(Object.entries(env).filter(([name]) => !KEY_VARIABLE.test(name)));
|
|
101
115
|
let file;
|
|
102
116
|
try {
|
|
103
117
|
file = join(handoffDir(), `brainchain-worker-key-${randomBytes(16).toString("hex")}`);
|
|
@@ -107,6 +121,6 @@ export function clearKeyFromEnvironment(key, warn, env = process.env, execve = p
|
|
|
107
121
|
catch (error) {
|
|
108
122
|
if (file !== undefined)
|
|
109
123
|
rmSync(file, { force: true });
|
|
110
|
-
warn(`warning: could not clear the key from this process's environment (${error.message})`);
|
|
124
|
+
warn(`warning: could not clear the key variables from this process's environment (${error.message})`);
|
|
111
125
|
}
|
|
112
126
|
}
|
package/dist/mint.js
CHANGED
|
@@ -23,8 +23,8 @@ export const wethAbi = parseAbi([
|
|
|
23
23
|
export const MAX_MINT = 10;
|
|
24
24
|
/** How often a delivered task's status is read until it settles. */
|
|
25
25
|
const STATUS_SEC = 300;
|
|
26
|
-
/**
|
|
27
|
-
const WATCH_SEC =
|
|
26
|
+
/** Grading, a dispute, its ruling, the appeal window and the ruling on appeal take up to 48 hours on set 3.5.0, so a task unsettled three days after delivery is left. */
|
|
27
|
+
const WATCH_SEC = 3 * 86_400;
|
|
28
28
|
/** A day after payment the task joins the public pool, so the worker's own attempts stop an hour short of it. */
|
|
29
29
|
const OWN_SEC = 23 * 3_600;
|
|
30
30
|
/** The least time between two mint attempts for one task. */
|
|
@@ -93,7 +93,8 @@ export async function pendingTasks(network, worker, fetcher = fetch) {
|
|
|
93
93
|
if (!Array.isArray(tasks) || !tasks.every((id) => typeof id === "string" && /^\d{1,78}$/.test(id))) {
|
|
94
94
|
throw new Error(`The BrainChain service at ${host} listed the tasks waiting for a Proof in a form the worker cannot read`);
|
|
95
95
|
}
|
|
96
|
-
|
|
96
|
+
// Task ids restart with each board and the list spans them, so a repeated id is another task.
|
|
97
|
+
return tasks.map((id) => BigInt(id));
|
|
97
98
|
}
|
|
98
99
|
const proofCount = (n) => `${n} Proof${n === 1 ? "" : "s"}`;
|
|
99
100
|
/** "task 12", "tasks 12 and 15", "tasks 12, 15 and 18". */
|
|
@@ -159,7 +160,7 @@ export async function mint(network, chain, options) {
|
|
|
159
160
|
return log(`The BrainChain service lists no paid task of ${chain.account} waiting for a Proof, so there is nothing to mint.`);
|
|
160
161
|
const batch = listed.slice(0, Math.min(options.count ?? MAX_MINT, MAX_MINT));
|
|
161
162
|
const left = listed.length - batch.length;
|
|
162
|
-
const rest = left > 0 ? ` ${left} more paid task${left === 1 ? " waits" : "s wait"} for a Proof
|
|
163
|
+
const rest = left > 0 ? ` ${left} more paid task${left === 1 ? " waits" : "s wait"} for a Proof, for a later mint.` : "";
|
|
163
164
|
const funds = await afford(proofs, chain, batch.length, { dry: options.dry, claiming: () => log("Claiming test WETH from the faucet..."), now: options.now ?? wall });
|
|
164
165
|
const each = `${formatEther(funds.price)} WETH each plus gas`;
|
|
165
166
|
if (options.dry) {
|
|
@@ -180,6 +181,8 @@ export async function mint(network, chain, options) {
|
|
|
180
181
|
}
|
|
181
182
|
if (rest)
|
|
182
183
|
log(rest.trim());
|
|
184
|
+
// The service scans in rounds and caches the list, so it still shows these tasks for a while.
|
|
185
|
+
log("`mint --dry` lists these tasks until the service has seen these mints, which can take minutes. A mint before then, here or by `run --mint`, pays for another piece.");
|
|
183
186
|
}
|
|
184
187
|
/**
|
|
185
188
|
* What `run` does once a task is delivered: it watches the task until it
|
|
@@ -216,7 +219,7 @@ export function createSettlementWatch(options) {
|
|
|
216
219
|
}
|
|
217
220
|
if (outcome === undefined) {
|
|
218
221
|
if (clock() - entry.deliveredAt >= WATCH_SEC)
|
|
219
|
-
drop(key, "not settled
|
|
222
|
+
drop(key, "not settled three days after delivery, so the worker stops watching it");
|
|
220
223
|
else
|
|
221
224
|
later(entry, STATUS_SEC);
|
|
222
225
|
return false;
|
package/dist/network.js
CHANGED
|
@@ -37,6 +37,7 @@ export const TESTNET = {
|
|
|
37
37
|
router: "0x1BBe71Dd3af85309A5c7Ab00B190F38f34Fe3569",
|
|
38
38
|
gas: "Get Robinhood Chain testnet ETH at https://faucet.testnet.chain.robinhood.com",
|
|
39
39
|
},
|
|
40
|
+
exitDesk: "0xf848984c8A0D9E49Cc9C97380C62b34C6f3b97D1",
|
|
40
41
|
contracts: {
|
|
41
42
|
taskBoard: "0x7A7cC5D35A642CCc268C7B263fF800bc7eBD52A4",
|
|
42
43
|
staking: "0x6aFC3EFB49CF9b14C2F7c6C4f6C0bb3eF0125e2b",
|
|
@@ -49,20 +50,20 @@ export const TESTNET = {
|
|
|
49
50
|
bountyToken: "0xC4ec72794bF3Ce9ba1DE4B38085464c2eb0cDCf2",
|
|
50
51
|
},
|
|
51
52
|
};
|
|
52
|
-
// The 3.
|
|
53
|
-
export const MAINNET_TASK_BOARD = "
|
|
54
|
-
export const MAINNET_WORKER_STAKING = "
|
|
55
|
-
export const MAINNET_PROPOSAL_BONDS = "
|
|
56
|
-
export const MAINNET_DECISION_EXECUTOR = "
|
|
53
|
+
// The 3.5.0 set. Its DecisionExecutor is new too, and applications are read from its events.
|
|
54
|
+
export const MAINNET_TASK_BOARD = "0x44b8464342Bbfb9B565FAE494DfDefa37D33A211";
|
|
55
|
+
export const MAINNET_WORKER_STAKING = "0xa4351E2b84eb634E93D9082907B2d0D3Aa91B98F";
|
|
56
|
+
export const MAINNET_PROPOSAL_BONDS = "0x33260Cd6b4F368031F5AF2B5413d0CE6bb2aFAA4";
|
|
57
|
+
export const MAINNET_DECISION_EXECUTOR = "0xFD268Ae2c1Ec0Db03C3C6b17E1AEDE973729FE29";
|
|
57
58
|
const BRAIN = "0x3367085Eb6C79BfB0fCa4f1433ec77bFA8CB8a37";
|
|
58
|
-
/** BrainChain mainnet, release 3.
|
|
59
|
+
/** BrainChain mainnet, release 3.5.0. */
|
|
59
60
|
export const MAINNET = {
|
|
60
61
|
id: "mainnet",
|
|
61
62
|
name: "BrainChain",
|
|
62
63
|
chainId: 274663,
|
|
63
64
|
rpc: "https://rpc.usebrainchain.com",
|
|
64
65
|
service: "https://brain.usebrainchain.com",
|
|
65
|
-
fromBlock:
|
|
66
|
+
fromBlock: 965n,
|
|
66
67
|
explorer: "https://usebrainchain.com",
|
|
67
68
|
stakeSymbol: "BRAIN",
|
|
68
69
|
bountySymbol: "USDG",
|
|
@@ -97,14 +98,19 @@ export const MAINNET = {
|
|
|
97
98
|
gas: "Send it a little ETH on Robinhood Chain",
|
|
98
99
|
tokens: { stake: "0xC80333bf7f4078e14Ce5b6e223aF607aDcA44418", bounty: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168" },
|
|
99
100
|
},
|
|
101
|
+
exitDesk: "0xafB0C4Ef895D4895040cdC6435B96D3EE8350c6E",
|
|
102
|
+
retired: [
|
|
103
|
+
{ release: "3.4.0", taskBoard: "0x8385bb4b8990411CcdD8e851B2676da1A4C1f3C4", staking: "0x21Fe66c04da07f85D855E9920251Df7d584c3D9F" },
|
|
104
|
+
{ release: "3.3.0", taskBoard: "0x72C890AA996bF7efcA88e3F5063B626560215160", staking: "0x0fb02559e704549794EdF3FBBBe91DB88bB849ee" },
|
|
105
|
+
],
|
|
100
106
|
contracts: {
|
|
101
107
|
taskBoard: MAINNET_TASK_BOARD,
|
|
102
108
|
staking: MAINNET_WORKER_STAKING,
|
|
103
109
|
bonds: MAINNET_PROPOSAL_BONDS,
|
|
104
110
|
executor: MAINNET_DECISION_EXECUTOR,
|
|
105
|
-
registry: "
|
|
106
|
-
log: "
|
|
107
|
-
gate: "
|
|
111
|
+
registry: "0xA7f8A21Cc9574DB049F525a4fd499DA49c0D51ed",
|
|
112
|
+
log: "0x2EF509bd9cA1e7C5FBe2C2FD7Ff25cE9F6f9cB68",
|
|
113
|
+
gate: "0x71B2dc3dEC1aEB000936380981DfC51E47AA4fb8",
|
|
108
114
|
stakeToken: BRAIN,
|
|
109
115
|
bountyToken: "0x8eBFEce96c0AeF7Fd23425D846a302a552c8c451",
|
|
110
116
|
},
|
package/dist/propose.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { BaseError, createPublicClient, createWalletClient, decodeErrorResult, decodeFunctionData,
|
|
2
|
-
import { erc20Errors } from "./abi.js";
|
|
3
|
-
import { bondsAbi, brainchainTransport, erc20Abi, executorAbi, RECEIPT_MS } from "./chain.js";
|
|
1
|
+
import { BaseError, createPublicClient, createWalletClient, decodeErrorResult, decodeFunctionData, encodeFunctionData, ExecutionRevertedError, getAddress, isAddress, isHex, parseAbi, parseEventLogs, size, } from "viem";
|
|
2
|
+
import { erc20Errors, transferErrors } from "./abi.js";
|
|
3
|
+
import { bondsAbi, brainchainTransport, callVetoed, erc20Abi, executorAbi, openProposalOf, RECEIPT_MS } from "./chain.js";
|
|
4
4
|
import { chainOf } from "./network.js";
|
|
5
5
|
import { gasHelp, stake, when } from "./standing.js";
|
|
6
6
|
/** Every call the executor can be asked to make, by the contract it calls. */
|
|
@@ -19,21 +19,20 @@ export const GOVERNED = {
|
|
|
19
19
|
registry: parseAbi(["function setModel(bytes32 id, address account, uint128 price, bool active)"]),
|
|
20
20
|
};
|
|
21
21
|
const TARGETS = Object.keys(GOVERNED);
|
|
22
|
-
/** What the brain reads from a proposal's URI, cut to the lengths it keeps. */
|
|
22
|
+
/** What the brain reads from a proposal's URI, cut to the lengths it keeps, in UTF-16 units as a JavaScript string counts them. */
|
|
23
23
|
export const LIMITS = { title: 120, rationale: 1_500, current: 300, proposed: 300 };
|
|
24
|
+
/** The most the brain reads of a whole case as JSON; past it, the case reads as blank and Jev decides on the call alone. */
|
|
25
|
+
export const CASE_BYTES = 4_096;
|
|
24
26
|
const proposalsAbi = parseAbi([
|
|
25
27
|
"event Expired(uint256 indexed id)",
|
|
26
28
|
"function decideWithin() view returns (uint64)",
|
|
27
29
|
]);
|
|
28
|
-
const executorReadsAbi = parseAbi([
|
|
29
|
-
"function vetoed(address target, bytes4 selector) view returns (bool)",
|
|
30
|
-
"function vetoedCall(bytes32) view returns (bool)",
|
|
31
|
-
"function changeThresholdBps() view returns (uint16)",
|
|
32
|
-
]);
|
|
30
|
+
const executorReadsAbi = parseAbi(["function changeThresholdBps() view returns (uint16)"]);
|
|
33
31
|
// The reverts a governed call can hit, so a rehearsal names the rule it broke.
|
|
34
32
|
const callErrorsAbi = [
|
|
35
|
-
...parseAbi(["error BadConfig()", "error BadFee()", "error ZeroAddress()", "error NotOwner()", "error NotSelf()", "error NotGuardian()"]),
|
|
33
|
+
...parseAbi(["error BadConfig()", "error BadFee()", "error ZeroAddress()", "error NotOwner()", "error NotSelf()", "error NotGuardian()", "error AccountShapedId()", "error UnknownModel()"]),
|
|
36
34
|
...erc20Errors,
|
|
35
|
+
...transferErrors,
|
|
37
36
|
];
|
|
38
37
|
/** Mirrors ProposalBonds.State. */
|
|
39
38
|
const State = { None: 0, Open: 1, Returned: 2, Burned: 3 };
|
|
@@ -169,7 +168,10 @@ export function caseURI(call, input) {
|
|
|
169
168
|
if (body[key].length > LIMITS[key])
|
|
170
169
|
throw new ProposalError(`--${key} is at most ${LIMITS[key]} characters`);
|
|
171
170
|
}
|
|
172
|
-
|
|
171
|
+
const json = Buffer.from(JSON.stringify(body));
|
|
172
|
+
if (json.byteLength > CASE_BYTES)
|
|
173
|
+
throw new ProposalError(`The case is ${json.byteLength} bytes as JSON, and Jev reads at most ${CASE_BYTES}, which text outside the Latin alphabet reaches before the character limits. Shorten it.`);
|
|
174
|
+
return `data:application/json;base64,${json.toString("base64")}`;
|
|
173
175
|
}
|
|
174
176
|
const LOG_RANGE = 50000n;
|
|
175
177
|
export function createProposals(network, account) {
|
|
@@ -204,20 +206,8 @@ export function createProposals(network, account) {
|
|
|
204
206
|
decideWithin: () => client.readContract({ ...proposals, functionName: "decideWithin" }),
|
|
205
207
|
changeThresholdBps: async () => BigInt(await client.readContract({ address: executor, abi: executorReadsAbi, functionName: "changeThresholdBps" })),
|
|
206
208
|
timelock: () => client.readContract({ address: executor, abi: executorAbi, functionName: "timelock" }),
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
try {
|
|
210
|
-
return await client.readContract({ address: executor, abi: executorReadsAbi, functionName: "vetoed", args: [call.address, slice(call.data, 0, 4)] });
|
|
211
|
-
}
|
|
212
|
-
catch {
|
|
213
|
-
return client.readContract({
|
|
214
|
-
address: executor,
|
|
215
|
-
abi: executorReadsAbi,
|
|
216
|
-
functionName: "vetoedCall",
|
|
217
|
-
args: [keccak256(encodeAbiParameters([{ type: "address" }, { type: "bytes" }], [call.address, call.data]))],
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
},
|
|
209
|
+
vetoed: (call) => callVetoed(client, executor, call.address, call.data),
|
|
210
|
+
openProposal: (call) => openProposalOf(client, network, call.address, call.data),
|
|
221
211
|
async rehearse(call) {
|
|
222
212
|
try {
|
|
223
213
|
await client.call({ account: executor, to: call.address, data: call.data });
|
|
@@ -230,8 +220,13 @@ export function createProposals(network, account) {
|
|
|
230
220
|
throw error;
|
|
231
221
|
const raw = error.walk((e) => typeof e.data === "string");
|
|
232
222
|
try {
|
|
233
|
-
if (raw?.data)
|
|
234
|
-
|
|
223
|
+
if (raw?.data) {
|
|
224
|
+
const decoded = decodeErrorResult({ abi: callErrorsAbi, data: raw.data });
|
|
225
|
+
// A plain Solidity revert carries its reason as text, and its error's name says nothing.
|
|
226
|
+
if (decoded.errorName === "Error")
|
|
227
|
+
return String(decoded.args?.[0]);
|
|
228
|
+
return `${decoded.errorName}, the contract's check on these values`;
|
|
229
|
+
}
|
|
235
230
|
}
|
|
236
231
|
catch {
|
|
237
232
|
// Not one of the contracts' own errors; the node's message says more.
|
|
@@ -251,7 +246,7 @@ export function createProposals(network, account) {
|
|
|
251
246
|
},
|
|
252
247
|
async get(id) {
|
|
253
248
|
const p = await client.readContract({ ...proposals, functionName: "get", args: [id] });
|
|
254
|
-
return { id, proposer: p.proposer, bond: p.bond, createdAt: p.createdAt, state: p.state };
|
|
249
|
+
return { id, proposer: p.proposer, bond: p.bond, createdAt: p.createdAt, state: p.state, deadline: p.deadline };
|
|
255
250
|
},
|
|
256
251
|
async outcome(id) {
|
|
257
252
|
const [settled, expired] = await Promise.all([
|
|
@@ -276,7 +271,7 @@ const days = (seconds) => `${Number(seconds) / 86_400} days`;
|
|
|
276
271
|
const percent = (bps) => `${Number(bps) / 100}%`;
|
|
277
272
|
export async function propose(network, chain, call, uri, options) {
|
|
278
273
|
const { log } = options;
|
|
279
|
-
const [bond, balance, gas, within, threshold, timelock, vetoed, revert] = await Promise.all([
|
|
274
|
+
const [bond, balance, gas, within, threshold, timelock, vetoed, open, revert] = await Promise.all([
|
|
280
275
|
chain.bond(),
|
|
281
276
|
chain.stakeBalance(),
|
|
282
277
|
chain.ethBalance(),
|
|
@@ -284,10 +279,13 @@ export async function propose(network, chain, call, uri, options) {
|
|
|
284
279
|
chain.changeThresholdBps(),
|
|
285
280
|
chain.timelock(),
|
|
286
281
|
chain.vetoed(call),
|
|
282
|
+
chain.openProposal(call),
|
|
287
283
|
chain.rehearse(call),
|
|
288
284
|
]);
|
|
289
285
|
if (vetoed)
|
|
290
286
|
throw new ProposalError(`The guardian vetoed ${call.text} on the ${call.target}, so the executor does not queue it until the admin reopens that function`);
|
|
287
|
+
if (open > 0n)
|
|
288
|
+
throw new ProposalError(`Proposal ${open} already asks for ${call.text} on the ${call.target} and is still open, and the contract takes one at a time. Follow it with \`npx brainchain-worker proposal ${open}\`.`);
|
|
291
289
|
if (revert)
|
|
292
290
|
throw new ProposalError(`${call.text} would fail if the executor made it now (${revert}), so the bond would be burned for nothing`);
|
|
293
291
|
log(`Proposal on ${network.name}
|
|
@@ -315,9 +313,8 @@ export async function proposal(network, chain, id, log) {
|
|
|
315
313
|
throw new ProposalError(`There is no proposal ${id} on ${network.name}`);
|
|
316
314
|
const head = `Proposal ${id} by ${p.proposer}, bond ${stake(network, p.bond)}`;
|
|
317
315
|
if (p.state === State.Open) {
|
|
318
|
-
const
|
|
319
|
-
const
|
|
320
|
-
const after = now > closes ? `its window closed at ${when(closes)}, so anyone can return the bond with expire` : `Jev decides by ${when(closes)}`;
|
|
316
|
+
const now = await chain.now();
|
|
317
|
+
const after = now > p.deadline ? `its window closed at ${when(p.deadline)}, so anyone can return the bond by calling expire on ${network.contracts.bonds}` : `Jev decides by ${when(p.deadline)}`;
|
|
321
318
|
log(`${head}\n Waiting for Jev: ${after}.`);
|
|
322
319
|
return;
|
|
323
320
|
}
|
package/dist/relay.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Relay (relay.link) moves value between Solana and Robinhood Chain in about a second.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Relay (relay.link) moves value between Solana and Robinhood Chain in about a second. `fund` uses it to bring USDC
|
|
2
|
+
// from Solana; every quote is checked against what goes in before anything is signed, so a bad price stops the move
|
|
3
|
+
// instead of costing the worker.
|
|
4
4
|
export const RELAY_API = "https://api.relay.link";
|
|
5
5
|
export const SOLANA_CHAIN_ID = 792703809;
|
|
6
6
|
/** USDC on Solana. */
|
|
@@ -8,6 +8,8 @@ export const SOLANA_USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
|
|
8
8
|
export const NATIVE = "0x0000000000000000000000000000000000000000";
|
|
9
9
|
/** The most a move may cost, as a share of its dollar value; small moves may cost up to FIXED_LOSS_USD instead. */
|
|
10
10
|
export const MAX_LOSS = 0.03;
|
|
11
|
+
/** The stake token's market alone costs about 3.7% at any size on top of Relay's fee, so the stake leg gets more room. */
|
|
12
|
+
export const MAX_STAKE_LOSS = 0.05;
|
|
11
13
|
/** Relay's fee is partly fixed (about 0.2 USD), so a small move is allowed to lose up to this much in dollars. */
|
|
12
14
|
export const FIXED_LOSS_USD = 0.5;
|
|
13
15
|
export class RelayError extends Error {
|
|
@@ -26,33 +28,37 @@ export async function relayQuote(request, fetchImpl = fetch) {
|
|
|
26
28
|
const requestId = body.steps.find((s) => s.requestId)?.requestId;
|
|
27
29
|
if (!requestId)
|
|
28
30
|
throw new RelayError("Relay's quote carries no request id to follow");
|
|
31
|
+
const out = body.details.currencyOut;
|
|
29
32
|
return {
|
|
30
33
|
requestId,
|
|
31
34
|
steps: body.steps,
|
|
32
35
|
inUsd: Number(body.details.currencyIn?.amountUsd ?? 0),
|
|
33
|
-
outUsd: Number(
|
|
34
|
-
outAmount: BigInt(
|
|
35
|
-
outFormatted: String(
|
|
36
|
+
outUsd: Number(out?.amountUsd ?? 0),
|
|
37
|
+
outAmount: /^\d+$/.test(String(out?.amount)) ? BigInt(out.amount) : 0n,
|
|
38
|
+
outFormatted: String(out?.amountFormatted ?? "0"),
|
|
39
|
+
recipient: body.details.recipient,
|
|
40
|
+
outCurrency: out?.currency?.address,
|
|
41
|
+
outChainId: out?.currency?.chainId,
|
|
36
42
|
};
|
|
37
43
|
}
|
|
38
|
-
/** Refuses a quote that loses more than
|
|
39
|
-
export function checkReturn(quote) {
|
|
44
|
+
/** Refuses a quote that loses more than `maxLoss` of its dollar value, or FIXED_LOSS_USD on a small move. */
|
|
45
|
+
export function checkReturn(quote, maxLoss = MAX_LOSS) {
|
|
40
46
|
if (!(quote.inUsd > 0))
|
|
41
47
|
throw new RelayError("Relay's quote has no dollar value for what goes in, so its price cannot be checked");
|
|
42
48
|
const loss = quote.inUsd - quote.outUsd;
|
|
43
|
-
const allowed = Math.max(quote.inUsd *
|
|
49
|
+
const allowed = Math.max(quote.inUsd * maxLoss, FIXED_LOSS_USD);
|
|
44
50
|
if (loss > allowed) {
|
|
45
|
-
throw new RelayError(`Relay would keep ${loss.toFixed(2)} USD of ${quote.inUsd.toFixed(2)}, more than the ${allowed.toFixed(2)} this command accepts (
|
|
51
|
+
throw new RelayError(`Relay would keep ${loss.toFixed(2)} USD of ${quote.inUsd.toFixed(2)}, more than the ${allowed.toFixed(2)} this command accepts (${Math.round(maxLoss * 100)}%, or 0.50 USD on a small move). Nothing was sent; try again later.`);
|
|
46
52
|
}
|
|
47
53
|
}
|
|
48
54
|
export async function relayStatus(requestId, fetchImpl = fetch) {
|
|
49
|
-
const response = await fetchImpl(`${RELAY_API}/intents/status/v2?requestId=${requestId}`, { signal: AbortSignal.timeout(20_000) });
|
|
55
|
+
const response = await fetchImpl(`${RELAY_API}/intents/status/v2?requestId=${encodeURIComponent(requestId)}`, { signal: AbortSignal.timeout(20_000) });
|
|
50
56
|
const body = (await response.json().catch(() => ({})));
|
|
51
57
|
if (body.status === "success" || body.status === "failure" || body.status === "refund")
|
|
52
58
|
return body.status;
|
|
53
59
|
return "pending";
|
|
54
60
|
}
|
|
55
|
-
/** Follows a move until Relay
|
|
61
|
+
/** Follows a move until Relay answers anything but pending, for at most `ms`. Its `failure` is usually not final: Relay can still retry or refund. */
|
|
56
62
|
export async function waitForRelay(requestId, ms = 600_000, fetchImpl = fetch, sleep = (t) => new Promise((r) => setTimeout(r, t))) {
|
|
57
63
|
const end = Date.now() + ms;
|
|
58
64
|
for (;;) {
|