brainchain-worker 0.13.1 → 0.15.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,23 +1,29 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { verifyDecision } from "brainchain-verify";
3
3
  import { createPublicClient, http } from "viem";
4
- import { parseUnits } from "viem";
4
+ import { formatEther, parseUnits } from "viem";
5
5
  import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
6
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
- import { createProofMinter, createProofsChain, MAX_MINT, mint } from "./mint.js";
9
+ import { createProofsChain, createSettlementWatch, 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 { ethNeeded, gasLine, hireable, joinLimits, shortOfGas, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
12
+ import { deliveries, deliveriesLeft, ethNeeded, flightLine, gasHelp, gasLine, hireable, joinLimits, shortOfGas, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
13
13
  import { openStore, readWatched, writeWatched } from "./state.js";
14
14
  import { clearKeyFromEnvironment, keyConfigured, keyText, writeKeyFile } from "./key.js";
15
15
  import { createCreditChain, credit } from "./credit.js";
16
16
  import { appeal, createDisputeChain, dispute } from "./dispute.js";
17
17
  import { createReporter, createWatch, createWatchChain, requireWatcher } from "./watch.js";
18
- import { createWork, firstProgram, npxUnderSupervisor, programFound, shellCommand, stopLeftover, stopper, watchStarter } from "./work.js";
18
+ import { createWork, firstProgram, npxUnderSupervisor, programFound, shellCommand, stopLeftover, stopper, unseen, watchStarter } from "./work.js";
19
19
  /** How many of the latest decisions a first watch --once checks. */
20
20
  const FIRST_RUN_DECISIONS = 10n;
21
+ /** How long an idle run goes without a line before it says it is still waiting. */
22
+ const HEARTBEAT_MS = 3_600_000;
23
+ /** Below this many deliveries' worth of gas, run warns after each delivery. */
24
+ const LOW_DELIVERIES = 3n;
25
+ /** How often a run that cannot be hired yet reads again whether it can. */
26
+ const HIREABLE_CHECK_MS = 300_000;
21
27
  /** A published record's bytes as the service serves them, capped at a megabyte. */
22
28
  async function fetchRecord(uri) {
23
29
  const response = await fetch(uri, { signal: AbortSignal.timeout(20_000) });
@@ -32,18 +38,21 @@ const { version } = createRequire(import.meta.url)("../package.json");
32
38
  /** Each command's lines in the usage text, in the order the text lists them. */
33
39
  const COMMAND_USAGE = {
34
40
  "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.`,
41
+ to a new file only you can read and prints the file's path in
42
+ place of the key.`,
36
43
  join: ` brainchain-worker join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]
37
44
  Stake the minimum and apply to take tasks. The name is at
38
45
  most 60 characters, the description at most 500. --agent-id
39
46
  (mainnet) names the worker's ERC-8004 agent on Robinhood Chain,
40
47
  so its BrainChain record is written to that identity.`,
41
- run: ` brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--no-mint]
48
+ run: ` brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--mint]
42
49
  Do every task Jev hires the worker for: the command reads the
43
50
  task on stdin and prints the deliverable on stdout, within
44
- --timeout minutes (20 by default, at most 1440). The worker
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.`,
51
+ --timeout minutes (20 by default, at most 1440). It logs how
52
+ each task settles. --mint also mints the Proof of each task it is
53
+ paid for.`,
54
+ status: ` brainchain-worker status Stake, wallet, application, record, earnings and gas, with where each
55
+ task in flight stands.`,
47
56
  price: ` brainchain-worker price <USDG> Change your asking price per task.`,
48
57
  mint: ` brainchain-worker mint [--count <n>] [--dry]
49
58
  Mint a Proof for each paid task waiting for one, up to 10 or
@@ -80,8 +89,9 @@ const COMMAND_USAGE = {
80
89
  window. Jev rules on it; a confident ruling decides.`,
81
90
  appeal: ` brainchain-worker appeal <task> Appeal the ruling on a task, when it went against the worker, within the
82
91
  appeal window, with the bond the task's terms fix (a quarter of the
83
- bounty). Jev rules again; the bond comes back unless Jev confidently
84
- rules the same way. Testnet only for now.`,
92
+ bounty), claimed from the test USDG faucet when the wallet holds less.
93
+ Jev rules again; the bond comes back unless Jev confidently rules the
94
+ same way. Testnet only for now.`,
85
95
  leave: ` brainchain-worker leave Start unstaking (3-day cooldown).`,
86
96
  withdraw: ` brainchain-worker withdraw Withdraw stake once the cooldown has passed.`,
87
97
  };
@@ -115,7 +125,8 @@ export class UsageError extends Error {
115
125
  const FLAGS = {
116
126
  "new-key": { file: false },
117
127
  join: { name: false, about: false, price: false, "agent-id": false },
118
- run: { exec: false, format: false, timeout: false, once: true, "no-mint": true },
128
+ // --no-mint is still accepted from before minting became opt-in; it changes nothing.
129
+ run: { exec: false, format: false, timeout: false, once: true, mint: true, "no-mint": true },
119
130
  status: {},
120
131
  price: {},
121
132
  leave: {},
@@ -189,7 +200,7 @@ function signer() {
189
200
  const raw = keyText()?.trim();
190
201
  if (!raw)
191
202
  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.");
192
- const hex = raw.startsWith("0x") ? raw.slice(2) : raw;
203
+ const hex = raw.replace(/^0x/i, "");
193
204
  if (!/^[0-9a-fA-F]{64}$/.test(hex))
194
205
  throw new Error("The worker's key must be 64 hex characters, with or without 0x");
195
206
  const key = `0x${hex.toLowerCase()}`;
@@ -210,12 +221,12 @@ function price(value) {
210
221
  }
211
222
  const RUN = '`npx brainchain-worker run --exec "<command>"`';
212
223
  const KEEP_RUNNING = "https://github.com/usebrainchain/brainchain-worker#keep-it-running";
213
- function noGas(chain, network) {
214
- return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas(chain.account)}.`);
224
+ async function noGas(chain, network) {
225
+ return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${gasHelp(network, chain.account, 0n, await chain.sent())}.`);
215
226
  }
216
227
  async function needGas(chain, network) {
217
228
  if ((await chain.ethBalance()) === 0n)
218
- throw noGas(chain, network);
229
+ throw await noGas(chain, network);
219
230
  }
220
231
  export async function join(chain, network, values) {
221
232
  const name = values.get("name")?.trim() ?? "";
@@ -224,11 +235,19 @@ export async function join(chain, network, values) {
224
235
  throw new UsageError("--name is required, up to 60 characters");
225
236
  if (name.length > 60)
226
237
  throw new UsageError("--name is at most 60 characters");
238
+ // A name that hides characters or turns text around can read as another, Jev's included.
239
+ const nameHides = unseen(name);
240
+ if (nameHides)
241
+ throw new UsageError(`--name must be one line with no invisible or direction-changing characters, and it holds ${nameHides}`);
227
242
  if (!about) {
228
243
  throw new UsageError("--about is required: say what work your agent does in up to 500 characters, since Jev reads it to decide and refuses an empty application");
229
244
  }
230
245
  if (about.length > 500)
231
246
  throw new UsageError("--about is at most 500 characters");
247
+ // A description may run over several lines.
248
+ const aboutHides = unseen(about.replace(/[\t\n]/g, " "));
249
+ if (aboutHides)
250
+ throw new UsageError(`--about must have no invisible or direction-changing characters, and it holds ${aboutHides}`);
232
251
  const agentId = values.get("agent-id");
233
252
  if (agentId !== undefined && !/^(0|[1-9]\d{0,14})$/.test(agentId))
234
253
  throw new UsageError("--agent-id is the worker's ERC-8004 agent id, a whole number");
@@ -252,12 +271,19 @@ export async function join(chain, network, values) {
252
271
  }
253
272
  // A worker that has staked keeps its price unless --price changes it.
254
273
  const asking = requested ?? (w.registered ? w.price : price(DEFAULT_PRICE));
255
- const [gas, gasPrice, bond, balance, timelock] = await Promise.all([chain.ethBalance(), chain.gasPrice(), chain.bond(), chain.stakeBalance(), chain.timelock()]);
274
+ const [gas, gasPrice, bond, balance, timelock, sent] = await Promise.all([
275
+ chain.ethBalance(),
276
+ chain.gasPrice(),
277
+ chain.bond(),
278
+ chain.stakeBalance(),
279
+ chain.timelock(),
280
+ chain.sent(),
281
+ ]);
256
282
  const need = toStake + (applying ? bond : 0n);
257
283
  const claims = balance < need && !network.buyStake;
258
284
  // Checked before anything is sent: a wallet that runs out partway leaves the stake placed and the application unsent.
259
285
  const gasNeed = ethNeeded(joinLimits(standing, claims, toStake === 0n && w.price !== asking), gasPrice);
260
- const lacksGas = gas < gasNeed ? `${chain.account} holds ${shortOfGas(network, chain.account, gas, gasNeed, "join")}` : undefined;
286
+ const lacksGas = gas < gasNeed ? `${chain.account} holds ${shortOfGas(network, chain.account, gas, sent, gasNeed, "join")}` : undefined;
261
287
  if (balance < need && network.buyStake) {
262
288
  const short = shortOfStake(network, w, balance, need, await chain.now());
263
289
  // Both needs at once, so a new worker does not bridge ETH only to learn it needs the stake token too.
@@ -311,9 +337,10 @@ export async function join(chain, network, values) {
311
337
  "the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
312
338
  }
313
339
  async function status(chain, network) {
314
- const [gas, gasPrice, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
340
+ const [gas, gasPrice, sent, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
315
341
  chain.ethBalance(),
316
342
  chain.gasPrice(),
343
+ chain.sent(),
317
344
  snapshot(chain),
318
345
  chain.held(),
319
346
  chain.record(),
@@ -331,9 +358,9 @@ async function status(chain, network) {
331
358
  Stake ${stake(network, w.staked)} (minimum ${stake(network, min)})${unstaking}
332
359
  Wallet ${wallet(network, s, stakeBalance, bond, faucet, now)}
333
360
  Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
334
- Record ${record.accepted} accepted, ${record.refunded} refunded, ${record.inFlight} in flight${held > 0 ? " (stake held until they settle)" : ""}
361
+ Record ${record.paid} paid, ${record.refunded} refunded, ${record.inFlight.length} in flight${held > 0 ? " (stake held until they settle)" : ""}${record.inFlight.map((f) => `\n ${flightLine(f)}`).join("")}
335
362
  Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, bountyBalance)}
336
- Gas ${gasLine(network, chain.account, s, gas, gasPrice, !network.buyStake && stakeBalance < s.toStake + (s.applying ? bond : 0n))}`);
363
+ Gas ${gasLine(network, chain.account, s, gas, gasPrice, !network.buyStake && stakeBalance < s.toStake + (s.applying ? bond : 0n), sent)}`);
337
364
  }
338
365
  async function run(network, { key, account }, values, once, mints) {
339
366
  const exec = values.get("exec");
@@ -346,7 +373,7 @@ async function run(network, { key, account }, values, once, mints) {
346
373
  if (!/^\d{1,4}(\.\d+)?$/.test(minutes) || Number(minutes) <= 0 || Number(minutes) > 1440) {
347
374
  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");
348
375
  }
349
- const log = (message) => console.log(`${new Date().toISOString().slice(0, 19)}Z ${message}`);
376
+ const { log, quietFor } = timedLog((line) => console.log(line));
350
377
  clearKeyFromEnvironment(key, log);
351
378
  const program = firstProgram(exec);
352
379
  if (program && !programFound(program))
@@ -355,7 +382,7 @@ async function run(network, { key, account }, values, once, mints) {
355
382
  // The lock rules out a live run, so a command still in the state was left by one that died.
356
383
  await stopLeftover(store, log);
357
384
  const chain = createChain(network, account, store);
358
- const proofs = createProofMinter({ network, chain: createProofsChain(network.proofs, account), settled: chain.settled, store, log, enabled: mints });
385
+ const settlements = createSettlementWatch({ network, chain: createProofsChain(network.proofs, account), settlement: chain.settlement, store, log, mints });
359
386
  const command = shellCommand(exec);
360
387
  // An idle stop keeps the exit code of a task that failed earlier in a --once pass.
361
388
  const stop = stopper({ busy: () => work.busy(), kill: command.kill, log, exit: (code) => process.exit(code || process.exitCode) });
@@ -370,8 +397,23 @@ async function run(network, { key, account }, values, once, mints) {
370
397
  stopping: stop.stopping,
371
398
  // A cron job sees a failed task as a failed run.
372
399
  failed: once ? () => void (process.exitCode = 1) : undefined,
373
- delivered: proofs.track,
400
+ delivered(task) {
401
+ settlements.track(task);
402
+ void lowGas();
403
+ },
404
+ gasHelp: async () => gasHelp(network, account.address, 0n, await chain.sent()),
374
405
  });
406
+ // The drip pays for only a few deliveries, so the log says when the wallet is down to its last ones.
407
+ const lowGas = async () => {
408
+ const [gas, price, sent] = await Promise.all([chain.ethBalance(), chain.gasPrice(), chain.sent()]).catch(() => []);
409
+ if (gas === undefined || price === undefined || sent === undefined)
410
+ return;
411
+ const left = deliveriesLeft(gas, price);
412
+ if (left >= LOW_DELIVERIES)
413
+ return;
414
+ const enough = left === 0n ? "not enough for another delivery" : `enough for about ${left} more ${left === 1n ? "delivery" : "deliveries"}`;
415
+ log(`warning: the wallet holds ${formatEther(gas)} ETH for gas, ${enough}. ${gasHelp(network, account.address, gas, sent)}.`);
416
+ };
375
417
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
376
418
  process.on(signal, stop.signal);
377
419
  if (process.env.npm_execpath !== undefined) {
@@ -396,12 +438,14 @@ async function run(network, { key, account }, values, once, mints) {
396
438
  log(`could not read the wallet's ETH balance: ${reason(error)}`);
397
439
  }
398
440
  if (gas === 0n)
399
- throw noGas(chain, network);
400
- const [line, sick] = await Promise.all([
441
+ throw await noGas(chain, network);
442
+ const held = gas;
443
+ const [line, sick, left] = await Promise.all([
401
444
  snapshot(chain).then((s) => hireable(s, network, "run"), (error) => `could not check (${reason(error)})`),
402
445
  unhealthy(network.service),
446
+ held === undefined ? undefined : chain.gasPrice().then((price) => deliveriesLeft(held, price), () => undefined),
403
447
  ]);
404
- log(`worker ${chain.account} on ${network.name}; hireable: ${line}`);
448
+ log(`worker ${chain.account} on ${network.name}; hireable: ${line}${left === undefined ? "" : `; gas for ${deliveries(left)}`}`);
405
449
  if (sick)
406
450
  log(`warning: ${sick}`);
407
451
  if (once) {
@@ -412,24 +456,64 @@ async function run(network, { key, account }, values, once, mints) {
412
456
  });
413
457
  if (result?.hired === 0 && result.delivered === 0)
414
458
  log("no task is hired to this worker right now");
415
- await proofs.round().catch((error) => log(`proofs round failed: ${reason(error)}`));
459
+ await settlements.round().catch((error) => log(`settlement round failed: ${reason(error)}`));
416
460
  return;
417
461
  }
418
462
  log("waiting for tasks (Ctrl-C to stop)");
419
463
  // Minting waits on receipts from another chain, so it runs beside the work, one round at a time.
420
464
  let minting;
465
+ const hireChange = hireWatch(line, () => snapshot(chain).then((s) => hireable(s, network, "run"), () => undefined));
421
466
  while (!stop.stopping()) {
422
467
  await work.round().catch((error) => log(`round failed: ${reason(error)}`));
423
- minting ??= proofs
468
+ minting ??= settlements
424
469
  .round()
425
- .catch((error) => log(`proofs round failed: ${reason(error)}`))
470
+ .catch((error) => log(`settlement round failed: ${reason(error)}`))
426
471
  .finally(() => (minting = undefined));
472
+ const changed = stop.stopping() ? undefined : await hireChange();
473
+ if (changed !== undefined)
474
+ log(`hireable: ${changed}`);
475
+ if (quietFor() >= HEARTBEAT_MS && !work.busy() && !stop.stopping()) {
476
+ log(`still waiting for tasks; hireable: ${await snapshot(chain).then((s) => hireable(s, network, "run"), (error) => `could not check (${reason(error)})`)}`);
477
+ }
427
478
  for (let i = 0; i < 15 && !stop.stopping(); i++)
428
479
  await new Promise((resolve) => setTimeout(resolve, 1_000));
429
480
  }
430
481
  await minting;
431
482
  log("stopped");
432
483
  }
484
+ /**
485
+ * Reads again, every few minutes until the worker can be hired, whether it can,
486
+ * and returns the new answer when it changed. A run started while Jev decides
487
+ * on the application then says when Jev allows the worker, and when its
488
+ * timelock has passed.
489
+ */
490
+ export function hireWatch(first, check, everyMs = HIREABLE_CHECK_MS, now = Date.now) {
491
+ let last = first;
492
+ let checkedAt = now();
493
+ return async () => {
494
+ if (last === "yes" || now() - checkedAt < everyMs)
495
+ return undefined;
496
+ checkedAt = now();
497
+ const line = await check();
498
+ if (line === undefined || line === last)
499
+ return undefined;
500
+ last = line;
501
+ return line;
502
+ };
503
+ }
504
+ /** Run's log, with each line's time, and how long it has been quiet, so an idle run can say it is still there. */
505
+ export function timedLog(write, now = Date.now) {
506
+ let last = now();
507
+ return {
508
+ log(message) {
509
+ last = now();
510
+ write(`${new Date(last).toISOString().slice(0, 19)}Z ${message}`);
511
+ },
512
+ quietFor: () => now() - last,
513
+ };
514
+ }
515
+ /** Where watch --once keeps its place: a key's place counts toward its watchtower mark, so each key has its own, apart from runs without one. */
516
+ export const watchPlace = (log, key) => (key === undefined ? log : `${log}.${key}`);
433
517
  /** What watch says at start. Without a key it names no address: the one its reads go through is a throwaway. */
434
518
  export function watching(network, address, veto) {
435
519
  if (address === undefined)
@@ -508,6 +592,7 @@ export async function main(argv) {
508
592
  const account = keyed ? signer().account : privateKeyToAccount(generatePrivateKey());
509
593
  const client = createPublicClient({ chain: chainOf(network), transport: brainchainTransport(network) });
510
594
  const { taskBoard, executor, log } = network.contracts;
595
+ const place = watchPlace(log, keyed ? account.address : undefined);
511
596
  const verify = async (id) => {
512
597
  const report = await verifyDecision({
513
598
  client,
@@ -529,7 +614,7 @@ export async function main(argv) {
529
614
  veto,
530
615
  log: console.log,
531
616
  // Each run of --once is a new process, so it keeps its place on disk; a first run checks the latest few.
532
- ...(once ? { seenDecision: readWatched(log), recent: FIRST_RUN_DECISIONS, saw: (count) => writeWatched(log, count) } : {}),
617
+ ...(once ? { seenDecision: readWatched(place), recent: FIRST_RUN_DECISIONS, saw: (count) => writeWatched(place, count) } : {}),
533
618
  });
534
619
  if (veto && !network.contracts.gate)
535
620
  throw new UsageError(`${network.name} has no guardian gate yet, so there is nothing to veto through`);
@@ -575,7 +660,7 @@ export async function main(argv) {
575
660
  return propose(network, createProposals(network, worker.account), call, uri, { dry: switches.has("dry"), log: console.log });
576
661
  }
577
662
  if (command === "run")
578
- return run(network, worker, values, switches.has("once"), !switches.has("no-mint"));
663
+ return run(network, worker, values, switches.has("once"), switches.has("mint"));
579
664
  const chain = createChain(network, worker.account);
580
665
  switch (command) {
581
666
  case "join":
package/dist/dispute.js CHANGED
@@ -1,15 +1,33 @@
1
- import { createPublicClient, createWalletClient, formatUnits, parseAbi } from "viem";
2
- import { brainchainTransport, erc20Abi, RECEIPT_MS, Status } from "./chain.js";
1
+ import { createPublicClient, createWalletClient, encodeFunctionData, formatUnits, parseAbi } from "viem";
2
+ import { erc20Errors, transferErrors } from "./abi.js";
3
+ import { appealAbi, brainchainTransport, erc20Abi, explainRevert, faucetReopens, reason, RECEIPT_MS, Status } from "./chain.js";
3
4
  import { chainOf } from "./network.js";
5
+ import { ethNeeded, GAS, shortOfGas } from "./standing.js";
4
6
  /** TaskBoard statuses from set 3.5.0 on: a ruling that waits out its appeal window, and one under appeal. */
5
7
  export const { Ruled, Appealed } = Status;
6
- const boardAbi = parseAbi([
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))",
8
- "function appealOf(uint256 id) view returns ((uint64 window, uint16 bondBps, uint64 ruledAt, bool pays, bool confident, address appellant, uint128 bond, uint64 appealedAt))",
9
- "function appealBond(uint256 id) view returns (uint128)",
10
- "function dispute(uint256 id)",
11
- "function appeal(uint256 id)",
12
- ]);
8
+ const boardAbi = [
9
+ ...parseAbi([
10
+ "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))",
11
+ "function appealBond(uint256 id) view returns (uint128)",
12
+ "function dispute(uint256 id)",
13
+ "function appeal(uint256 id)",
14
+ "error BadStatus()",
15
+ "error TooLate()",
16
+ "error NotDisputant()",
17
+ "error NotAppellant()",
18
+ "error TransferMismatch()",
19
+ ]),
20
+ ...appealAbi,
21
+ ...erc20Errors,
22
+ ...transferErrors,
23
+ ];
24
+ /** The highest gas limit seen for the bond's approval. */
25
+ const APPROVE_GAS = 50000n;
26
+ /**
27
+ * The appeal was estimated at 112,355 gas, yet the RPC refused it to a wallet
28
+ * that held 2.8 times that at the going price, so it is counted high.
29
+ */
30
+ const APPEAL_GAS = 140000n;
13
31
  export function createDisputeChain(network, account) {
14
32
  const chain = chainOf(network);
15
33
  const transport = brainchainTransport(network);
@@ -18,7 +36,9 @@ export function createDisputeChain(network, account) {
18
36
  const board = { address: network.contracts.taskBoard, abi: boardAbi };
19
37
  const token = { address: network.contracts.bountyToken, abi: erc20Abi };
20
38
  async function send(functionName, id) {
21
- const hash = await wallet.writeContract({ ...board, functionName, args: [id] });
39
+ const hash = await wallet.writeContract({ ...board, functionName, args: [id] }).catch(async (error) => {
40
+ throw await explainRevert(error, () => client.call({ account: account.address, to: board.address, data: encodeFunctionData({ abi: boardAbi, functionName, args: [id] }) }));
41
+ });
22
42
  const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
23
43
  if (receipt.status !== "success")
24
44
  throw new Error(`Transaction ${hash} reverted`);
@@ -35,14 +55,34 @@ export function createDisputeChain(network, account) {
35
55
  appealOf: (id) => client.readContract({ ...board, functionName: "appealOf", args: [id] }),
36
56
  appealBond: (id) => client.readContract({ ...board, functionName: "appealBond", args: [id] }),
37
57
  bountyBalance: () => client.readContract({ ...token, functionName: "balanceOf", args: [account.address] }),
58
+ async bountyFaucet() {
59
+ const [amount, interval, last] = await Promise.all([
60
+ client.readContract({ ...token, functionName: "FAUCET_AMOUNT" }),
61
+ client.readContract({ ...token, functionName: "FAUCET_INTERVAL" }),
62
+ client.readContract({ ...token, functionName: "lastClaim", args: [account.address] }),
63
+ ]);
64
+ return { amount, opensAt: last === 0n ? 0n : last + interval };
65
+ },
66
+ async claimBounty() {
67
+ const hash = await wallet.writeContract({ ...token, functionName: "faucet" });
68
+ const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
69
+ if (receipt.status !== "success")
70
+ throw new Error(`The faucet claim ${hash} reverted`);
71
+ return hash;
72
+ },
73
+ ethBalance: () => client.getBalance({ address: account.address }),
74
+ sent: () => client.getTransactionCount({ address: account.address }),
75
+ gasPrice: () => client.getGasPrice(),
76
+ allowance: () => client.readContract({ ...token, functionName: "allowance", args: [account.address, board.address] }),
38
77
  async approveBounty(amount) {
39
78
  const current = await client.readContract({ ...token, functionName: "allowance", args: [account.address, board.address] });
40
79
  if (current >= amount)
41
- return;
80
+ return undefined;
42
81
  const hash = await wallet.writeContract({ ...token, functionName: "approve", args: [board.address, amount] });
43
82
  const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
44
83
  if (receipt.status !== "success")
45
84
  throw new Error(`Approval ${hash} reverted`);
85
+ return hash;
46
86
  },
47
87
  dispute: (id) => send("dispute", id),
48
88
  appeal: (id) => send("appeal", id),
@@ -86,12 +126,48 @@ export async function appeal(network, chain, id, log) {
86
126
  const closes = terms.ruledAt + terms.window;
87
127
  if (now > closes)
88
128
  throw new Error(`The appeal window for task ${id} closed at ${at(closes)}.`);
89
- const [bond, balance] = await Promise.all([chain.appealBond(id), chain.bountyBalance()]);
129
+ const [bond, balance, eth, gasPrice, allowance, sent] = await Promise.all([
130
+ chain.appealBond(id),
131
+ chain.bountyBalance(),
132
+ chain.ethBalance(),
133
+ chain.gasPrice(),
134
+ chain.allowance(),
135
+ chain.sent(),
136
+ ]);
90
137
  const amount = `${formatUnits(bond, 6)} ${network.bountySymbol}`;
91
- if (balance < bond)
92
- throw new Error(`The appeal bond is ${amount}, and the worker holds ${formatUnits(balance, 6)}. Bridge more with \`npx brainchain-worker bridge --bounty <amount>\`.`);
93
- await chain.approveBounty(bond);
94
- const tx = await chain.appeal(id);
138
+ const holds = `The appeal bond is ${amount}, and this wallet holds ${formatUnits(balance, 6)}.`;
139
+ // Test USDG comes from its faucet, as tBRAIN does for join; only mainnet's USDG is bridged in.
140
+ const claims = balance < bond && network.bountyFaucet === true;
141
+ if (balance < bond && !claims)
142
+ throw new Error(`${holds} Bridge more with \`npx brainchain-worker bridge --bounty <amount>\`.`);
143
+ const closed = (opensAt) => new Error(`${holds} The ${network.bountySymbol} faucet allows one claim a day, and this wallet can claim again at ${at(opensAt)}` +
144
+ (opensAt > closes ? `, after the appeal window closes at ${at(closes)}.` : `. Run appeal again then, before the appeal window closes at ${at(closes)}.`));
145
+ if (claims) {
146
+ const faucet = await chain.bountyFaucet();
147
+ if (now < faucet.opensAt)
148
+ throw closed(faucet.opensAt);
149
+ if (balance + faucet.amount < bond)
150
+ throw new Error(`${holds} The faucet's ${formatUnits(faucet.amount, 6)} ${network.bountySymbol} a day would not make up the rest.`);
151
+ }
152
+ // Checked for every transaction before any is sent: an approval alone spends gas and leaves the bond approved.
153
+ const need = ethNeeded([...(claims ? [GAS.faucet] : []), ...(allowance < bond ? [APPROVE_GAS] : []), APPEAL_GAS], gasPrice);
154
+ if (eth < need)
155
+ throw new Error(`${chain.account} holds ${shortOfGas(network, chain.account, eth, sent, need, "the appeal")} Nothing was sent.`);
156
+ if (claims) {
157
+ log(`Claiming ${network.bountySymbol} from the testnet faucet for the bond...`);
158
+ const claim = await chain.claimBounty().catch((error) => {
159
+ const opensAt = faucetReopens(error);
160
+ throw opensAt === undefined ? error : closed(opensAt);
161
+ });
162
+ log(`Claimed: ${claim}`);
163
+ }
164
+ const approval = await chain.approveBounty(bond);
165
+ const tx = await chain.appeal(id).catch((error) => {
166
+ if (approval === undefined)
167
+ throw error;
168
+ throw new Error(`The board was approved to take the ${amount} bond (${approval}), and then the appeal failed: ${reason(error)}. ` +
169
+ `The approval stands, so running appeal again before ${at(closes)} sends only the appeal.`);
170
+ });
95
171
  log(`Appealed the ruling on task ${id} with a ${amount} bond: ${tx}`);
96
172
  log(`Jev rules again within ${task.ruleWindow / 3600n} hours. The bond comes back unless Jev confidently rules the same way.`);
97
173
  }
package/dist/key.js CHANGED
@@ -13,7 +13,7 @@ export const keyConfigured = (env = process.env) => Boolean(env[HANDOFF] || env.
13
13
  function keyFileProblem(text) {
14
14
  if (text === "")
15
15
  return "is empty";
16
- if (/^(0x)?[0-9a-fA-F]{64}$/.test(text))
16
+ if (/^(0x)?[0-9a-f]{64}$/i.test(text))
17
17
  return undefined;
18
18
  if (/[\r\n]/.test(text))
19
19
  return "holds more than one line";
@@ -21,7 +21,7 @@ function keyFileProblem(text) {
21
21
  return "holds a line in the form NAME=value";
22
22
  if (/^["'`]|["'`]$/.test(text))
23
23
  return "holds the key in quotes";
24
- const hex = text.startsWith("0x") ? text.slice(2) : text;
24
+ const hex = text.replace(/^0x/i, "");
25
25
  if (/^[0-9a-fA-F]+$/.test(hex))
26
26
  return `holds ${hex.length} hex characters where a key has 64`;
27
27
  return "holds characters that are not hex digits";