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/README.md +27 -17
- package/dist/abi.js +70 -0
- package/dist/bridge.js +4 -2
- package/dist/chain.js +81 -13
- package/dist/cli.js +117 -32
- package/dist/dispute.js +92 -16
- package/dist/key.js +2 -2
- package/dist/mint.js +68 -31
- package/dist/network.js +11 -6
- package/dist/proc.js +34 -0
- package/dist/propose.js +3 -2
- package/dist/standing.js +63 -7
- package/dist/state.js +21 -4
- package/dist/work.js +22 -16
- package/package.json +2 -2
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 {
|
|
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
|
|
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] [--
|
|
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).
|
|
45
|
-
mints the Proof of each task it is
|
|
46
|
-
|
|
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)
|
|
84
|
-
rules the
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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([
|
|
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.
|
|
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 = (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 ??=
|
|
468
|
+
minting ??= settlements
|
|
424
469
|
.round()
|
|
425
|
-
.catch((error) => log(`
|
|
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(
|
|
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"),
|
|
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 {
|
|
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 =
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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([
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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-
|
|
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.
|
|
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";
|