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/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 { formatEther, parseUnits } from "viem";
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
- const USAGE = `brainchain-worker ${version}
32
-
33
- Earn on BrainChain by doing tasks: stake, apply, and let Jev hire your agent.
34
-
35
- Usage:
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. --dry checks the balances and sends nothing.
58
- brainchain-worker propose --target board|executor|registry --call <function> --args '<JSON array>'
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
- brainchain-worker credit --agent-id <id> [--premium <bps>]
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 = FLAGS[command];
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 = process.env.BRAINCHAIN_WORKER_KEY?.trim();
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("BRAINCHAIN_WORKER_KEY must be 64 hex characters, with or without 0x");
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("BRAINCHAIN_WORKER_KEY is not a valid private key. `npx brainchain-worker new-key` makes one.");
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}; 0.001 ETH is plenty.`);
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 gas = await chain.ethBalance();
205
- // The faucet covers the stake where there is one, so gas is all a new worker can lack there.
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(gas === 0n ? `${noGas(chain, network).message}\n${short}` : short);
264
+ throw new Error(lacksGas ? `${lacksGas}\n${short}` : short);
229
265
  }
230
- if (gas === 0n)
231
- throw noGas(chain, network);
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 ${formatEther(gas)} ETH`);
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" || argv.includes("--help") || argv.includes("-h")) {
390
- console.log(USAGE);
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
- console.log(`BRAINCHAIN_WORKER_KEY=${fresh}\naddress ${privateKeyToAccount(fresh).address}\nKeep the key secret; it controls the stake and the earnings.`);
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 || Boolean(process.env.BRAINCHAIN_WORKER_KEY?.trim());
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: createWatchChain(network, account),
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
- console.log(`watching ${network.name} as ${account.address}${veto ? ", vetoing through the guardian gate" : ""}${keyed ? ", reporting rounds to the service" : ""}`);
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 = process.env.BRAINCHAIN_WORKER_KEY?.trim() ? signer().account : privateKeyToAccount(generatePrivateKey());
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
- if (!(await chain.stake()).registered)
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 = 8;
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.4.0. */
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 and bridge some at https://usebrainchain.com/testnet",
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: "Bridge some from Robinhood Chain at https://usebrainchain.com/network",
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}; 0.001 ETH is plenty.`);
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
- return `no: Jev allowed this worker; it can be hired after ${when(change.eta)}`;
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)}`;