brainchain-worker 0.12.2 → 0.13.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 +40 -17
- package/dist/chain.js +21 -3
- package/dist/cli.js +137 -65
- package/dist/dispute.js +1 -2
- package/dist/key.js +92 -0
- package/dist/network.js +3 -3
- package/dist/propose.js +1 -1
- package/dist/standing.js +47 -3
- package/dist/state.js +150 -19
- package/dist/watch.js +83 -25
- package/dist/work.js +61 -4
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { verifyDecision } from "brainchain-verify";
|
|
3
|
-
import { createPublicClient } from "viem";
|
|
4
|
-
import {
|
|
3
|
+
import { createPublicClient, http } from "viem";
|
|
4
|
+
import { parseUnits } from "viem";
|
|
5
5
|
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
|
|
6
|
-
import { brainchainTransport, createChain, faucetReopens, reason, unhealthy } from "./chain.js";
|
|
6
|
+
import { brainchainTransport, createChain, faucetReopens, reason, requireChainId, unhealthy } from "./chain.js";
|
|
7
7
|
import { assetOf, bridge, createBridgeChain } from "./bridge.js";
|
|
8
8
|
import { claim, createIncome } from "./claim.js";
|
|
9
9
|
import { createProofMinter, createProofsChain, MAX_MINT, mint } from "./mint.js";
|
|
10
10
|
import { chainOf, resolveNetwork } from "./network.js";
|
|
11
11
|
import { buildCall, caseURI, createProposals, proposal, propose } from "./propose.js";
|
|
12
|
-
import { hireable, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
|
|
12
|
+
import { ethNeeded, gasLine, hireable, joinLimits, shortOfGas, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
|
|
13
13
|
import { openStore, readWatched, writeWatched } from "./state.js";
|
|
14
|
+
import { clearKeyFromEnvironment, keyConfigured, keyText, writeKeyFile } from "./key.js";
|
|
14
15
|
import { createCreditChain, credit } from "./credit.js";
|
|
15
16
|
import { appeal, createDisputeChain, dispute } from "./dispute.js";
|
|
16
|
-
import { createReporter, createWatch, createWatchChain } from "./watch.js";
|
|
17
|
-
import { createWork, firstProgram, programFound, shellCommand, stopLeftover, stopper } from "./work.js";
|
|
17
|
+
import { createReporter, createWatch, createWatchChain, requireWatcher } from "./watch.js";
|
|
18
|
+
import { createWork, firstProgram, npxUnderSupervisor, programFound, shellCommand, stopLeftover, stopper, watchStarter } from "./work.js";
|
|
18
19
|
/** How many of the latest decisions a first watch --once checks. */
|
|
19
20
|
const FIRST_RUN_DECISIONS = 10n;
|
|
20
21
|
/** A published record's bytes as the service serves them, capped at a megabyte. */
|
|
@@ -28,71 +29,81 @@ async function fetchRecord(uri) {
|
|
|
28
29
|
return bytes;
|
|
29
30
|
}
|
|
30
31
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
brainchain-worker new-key Print a fresh private key and its address.
|
|
37
|
-
brainchain-worker join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]
|
|
32
|
+
/** Each command's lines in the usage text, in the order the text lists them. */
|
|
33
|
+
const COMMAND_USAGE = {
|
|
34
|
+
"new-key": ` brainchain-worker new-key [--file <path>] Print a fresh private key and its address. --file writes the key
|
|
35
|
+
to a new file only you can read and prints only the address.`,
|
|
36
|
+
join: ` brainchain-worker join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]
|
|
38
37
|
Stake the minimum and apply to take tasks. The name is at
|
|
39
38
|
most 60 characters, the description at most 500. --agent-id
|
|
40
39
|
(mainnet) names the worker's ERC-8004 agent on Robinhood Chain,
|
|
41
|
-
so its BrainChain record is written to that identity
|
|
42
|
-
brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--no-mint]
|
|
40
|
+
so its BrainChain record is written to that identity.`,
|
|
41
|
+
run: ` brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--no-mint]
|
|
43
42
|
Do every task Jev hires the worker for: the command reads the
|
|
44
43
|
task on stdin and prints the deliverable on stdout, within
|
|
45
44
|
--timeout minutes (20 by default, at most 1440). The worker
|
|
46
|
-
mints the Proof of each task it is paid for, unless --no-mint
|
|
47
|
-
brainchain-worker status Stake, wallet, application, record and earnings
|
|
48
|
-
brainchain-worker price <USDG> Change your asking price per task
|
|
49
|
-
brainchain-worker mint [--count <n>] [--dry]
|
|
45
|
+
mints the Proof of each task it is paid for, unless --no-mint.`,
|
|
46
|
+
status: ` brainchain-worker status Stake, wallet, application, record and earnings.`,
|
|
47
|
+
price: ` brainchain-worker price <USDG> Change your asking price per task.`,
|
|
48
|
+
mint: ` brainchain-worker mint [--count <n>] [--dry]
|
|
50
49
|
Mint a Proof for each paid task waiting for one, up to 10 or
|
|
51
|
-
--count, at 0.001 WETH each on Robinhood Chain. --dry sends nothing
|
|
52
|
-
brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
|
|
53
|
-
--dry lists what is due and sends nothing
|
|
54
|
-
brainchain-worker bridge --eth <ETH> | --stake <amount> | --bounty <amount> [--dry]
|
|
50
|
+
--count, at 0.001 WETH each on Robinhood Chain. --dry sends nothing.`,
|
|
51
|
+
claim: ` brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
|
|
52
|
+
--dry lists what is due and sends nothing.`,
|
|
53
|
+
bridge: ` brainchain-worker bridge --eth <ETH> | --stake <amount> | --bounty <amount> [--dry]
|
|
55
54
|
Move ETH for gas, the stake token or the bounty token from Robinhood
|
|
56
55
|
Chain to the worker's address on BrainChain; it arrives in about a
|
|
57
|
-
minute. --
|
|
58
|
-
|
|
56
|
+
minute. --eth works on both networks, the tokens on mainnet. --dry
|
|
57
|
+
checks the balances and sends nothing.`,
|
|
58
|
+
propose: ` brainchain-worker propose --target board|executor|registry --call <function> --args '<JSON array>'
|
|
59
59
|
--title <text> --rationale <text> [--current <text>] [--proposed <text>] [--dry]
|
|
60
60
|
Bond the stake token and propose a change to BrainChain for Jev to
|
|
61
61
|
decide. --data <hex> gives the call as raw calldata instead of
|
|
62
|
-
--call and --args. --dry checks the call and sends nothing
|
|
63
|
-
brainchain-worker proposal <id> Where a proposal stands: waiting, adopted and when it runs, or kept
|
|
64
|
-
brainchain-worker watch [--veto] [--once]
|
|
62
|
+
--call and --args. --dry checks the call and sends nothing.`,
|
|
63
|
+
proposal: ` brainchain-worker proposal <id> Where a proposal stands: waiting, adopted and when it runs, or kept.`,
|
|
64
|
+
watch: ` brainchain-worker watch [--veto] [--once]
|
|
65
65
|
Watch the chain: judge every queued change against the constitution
|
|
66
66
|
and re-verify every new decision, reporting what disagrees. With the
|
|
67
67
|
worker's key set, each round is reported to the service toward the
|
|
68
68
|
worker's watchtower mark. --veto vetoes a change that crosses a floor
|
|
69
69
|
through the guardian gate, when the worker's key is the gate's
|
|
70
70
|
watcher. --once reads one round, checks the decisions added since
|
|
71
|
-
the last --once (the latest 10 on the first) and exits 1 on a finding
|
|
72
|
-
|
|
71
|
+
the last --once (the latest 10 on the first) and exits 1 on a finding
|
|
72
|
+
or a decision it could not check.`,
|
|
73
|
+
credit: ` brainchain-worker credit --agent-id <id> [--premium <bps>]
|
|
73
74
|
Ask BrainChain to back a credit line for the worker's ERC-8004 agent
|
|
74
75
|
on Priors (priors.trade): signs Priors' consent with the agent owner's
|
|
75
76
|
key and sends it. The line opens once the worker is proven on the
|
|
76
77
|
board. --premium is the most the sponsor may charge per 30 days, in
|
|
77
|
-
basis points (default 0, at most 200). Mainnet only
|
|
78
|
-
brainchain-worker dispute <task> Dispute the grade on a task the worker delivered, within the dispute
|
|
79
|
-
window. Jev rules on it; a confident ruling decides
|
|
80
|
-
brainchain-worker appeal <task> Appeal the ruling on a task, when it went against the worker, within the
|
|
78
|
+
basis points (default 0, at most 200). Mainnet only.`,
|
|
79
|
+
dispute: ` brainchain-worker dispute <task> Dispute the grade on a task the worker delivered, within the dispute
|
|
80
|
+
window. Jev rules on it; a confident ruling decides.`,
|
|
81
|
+
appeal: ` brainchain-worker appeal <task> Appeal the ruling on a task, when it went against the worker, within the
|
|
81
82
|
appeal window, with the bond the task's terms fix (a quarter of the
|
|
82
83
|
bounty). Jev rules again; the bond comes back unless Jev confidently
|
|
83
|
-
rules the same way. Testnet only for now
|
|
84
|
-
brainchain-worker leave Start unstaking (3-day cooldown)
|
|
85
|
-
brainchain-worker withdraw Withdraw stake once the cooldown has passed
|
|
84
|
+
rules the same way. Testnet only for now.`,
|
|
85
|
+
leave: ` brainchain-worker leave Start unstaking (3-day cooldown).`,
|
|
86
|
+
withdraw: ` brainchain-worker withdraw Withdraw stake once the cooldown has passed.`,
|
|
87
|
+
};
|
|
88
|
+
const USAGE = `brainchain-worker ${version}
|
|
89
|
+
|
|
90
|
+
Earn on BrainChain by doing tasks: stake, apply, and let Jev hire your agent.
|
|
91
|
+
|
|
92
|
+
Usage:
|
|
93
|
+
${Object.values(COMMAND_USAGE).join("\n")}
|
|
86
94
|
brainchain-worker --version Print the version.
|
|
87
95
|
|
|
88
96
|
Environment:
|
|
89
97
|
BRAINCHAIN_WORKER_KEY Private key of the worker's wallet, 64 hex characters with or without 0x.
|
|
98
|
+
BRAINCHAIN_WORKER_KEY_FILE
|
|
99
|
+
A file that holds the key, used instead, so no process's environment holds it.
|
|
90
100
|
BRAINCHAIN_NETWORK testnet (the default) or mainnet.
|
|
91
101
|
BRAINCHAIN_RPC_URL Overrides the network's RPC.
|
|
92
102
|
BRAINCHAIN_SERVICE_URL Overrides the BrainChain service that keeps results: https, or http on
|
|
93
103
|
localhost or 127.0.0.1.
|
|
94
104
|
|
|
95
105
|
The command sees the worker's environment without any BRAINCHAIN_ variable, plus BRAINCHAIN_TASK_ID.
|
|
106
|
+
It runs as your user, so it can reach what your user can: see "Your command" in the README.
|
|
96
107
|
Unfinished work is kept in ~/.config/brainchain-worker/<address>.<network>.json across restarts,
|
|
97
108
|
and one run at a time works for each address on each network.
|
|
98
109
|
|
|
@@ -102,6 +113,7 @@ export class UsageError extends Error {
|
|
|
102
113
|
}
|
|
103
114
|
/** The flags each command takes; `true` marks a switch, which takes no value. */
|
|
104
115
|
const FLAGS = {
|
|
116
|
+
"new-key": { file: false },
|
|
105
117
|
join: { name: false, about: false, price: false, "agent-id": false },
|
|
106
118
|
run: { exec: false, format: false, timeout: false, once: true, "no-mint": true },
|
|
107
119
|
status: {},
|
|
@@ -119,8 +131,10 @@ const FLAGS = {
|
|
|
119
131
|
appeal: {},
|
|
120
132
|
};
|
|
121
133
|
const POSITIONAL = { price: 1, proposal: 1, dispute: 1, appeal: 1 };
|
|
134
|
+
/** A command's flags; a name from argv can match what every object inherits, such as constructor. */
|
|
135
|
+
const flagsOf = (command) => (Object.hasOwn(FLAGS, command) ? FLAGS[command] : undefined);
|
|
122
136
|
export function parse(command, args) {
|
|
123
|
-
const known =
|
|
137
|
+
const known = flagsOf(command);
|
|
124
138
|
if (known === undefined)
|
|
125
139
|
throw new UsageError(`Unknown command "${command}" (see brainchain-worker --help)`);
|
|
126
140
|
const positional = [];
|
|
@@ -133,7 +147,7 @@ export function parse(command, args) {
|
|
|
133
147
|
continue;
|
|
134
148
|
}
|
|
135
149
|
const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
136
|
-
const isSwitch = known[name];
|
|
150
|
+
const isSwitch = Object.hasOwn(known, name) ? known[name] : undefined;
|
|
137
151
|
if (isSwitch === undefined)
|
|
138
152
|
throw new UsageError(`The ${command} command has no --${name} flag (see brainchain-worker --help)`);
|
|
139
153
|
if (isSwitch) {
|
|
@@ -151,19 +165,39 @@ export function parse(command, args) {
|
|
|
151
165
|
throw new UsageError(`Unexpected "${positional.at(-1)}" (see brainchain-worker --help)`);
|
|
152
166
|
return { positional, values, switches };
|
|
153
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Whether the arguments ask for help as a flag of their own. It reads them as
|
|
170
|
+
* parse does: `--about -h` is a description, and a flag's value never starts
|
|
171
|
+
* with --, so `--about --help` still asks.
|
|
172
|
+
*/
|
|
173
|
+
export function asksHelp(command, args) {
|
|
174
|
+
const known = flagsOf(command) ?? {};
|
|
175
|
+
for (let i = 0; i < args.length; i++) {
|
|
176
|
+
const arg = args[i];
|
|
177
|
+
if (arg === "--help" || arg === "-h")
|
|
178
|
+
return true;
|
|
179
|
+
if (arg.startsWith("--") && !arg.includes("=") && Object.hasOwn(known, arg.slice(2)) && known[arg.slice(2)] === false && !args[i + 1]?.startsWith("--"))
|
|
180
|
+
i++;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
/** One command's usage lines, or the whole text for anything else. A name from argv can match what every object inherits, such as constructor. */
|
|
185
|
+
export function usage(command) {
|
|
186
|
+
return command !== undefined && Object.hasOwn(COMMAND_USAGE, command) ? `Usage:\n${COMMAND_USAGE[command]}` : USAGE;
|
|
187
|
+
}
|
|
154
188
|
function signer() {
|
|
155
|
-
const raw =
|
|
189
|
+
const raw = keyText()?.trim();
|
|
156
190
|
if (!raw)
|
|
157
|
-
throw new Error("Set BRAINCHAIN_WORKER_KEY to the worker's private key. `npx brainchain-worker new-key` makes one.");
|
|
191
|
+
throw new Error("Set BRAINCHAIN_WORKER_KEY to the worker's private key, or BRAINCHAIN_WORKER_KEY_FILE to a file that holds it. `npx brainchain-worker new-key` makes one.");
|
|
158
192
|
const hex = raw.startsWith("0x") ? raw.slice(2) : raw;
|
|
159
193
|
if (!/^[0-9a-fA-F]{64}$/.test(hex))
|
|
160
|
-
throw new Error("
|
|
194
|
+
throw new Error("The worker's key must be 64 hex characters, with or without 0x");
|
|
161
195
|
const key = `0x${hex.toLowerCase()}`;
|
|
162
196
|
try {
|
|
163
197
|
return { key, account: privateKeyToAccount(key) };
|
|
164
198
|
}
|
|
165
199
|
catch {
|
|
166
|
-
throw new Error("
|
|
200
|
+
throw new Error("The worker's key is not a valid private key. `npx brainchain-worker new-key` makes one.");
|
|
167
201
|
}
|
|
168
202
|
}
|
|
169
203
|
const DEFAULT_PRICE = "0.20";
|
|
@@ -175,8 +209,9 @@ function price(value) {
|
|
|
175
209
|
return parseUnits(value, 6);
|
|
176
210
|
}
|
|
177
211
|
const RUN = '`npx brainchain-worker run --exec "<command>"`';
|
|
212
|
+
const KEEP_RUNNING = "https://github.com/usebrainchain/brainchain-worker#keep-it-running";
|
|
178
213
|
function noGas(chain, network) {
|
|
179
|
-
return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}
|
|
214
|
+
return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}.`);
|
|
180
215
|
}
|
|
181
216
|
async function needGas(chain, network) {
|
|
182
217
|
if ((await chain.ethBalance()) === 0n)
|
|
@@ -201,11 +236,8 @@ export async function join(chain, network, values) {
|
|
|
201
236
|
throw new UsageError("--agent-id works on mainnet only, where BrainChain writes each worker's record to its ERC-8004 identity");
|
|
202
237
|
}
|
|
203
238
|
const requested = values.has("price") ? price(values.get("price")) : undefined;
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
if (gas === 0n && !network.buyStake)
|
|
207
|
-
throw noGas(chain, network);
|
|
208
|
-
const { allowed, w, change, pending, open, applying, toStake } = await snapshot(chain);
|
|
239
|
+
const standing = await snapshot(chain);
|
|
240
|
+
const { allowed, w, change, pending, open, applying, toStake } = standing;
|
|
209
241
|
// The agent id travels in the application, and Jev keeps the profile it decided on.
|
|
210
242
|
if (agentId !== undefined) {
|
|
211
243
|
if (allowed || (change && pending) || open) {
|
|
@@ -220,15 +252,19 @@ export async function join(chain, network, values) {
|
|
|
220
252
|
}
|
|
221
253
|
// A worker that has staked keeps its price unless --price changes it.
|
|
222
254
|
const asking = requested ?? (w.registered ? w.price : price(DEFAULT_PRICE));
|
|
223
|
-
const [bond, balance, timelock] = await Promise.all([chain.bond(), chain.stakeBalance(), chain.timelock()]);
|
|
255
|
+
const [gas, gasPrice, bond, balance, timelock] = await Promise.all([chain.ethBalance(), chain.gasPrice(), chain.bond(), chain.stakeBalance(), chain.timelock()]);
|
|
224
256
|
const need = toStake + (applying ? bond : 0n);
|
|
257
|
+
const claims = balance < need && !network.buyStake;
|
|
258
|
+
// Checked before anything is sent: a wallet that runs out partway leaves the stake placed and the application unsent.
|
|
259
|
+
const gasNeed = ethNeeded(joinLimits(standing, claims, toStake === 0n && w.price !== asking), gasPrice);
|
|
260
|
+
const lacksGas = gas < gasNeed ? `${chain.account} holds ${shortOfGas(network, gas, gasNeed, "join")}` : undefined;
|
|
225
261
|
if (balance < need && network.buyStake) {
|
|
226
262
|
const short = shortOfStake(network, w, balance, need, await chain.now());
|
|
227
263
|
// Both needs at once, so a new worker does not bridge ETH only to learn it needs the stake token too.
|
|
228
|
-
throw new Error(
|
|
264
|
+
throw new Error(lacksGas ? `${lacksGas}\n${short}` : short);
|
|
229
265
|
}
|
|
230
|
-
if (
|
|
231
|
-
throw
|
|
266
|
+
if (lacksGas)
|
|
267
|
+
throw new Error(lacksGas);
|
|
232
268
|
if (balance < need) {
|
|
233
269
|
const [faucet, now] = await Promise.all([chain.faucet(), chain.now()]);
|
|
234
270
|
const closed = (opensAt) => {
|
|
@@ -275,8 +311,9 @@ export async function join(chain, network, values) {
|
|
|
275
311
|
"the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
|
|
276
312
|
}
|
|
277
313
|
async function status(chain, network) {
|
|
278
|
-
const [gas, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
|
|
314
|
+
const [gas, gasPrice, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
|
|
279
315
|
chain.ethBalance(),
|
|
316
|
+
chain.gasPrice(),
|
|
280
317
|
snapshot(chain),
|
|
281
318
|
chain.held(),
|
|
282
319
|
chain.record(),
|
|
@@ -296,7 +333,7 @@ async function status(chain, network) {
|
|
|
296
333
|
Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
|
|
297
334
|
Record ${record.accepted} accepted, ${record.refunded} refunded, ${record.inFlight} in flight${held > 0 ? " (stake held until they settle)" : ""}
|
|
298
335
|
Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, bountyBalance)}
|
|
299
|
-
Gas ${
|
|
336
|
+
Gas ${gasLine(network, s, gas, gasPrice, !network.buyStake && stakeBalance < s.toStake + (s.applying ? bond : 0n))}`);
|
|
300
337
|
}
|
|
301
338
|
async function run(network, { key, account }, values, once, mints) {
|
|
302
339
|
const exec = values.get("exec");
|
|
@@ -310,6 +347,7 @@ async function run(network, { key, account }, values, once, mints) {
|
|
|
310
347
|
throw new UsageError("--timeout is the most minutes the command may spend on one task: more than 0, at most 1440, and 20 by default");
|
|
311
348
|
}
|
|
312
349
|
const log = (message) => console.log(`${new Date().toISOString().slice(0, 19)}Z ${message}`);
|
|
350
|
+
clearKeyFromEnvironment(key, log);
|
|
313
351
|
const program = firstProgram(exec);
|
|
314
352
|
if (program && !programFound(program))
|
|
315
353
|
log(`warning: the shell cannot find ${program}, so --exec will fail when the worker is hired`);
|
|
@@ -336,6 +374,14 @@ async function run(network, { key, account }, values, once, mints) {
|
|
|
336
374
|
});
|
|
337
375
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
338
376
|
process.on(signal, stop.signal);
|
|
377
|
+
if (process.env.npm_execpath !== undefined) {
|
|
378
|
+
watchStarter(() => {
|
|
379
|
+
log("npx, which started this run, is gone, so this counts as a stop");
|
|
380
|
+
stop.signal("SIGTERM");
|
|
381
|
+
});
|
|
382
|
+
if (npxUnderSupervisor())
|
|
383
|
+
log(`warning: a supervisor's stop reaches npx and ends this run with its task. For a stop that lets the task finish, install the package and start the brainchain-worker binary: ${KEEP_RUNNING}`);
|
|
384
|
+
}
|
|
339
385
|
let gas;
|
|
340
386
|
try {
|
|
341
387
|
gas = await chain.ethBalance();
|
|
@@ -384,10 +430,20 @@ async function run(network, { key, account }, values, once, mints) {
|
|
|
384
430
|
await minting;
|
|
385
431
|
log("stopped");
|
|
386
432
|
}
|
|
433
|
+
/** What watch says at start. Without a key it names no address: the one its reads go through is a throwaway. */
|
|
434
|
+
export function watching(network, address, veto) {
|
|
435
|
+
if (address === undefined)
|
|
436
|
+
return `watching ${network.name} (read-only, no key)`;
|
|
437
|
+
return `watching ${network.name} as ${address}${veto ? ", vetoing through the guardian gate" : ""}, reporting rounds to the service`;
|
|
438
|
+
}
|
|
387
439
|
export async function main(argv) {
|
|
388
440
|
const [command, ...rest] = argv;
|
|
389
|
-
if (command === undefined || command === "help" ||
|
|
390
|
-
console.log(
|
|
441
|
+
if (command === undefined || command === "help" || command === "--help" || command === "-h") {
|
|
442
|
+
console.log(usage(rest[0]));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (asksHelp(command, rest)) {
|
|
446
|
+
console.log(usage(command));
|
|
391
447
|
return;
|
|
392
448
|
}
|
|
393
449
|
if (command === "--version" || command === "-v") {
|
|
@@ -395,12 +451,22 @@ export async function main(argv) {
|
|
|
395
451
|
return;
|
|
396
452
|
}
|
|
397
453
|
if (command === "new-key") {
|
|
454
|
+
const file = parse(command, rest).values.get("file");
|
|
398
455
|
const fresh = generatePrivateKey();
|
|
399
|
-
|
|
456
|
+
const { address } = privateKeyToAccount(fresh);
|
|
457
|
+
if (file === undefined) {
|
|
458
|
+
console.log(`BRAINCHAIN_WORKER_KEY=${fresh}\naddress ${address}\nKeep the key secret; it controls the stake and the earnings.`);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
writeKeyFile(file, fresh);
|
|
462
|
+
console.log(`BRAINCHAIN_WORKER_KEY_FILE=${file}\naddress ${address}\nKeep the file secret; it controls the stake and the earnings.`);
|
|
400
463
|
return;
|
|
401
464
|
}
|
|
402
465
|
const { positional, values, switches } = parse(command, rest);
|
|
403
466
|
const network = resolveNetwork(process.env);
|
|
467
|
+
if (process.env.BRAINCHAIN_RPC_URL?.trim()) {
|
|
468
|
+
await requireChainId(network, () => createPublicClient({ transport: http(network.rpc, { timeout: 10_000, retryCount: 0 }) }).getChainId());
|
|
469
|
+
}
|
|
404
470
|
if (command === "claim") {
|
|
405
471
|
return claim(network, (income) => createIncome(network.proofs, income, signer().account), { dry: switches.has("dry"), log: console.log });
|
|
406
472
|
}
|
|
@@ -438,7 +504,7 @@ export async function main(argv) {
|
|
|
438
504
|
const veto = switches.has("veto");
|
|
439
505
|
const once = switches.has("once");
|
|
440
506
|
// Judging changes needs no key; vetoing and reporting do. A reader stands in for the signer otherwise.
|
|
441
|
-
const keyed = veto ||
|
|
507
|
+
const keyed = veto || keyConfigured();
|
|
442
508
|
const account = keyed ? signer().account : privateKeyToAccount(generatePrivateKey());
|
|
443
509
|
const client = createPublicClient({ chain: chainOf(network), transport: brainchainTransport(network) });
|
|
444
510
|
const { taskBoard, executor, log } = network.contracts;
|
|
@@ -454,9 +520,10 @@ export async function main(argv) {
|
|
|
454
520
|
const record = findings.length === 0 && keyed && report.payloadURI ? await fetchRecord(report.payloadURI).catch(() => undefined) : undefined;
|
|
455
521
|
return { findings, ...(record ? { record } : {}) };
|
|
456
522
|
};
|
|
523
|
+
const chain = createWatchChain(network, account);
|
|
457
524
|
const watch = createWatch({
|
|
458
525
|
network,
|
|
459
|
-
chain
|
|
526
|
+
chain,
|
|
460
527
|
verify,
|
|
461
528
|
...(keyed ? { report: createReporter(network, account) } : {}),
|
|
462
529
|
veto,
|
|
@@ -466,10 +533,12 @@ export async function main(argv) {
|
|
|
466
533
|
});
|
|
467
534
|
if (veto && !network.contracts.gate)
|
|
468
535
|
throw new UsageError(`${network.name} has no guardian gate yet, so there is nothing to veto through`);
|
|
469
|
-
|
|
536
|
+
if (veto)
|
|
537
|
+
await requireWatcher(chain, console.log);
|
|
538
|
+
console.log(watching(network, keyed ? account.address : undefined, veto));
|
|
470
539
|
if (once) {
|
|
471
|
-
const { findings } = await watch.round();
|
|
472
|
-
if (findings > 0)
|
|
540
|
+
const { findings, incomplete } = await watch.round();
|
|
541
|
+
if (findings > 0 || incomplete)
|
|
473
542
|
process.exitCode = 1;
|
|
474
543
|
return;
|
|
475
544
|
}
|
|
@@ -483,7 +552,7 @@ export async function main(argv) {
|
|
|
483
552
|
if (id === undefined || !/^\d{1,18}$/.test(id) || id === "0")
|
|
484
553
|
throw new UsageError("Usage: brainchain-worker proposal <id>, such as proposal 2");
|
|
485
554
|
// Reading a proposal needs no key, so any address stands in for the signer.
|
|
486
|
-
const reader =
|
|
555
|
+
const reader = keyConfigured() ? signer().account : privateKeyToAccount(generatePrivateKey());
|
|
487
556
|
return proposal(network, createProposals(network, reader), BigInt(id), console.log);
|
|
488
557
|
}
|
|
489
558
|
const worker = signer();
|
|
@@ -517,8 +586,11 @@ export async function main(argv) {
|
|
|
517
586
|
if (positional[0] === undefined)
|
|
518
587
|
throw new UsageError("Usage: brainchain-worker price <USDG>, such as price 0.25");
|
|
519
588
|
const asking = price(positional[0]);
|
|
520
|
-
|
|
589
|
+
const w = await chain.stake();
|
|
590
|
+
if (!w.registered)
|
|
521
591
|
throw new Error("This worker has not staked yet. Run join, which sets the price too.");
|
|
592
|
+
if (w.price === asking)
|
|
593
|
+
return console.log(`The price is already ${usdg(network, asking)} per task.`);
|
|
522
594
|
await needGas(chain, network);
|
|
523
595
|
await chain.setPrice(asking);
|
|
524
596
|
console.log(`Price set to ${usdg(network, asking)} per task.`);
|
package/dist/dispute.js
CHANGED
|
@@ -2,8 +2,7 @@ import { createPublicClient, createWalletClient, formatUnits, parseAbi } from "v
|
|
|
2
2
|
import { brainchainTransport, erc20Abi, RECEIPT_MS, Status } from "./chain.js";
|
|
3
3
|
import { chainOf } from "./network.js";
|
|
4
4
|
/** TaskBoard statuses from set 3.5.0 on: a ruling that waits out its appeal window, and one under appeal. */
|
|
5
|
-
export const Ruled =
|
|
6
|
-
export const Appealed = 9;
|
|
5
|
+
export const { Ruled, Appealed } = Status;
|
|
7
6
|
const boardAbi = parseAbi([
|
|
8
7
|
"function get(uint256 id) view returns ((address poster, uint128 bounty, uint64 deadline, uint8 status, bool accepted, bool disputed, bool disputedByModel, bytes32 model, bytes32 briefHash, bytes32 resultHash, uint64 submittedAt, uint64 gradedAt, uint64 disputedAt, uint64 postedAt, address payee, address feeRecipient, uint16 feeBps, uint16 hireThresholdBps, uint16 gradeThresholdBps, uint16 ruleThresholdBps, uint64 gradeWindow, uint64 disputeWindow, uint64 ruleWindow))",
|
|
9
8
|
"function appealOf(uint256 id) view returns ((uint64 window, uint16 bondBps, uint64 ruledAt, bool pays, bool confident, address appellant, uint128 bond, uint64 appealedAt))",
|
package/dist/key.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { accessSync, constants, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { stateDir } from "./state.js";
|
|
6
|
+
/** Names the file a run passes its key through when it restarts itself without the key in its environment. */
|
|
7
|
+
const HANDOFF = "BRAINCHAIN_WORKER_KEY_HANDOFF";
|
|
8
|
+
const HANDOFF_NAME = /^brainchain-worker-key-[0-9a-f]{32}$/;
|
|
9
|
+
const expandHome = (path) => (path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path);
|
|
10
|
+
/** Whether the environment names a key, without reading it: a key file can be a pipe, which reads only once. */
|
|
11
|
+
export const keyConfigured = (env = process.env) => Boolean(env[HANDOFF] || env.BRAINCHAIN_WORKER_KEY?.trim() || env.BRAINCHAIN_WORKER_KEY_FILE?.trim());
|
|
12
|
+
function fromFile(path, warn) {
|
|
13
|
+
const file = expandHome(path);
|
|
14
|
+
let text;
|
|
15
|
+
try {
|
|
16
|
+
const stat = statSync(file);
|
|
17
|
+
if (stat.isFile() && (stat.mode & 0o077) !== 0)
|
|
18
|
+
warn(`warning: other users can read ${file}; run chmod 600 on it`);
|
|
19
|
+
text = readFileSync(file, "utf8");
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
throw new Error(`BRAINCHAIN_WORKER_KEY_FILE names ${file}, which could not be read (${error.code ?? "unknown error"})`);
|
|
23
|
+
}
|
|
24
|
+
// The file holds the key alone, or the line new-key prints.
|
|
25
|
+
return /^\s*(?:BRAINCHAIN_WORKER_KEY=)?(\S+)\s*$/m.exec(text)?.[1] ?? "";
|
|
26
|
+
}
|
|
27
|
+
/** The text of the worker's key, from wherever the environment says it is; undefined when it names none. */
|
|
28
|
+
export function keyText(env = process.env, warn = console.warn) {
|
|
29
|
+
const handoff = env[HANDOFF];
|
|
30
|
+
if (handoff !== undefined) {
|
|
31
|
+
delete env[HANDOFF];
|
|
32
|
+
if (!HANDOFF_NAME.test(basename(handoff)))
|
|
33
|
+
throw new Error(`${HANDOFF} is for the worker's own use; unset it`);
|
|
34
|
+
const text = readFileSync(handoff, "utf8");
|
|
35
|
+
rmSync(handoff, { force: true });
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
38
|
+
const inline = env.BRAINCHAIN_WORKER_KEY?.trim();
|
|
39
|
+
const file = env.BRAINCHAIN_WORKER_KEY_FILE?.trim();
|
|
40
|
+
if (inline && file)
|
|
41
|
+
throw new Error("Set BRAINCHAIN_WORKER_KEY or BRAINCHAIN_WORKER_KEY_FILE, not both");
|
|
42
|
+
return file ? fromFile(file, warn) : inline || undefined;
|
|
43
|
+
}
|
|
44
|
+
/** Writes a new key to a file only its owner can read; an existing file is left alone, since it may hold a wallet's only key. */
|
|
45
|
+
export function writeKeyFile(path, key) {
|
|
46
|
+
const file = expandHome(path);
|
|
47
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
48
|
+
try {
|
|
49
|
+
writeFileSync(file, `${key}\n`, { flag: "wx", mode: 0o600 });
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code === "EEXIST")
|
|
53
|
+
throw new Error(`${file} already exists, and new-key never replaces a key`);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Memory rather than disk where the system offers it. The file lives for the milliseconds a restart takes. */
|
|
58
|
+
function handoffDir() {
|
|
59
|
+
try {
|
|
60
|
+
accessSync("/dev/shm", constants.W_OK);
|
|
61
|
+
return "/dev/shm";
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
mkdirSync(stateDir(), { recursive: true, mode: 0o700 });
|
|
65
|
+
return stateDir();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Restarts this process in place without BRAINCHAIN_WORKER_KEY in its
|
|
70
|
+
* environment. Deleting the variable would not do: `ps eww` and /proc read the
|
|
71
|
+
* block the process started with, and a command run by `run` can reach both.
|
|
72
|
+
* The pid, the output and the supervisor's view all stay as they were. Returns
|
|
73
|
+
* when there is nothing to clear, or when it could not be cleared.
|
|
74
|
+
*/
|
|
75
|
+
export function clearKeyFromEnvironment(key, warn, env = process.env, execve = process.execve) {
|
|
76
|
+
if (env.BRAINCHAIN_WORKER_KEY === undefined)
|
|
77
|
+
return;
|
|
78
|
+
if (typeof execve !== "function")
|
|
79
|
+
return warn(`warning: Node.js ${process.version} cannot clear the key from this process's environment; Node.js 22.15 or later can`);
|
|
80
|
+
const { BRAINCHAIN_WORKER_KEY: _, ...rest } = env;
|
|
81
|
+
let file;
|
|
82
|
+
try {
|
|
83
|
+
file = join(handoffDir(), `brainchain-worker-key-${randomBytes(16).toString("hex")}`);
|
|
84
|
+
writeFileSync(file, key, { flag: "wx", mode: 0o600 });
|
|
85
|
+
execve(process.execPath, [process.execPath, ...process.execArgv, process.argv[1], ...process.argv.slice(2)], { ...rest, [HANDOFF]: file });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (file !== undefined)
|
|
89
|
+
rmSync(file, { force: true });
|
|
90
|
+
warn(`warning: could not clear the key from this process's environment (${error.message})`);
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/network.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineChain, fallback, http, zeroAddress } from "viem";
|
|
2
|
-
/** BrainChain testnet, release 3.
|
|
2
|
+
/** BrainChain testnet, release 3.5.0. */
|
|
3
3
|
export const TESTNET = {
|
|
4
4
|
id: "testnet",
|
|
5
5
|
name: "BrainChain testnet",
|
|
@@ -10,7 +10,7 @@ export const TESTNET = {
|
|
|
10
10
|
explorer: "https://usebrainchain.com/testnet",
|
|
11
11
|
stakeSymbol: "tBRAIN",
|
|
12
12
|
bountySymbol: "tUSDG",
|
|
13
|
-
gas: "Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com
|
|
13
|
+
gas: "Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com, then move it here with `npx brainchain-worker bridge --eth 0.001`",
|
|
14
14
|
proofs: {
|
|
15
15
|
chainId: 46630,
|
|
16
16
|
name: "Robinhood Chain testnet",
|
|
@@ -58,7 +58,7 @@ export const MAINNET = {
|
|
|
58
58
|
explorer: "https://usebrainchain.com",
|
|
59
59
|
stakeSymbol: "BRAIN",
|
|
60
60
|
bountySymbol: "USDG",
|
|
61
|
-
gas: "
|
|
61
|
+
gas: "Send this address ETH on Robinhood Chain, then move it here with `npx brainchain-worker bridge --eth 0.001`",
|
|
62
62
|
buyStake: "Buy BRAIN on Robinhood Chain at ripples.run (https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and move it to BrainChain with " +
|
|
63
63
|
`brainchain-worker bridge --stake <amount> (docs: https://docs.usebrainchain.com/bridging); the stake token on BrainChain is ${BRAIN}.`,
|
|
64
64
|
proofs: {
|
package/dist/propose.js
CHANGED
|
@@ -303,7 +303,7 @@ export async function propose(network, chain, call, uri, options) {
|
|
|
303
303
|
throw new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, bond)} bond.${buy}`);
|
|
304
304
|
}
|
|
305
305
|
if (gas === 0n)
|
|
306
|
-
throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}
|
|
306
|
+
throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}.`);
|
|
307
307
|
log(`Bonding ${stake(network, bond)} and proposing...`);
|
|
308
308
|
const { id, tx } = await chain.propose(call, uri, bond);
|
|
309
309
|
log(`Proposal ${id} is waiting for Jev (${tx}). Follow it with \`npx brainchain-worker proposal ${id}\`.`);
|
package/dist/standing.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatUnits } from "viem";
|
|
1
|
+
import { formatEther, formatUnits } from "viem";
|
|
2
2
|
import { ApplicationState } from "./chain.js";
|
|
3
3
|
/** A token amount with thousands separators, such as 1,250,000; a fraction is kept as it is. */
|
|
4
4
|
function grouped(amount, decimals) {
|
|
@@ -26,6 +26,39 @@ export async function snapshot(chain) {
|
|
|
26
26
|
toStake: w.staked >= min ? 0n : min - w.staked,
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
+
/** The highest gas limit seen for each transaction join can send, across testnet and mainnet. */
|
|
30
|
+
const GAS = { faucet: 76000n, approve: 50000n, stake: 120000n, setPrice: 31000n, apply: 240000n };
|
|
31
|
+
/** The highest gas limit seen for delivering a result. */
|
|
32
|
+
const SUBMIT_GAS = 104000n;
|
|
33
|
+
/**
|
|
34
|
+
* The ETH a wallet must hold for transactions with these gas limits to go
|
|
35
|
+
* through in order. Each pays the going price, but the RPC refuses one the
|
|
36
|
+
* wallet could not cover at 2.4 times it, the fee cap it fills in.
|
|
37
|
+
*/
|
|
38
|
+
export function ethNeeded(limits, gasPrice) {
|
|
39
|
+
let spent = 0n;
|
|
40
|
+
let need = 0n;
|
|
41
|
+
for (const limit of limits) {
|
|
42
|
+
const covered = spent + (limit * gasPrice * 24n) / 10n;
|
|
43
|
+
if (covered > need)
|
|
44
|
+
need = covered;
|
|
45
|
+
spent += limit * gasPrice;
|
|
46
|
+
}
|
|
47
|
+
return need;
|
|
48
|
+
}
|
|
49
|
+
/** The gas limits of what join would send for this worker, in order. */
|
|
50
|
+
export function joinLimits(s, claims, reprices) {
|
|
51
|
+
return [
|
|
52
|
+
...(claims ? [GAS.faucet] : []),
|
|
53
|
+
...(s.toStake > 0n ? [GAS.approve, GAS.stake] : reprices ? [GAS.setPrice] : []),
|
|
54
|
+
...(s.applying ? [GAS.approve, GAS.apply] : []),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
/** An amount of ETH rounded up to two significant figures. */
|
|
58
|
+
export function aboutEth(wei) {
|
|
59
|
+
const unit = 10n ** BigInt(Math.max(wei.toString().length - 2, 0));
|
|
60
|
+
return formatEther(((wei + unit - 1n) / unit) * unit);
|
|
61
|
+
}
|
|
29
62
|
/** Why the worker can or cannot be hired right now, in one line for `run` or for `status`, which shows the rest itself. */
|
|
30
63
|
export function hireable({ allowed, w, min, applications, change, pending }, network, from) {
|
|
31
64
|
if (allowed) {
|
|
@@ -34,8 +67,11 @@ export function hireable({ allowed, w, min, applications, change, pending }, net
|
|
|
34
67
|
return `no: your stake is below the minimum of ${stake(network, min)}. Run join to stake again${from === "run" ? "; status shows what it needs" : ""}.`;
|
|
35
68
|
}
|
|
36
69
|
// An allowed application waiting out its timelock outranks anything filed after it.
|
|
37
|
-
if (change && pending)
|
|
38
|
-
|
|
70
|
+
if (change && pending) {
|
|
71
|
+
const due = BigInt(Math.floor(Date.now() / 1000)) >= change.eta;
|
|
72
|
+
const allowed = `no: Jev allowed this worker; ${due ? `the change that allows it was due at ${when(change.eta)} and has not run yet` : `it can be hired after ${when(change.eta)}`}`;
|
|
73
|
+
return w.staked >= min ? allowed : `${allowed}. Its stake is below the minimum of ${stake(network, min)}, so run join to stake again first.`;
|
|
74
|
+
}
|
|
39
75
|
const last = applications.at(-1);
|
|
40
76
|
if (last === undefined)
|
|
41
77
|
return "no: run `npx brainchain-worker join` to apply";
|
|
@@ -69,6 +105,14 @@ export function wallet(network, s, balance, bond, faucet, now) {
|
|
|
69
105
|
const buy = network.buyStake ? `. ${network.buyStake}` : "";
|
|
70
106
|
return `${has}; join needs ${stake(network, need)}${reopens}${unstaking}${buy}`;
|
|
71
107
|
}
|
|
108
|
+
/** What a wallet short of gas holds and needs, for join's refusal and for `status`. */
|
|
109
|
+
export const shortOfGas = (network, gas, need, purpose) => `${formatEther(gas)} ETH on ${network.name}; ${purpose} needs about ${aboutEth(need)} ETH for gas. ${network.gas}.`;
|
|
110
|
+
/** The wallet's ETH, and what more join or the next delivery needs, since a send the wallet cannot cover fails partway through. */
|
|
111
|
+
export function gasLine(network, s, gas, gasPrice, claims) {
|
|
112
|
+
const limits = joinLimits(s, claims, false);
|
|
113
|
+
const need = ethNeeded(limits.length > 0 ? limits : [SUBMIT_GAS], gasPrice);
|
|
114
|
+
return gas >= need ? `${formatEther(gas)} ETH` : shortOfGas(network, gas, need, limits.length > 0 ? "join" : "delivering a result");
|
|
115
|
+
}
|
|
72
116
|
/** What join says when the wallet holds too little of a stake token that has no faucet. */
|
|
73
117
|
export function shortOfStake(network, w, balance, need, now) {
|
|
74
118
|
const withdraw = now >= w.unlocksAt ? "now" : `after ${when(w.unlocksAt)}`;
|