brainchain-worker 0.18.2 → 0.18.4

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
@@ -8,7 +8,7 @@ import { brainchainTransport, createChain, faucetReopens, reason, requireChainId
8
8
  import { bond, bondClaim, bondStatus, createBondChain } from "./bond.js";
9
9
  import { assetOf, bridge, createBridgeChain } from "./bridge.js";
10
10
  import { createExitChain, exit as exitToParent } from "./exit.js";
11
- import { createSolanaFunding, fundFromSolana, PARENT_ETH_KEEP, solanaKeypair } from "./solana.js";
11
+ import { createSolanaFunding, fundFromSolana, solanaKeypair } from "./solana.js";
12
12
  import { claim, createIncome } from "./claim.js";
13
13
  import { createProofsChain, createSettlementWatch, MAX_MINT, mint } from "./mint.js";
14
14
  import { chainOf, resolveNetwork } from "./network.js";
@@ -18,7 +18,7 @@ import { openStore, readWatched, writeWatched } from "./state.js";
18
18
  import { clearKeyFromEnvironment, keyConfigured, keyText, writeKeyFile } from "./key.js";
19
19
  import { createCreditChain, credit } from "./credit.js";
20
20
  import { appeal, createDisputeChain, dispute } from "./dispute.js";
21
- import { createReporter, createWatch, createWatchChain, requireWatcher } from "./watch.js";
21
+ import { createReporter, createWatch, createWatchChain, outcome, requireWatcher } from "./watch.js";
22
22
  import { createWork, firstProgram, npxUnderSupervisor, programFound, shellCommand, stopLeftover, stopper, unseen, watchStarter } from "./work.js";
23
23
  /** How many of the latest decisions a first watch --once checks. */
24
24
  const FIRST_RUN_DECISIONS = 10n;
@@ -46,9 +46,13 @@ const COMMAND_USAGE = {
46
46
  place of the key.`,
47
47
  join: ` brainchain-worker join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]
48
48
  Stake the minimum and apply to take tasks. The name is at
49
- most 60 characters, the description at most 500. --agent-id
50
- (mainnet) names the worker's ERC-8004 agent on Robinhood Chain,
51
- so its BrainChain record is written to that identity.`,
49
+ most 60 characters and the description at most 500, counted
50
+ as UTF-16 units: two or more for an emoji. A worker that is
51
+ allowed or has an application waiting needs neither, and join
52
+ says so if given them: it stakes back up to the minimum.
53
+ --agent-id (mainnet) names the worker's ERC-8004 agent on
54
+ Robinhood Chain, so its BrainChain record is written to that
55
+ identity.`,
52
56
  run: ` brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--mint]
53
57
  Do every task Jev hires the worker for: the command reads the
54
58
  task on stdin and prints the deliverable on stdout, within
@@ -60,32 +64,49 @@ const COMMAND_USAGE = {
60
64
  price: ` brainchain-worker price <USDG> Change your asking price per task.`,
61
65
  mint: ` brainchain-worker mint [--count <n>] [--dry]
62
66
  Mint a Proof for each paid task waiting for one, up to 10 or
63
- --count, at 0.001 WETH each on Robinhood Chain. --dry sends nothing.`,
67
+ --count, at 0.001 WETH each on Robinhood Chain. A task stays
68
+ listed until the service has seen its mint, which can take minutes,
69
+ and a mint before then pays for another piece. --dry lists them and
70
+ sends nothing.`,
64
71
  claim: ` brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
65
72
  --dry lists what is due and sends nothing.`,
66
73
  bond: ` brainchain-worker bond <task> <USDG> [--yes] | bond status <task> | bond claim <task>
67
- Bond USDG on Shipped behind a task, at least its bounty and 2 USDG:
68
- the task's Proof releases it less Shipped's 1% fee, and a task that
69
- is not paid by its deadline pays the bond to the poster. Shows the
70
- cost and asks before sending; --yes sends without asking. status
71
- says where the bond stands and claim collects what a settled one
72
- left the worker. Mainnet.`,
74
+ Bond USDG on Shipped behind a task Jev hired this worker for, at
75
+ least its bounty and 2 USDG. If the task is paid and its Proof
76
+ recorded by the bond's deadline, the task's plus 48 hours, the bond
77
+ comes back less Shipped's 1% fee; if not, it goes to the agent that
78
+ paid BrainChain's desk for the task, or to the poster for a task
79
+ posted on the board directly. Shows the cost and asks before
80
+ sending; --yes sends without asking. status says where the bond
81
+ stands and claim collects what Shipped's registry owes the worker.
82
+ Mainnet.`,
73
83
  bridge: ` brainchain-worker bridge --eth <ETH> | --stake <amount> | --bounty <amount> [--dry]
74
84
  Move ETH for gas, the stake token or the bounty token from Robinhood
75
85
  Chain to the worker's address on BrainChain; it arrives in about a
76
- minute. --eth works on both networks, the tokens on mainnet. --dry
77
- checks the balances and sends nothing.`,
86
+ minute. It takes one of the three at a time. --eth works on both
87
+ networks, the tokens on mainnet. --dry checks the balances and sends
88
+ nothing.`,
78
89
  exit: ` brainchain-worker exit [--amount <USDG>] [--to-solana <address>] [--dry]
79
- Get the worker's USDG on BrainChain back within a minute, not the
80
- bridge's seven days: the desk pays it on Robinhood Chain less a 0.5%
81
- fee, or as USDC on Solana with --to-solana (mainnet). Without --amount,
82
- the whole balance. --dry checks and sends nothing.`,
90
+ On testnet, get the worker's tUSDG on BrainChain back, usually within
91
+ a minute, not the bridge's seven days: the desk pays it on Robinhood
92
+ Chain testnet less a 0.5% fee, at least 0.01 tUSDG. Without --amount,
93
+ the whole balance. --dry checks and sends nothing. Fast exits are off
94
+ on mainnet, where exit says so and sends nothing. --to-solana names a
95
+ Solana wallet for the payout, which Relay can reach from mainnet only,
96
+ so neither network takes it.`,
83
97
  fund: ` brainchain-worker fund --from-solana --usdc <amount> [--stake] [--gas-usdc <amount>] [--dry]
84
- Fund the worker from a Solana wallet: Relay turns its USDC into ETH
85
- for gas (2 USDC by default) and, with --stake, the rest into the stake
86
- token, on Robinhood Chain, and bridge moves both to BrainChain. Signed
87
- with BRAINCHAIN_SOLANA_KEY or BRAINCHAIN_SOLANA_KEY_FILE; the wallet
88
- needs about 0.002 SOL for fees. Mainnet.`,
98
+ Fund the worker from a Solana wallet. Relay turns --gas-usdc of its
99
+ USDC (2 by default, and never more than --usdc) into ETH on Robinhood
100
+ Chain. With --stake, the rest of --usdc becomes the stake token there;
101
+ without it, the rest stays on Solana. Then bridge moves on to BrainChain
102
+ only what this run brought, less the ETH a token deposit pays, leaving
103
+ a little for gas; what the wallet already held stays. A quote that would
104
+ cost more than 3% on the ETH or 5% on the stake token (or 0.50 USD on a
105
+ small move), or that asks to sign anything but this USDC's deposit into
106
+ Relay's depository, stops the command before anything is signed. Signed
107
+ with BRAINCHAIN_SOLANA_KEY or BRAINCHAIN_SOLANA_KEY_FILE, one or the
108
+ other; the wallet needs about 0.002 SOL for fees. --dry checks and sends
109
+ nothing. Mainnet.`,
89
110
  propose: ` brainchain-worker propose --target board|executor|registry --call <function> --args '<JSON array>'
90
111
  --title <text> --rationale <text> [--current <text>] [--proposed <text>] [--dry]
91
112
  Bond the stake token and propose a change to BrainChain for Jev to
@@ -94,13 +115,18 @@ const COMMAND_USAGE = {
94
115
  proposal: ` brainchain-worker proposal <id> Where a proposal stands: waiting, adopted and when it runs, or kept.`,
95
116
  watch: ` brainchain-worker watch [--veto] [--once]
96
117
  Watch the chain: judge every queued change against the constitution
97
- and re-verify every new decision, reporting what disagrees. With the
98
- worker's key set, each round is reported to the service toward the
99
- worker's watchtower mark. --veto vetoes a change that crosses a floor
100
- through the guardian gate, when the worker's key is the gate's
101
- watcher. --once reads one round, checks the decisions added since
102
- the last --once (the latest 10 on the first) and exits 1 on a finding
103
- or a decision it could not check.`,
118
+ and re-verify every new decision, reporting what disagrees. A call the
119
+ monitor does not know counts as breaking a floor; one with no floor,
120
+ such as moving house treasury funds, is listed as that. A decision
121
+ served without its work is listed as withheld, with the hash checks
122
+ that did not run. A record that cannot be read holds the place until a
123
+ day after its decision's block time; past that it is a finding, reported
124
+ once, and the place moves on. With the worker's key set, each round is
125
+ reported to the service toward the worker's watchtower mark. --veto vetoes
126
+ a change that crosses a floor through the guardian gate, when the worker's
127
+ key is the gate's watcher. --once reads one round, checks the decisions
128
+ added since the last --once (the latest 10 on the first) and exits 1
129
+ on a finding or when a decision could not be checked or reported.`,
104
130
  credit: ` brainchain-worker credit --agent-id <id> [--premium <bps>]
105
131
  Ask BrainChain to back a credit line for the worker's ERC-8004 agent
106
132
  on Priors (priors.trade): signs Priors' consent with the agent owner's
@@ -133,12 +159,13 @@ Environment:
133
159
  BRAINCHAIN_RPC_URL Overrides the network's RPC.
134
160
  BRAINCHAIN_SOLANA_KEY For fund --from-solana: the Solana wallet's secret key, base58.
135
161
  BRAINCHAIN_SOLANA_KEY_FILE
136
- Or a Solana CLI keypair file holding it.
162
+ Or a Solana CLI keypair file holding it, one or the other.
137
163
  BRAINCHAIN_SOLANA_RPC Overrides the Solana RPC (https://api.mainnet-beta.solana.com).
138
164
  BRAINCHAIN_SERVICE_URL Overrides the BrainChain service that keeps results: https, or http on
139
165
  localhost or 127.0.0.1.
140
166
 
141
167
  The command sees the worker's environment without any BRAINCHAIN_ variable, plus BRAINCHAIN_TASK_ID.
168
+ run restarts itself in place at start on Node.js 22.15 or later, so its own environment holds no key either.
142
169
  It runs as your user, so it can reach what your user can: see "Your command" in the README.
143
170
  Unfinished work is kept in ~/.config/brainchain-worker/<address>.<network>.json across restarts,
144
171
  and one run at a time works for each address on each network.
@@ -260,19 +287,15 @@ async function needGas(chain, network) {
260
287
  export async function join(chain, network, values) {
261
288
  const name = values.get("name")?.trim() ?? "";
262
289
  const about = values.get("about")?.trim() ?? "";
263
- if (!name)
264
- throw new UsageError("--name is required, up to 60 characters");
290
+ // What the brain keeps, in UTF-16 units as a JavaScript string counts them: one for nearly all text, two or more for an emoji.
265
291
  if (name.length > 60)
266
- throw new UsageError("--name is at most 60 characters");
292
+ throw new UsageError("--name is at most 60 characters, counted as UTF-16 units: one for nearly all text, two or more for an emoji");
267
293
  // A name that hides characters or turns text around can read as another, Jev's included.
268
294
  const nameHides = unseen(name);
269
295
  if (nameHides)
270
296
  throw new UsageError(`--name must be one line with no invisible or direction-changing characters, and it holds ${nameHides}`);
271
- if (!about) {
272
- 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");
273
- }
274
297
  if (about.length > 500)
275
- throw new UsageError("--about is at most 500 characters");
298
+ throw new UsageError("--about is at most 500 characters, counted as UTF-16 units: one for nearly all text, two or more for an emoji");
276
299
  // A description may run over several lines.
277
300
  const aboutHides = unseen(about.replace(/[\t\n]/g, " "));
278
301
  if (aboutHides)
@@ -286,6 +309,21 @@ export async function join(chain, network, values) {
286
309
  const requested = values.has("price") ? price(values.get("price")) : undefined;
287
310
  const standing = await snapshot(chain);
288
311
  const { allowed, w, change, pending, open, applying, toStake } = standing;
312
+ // Only an application carries a name and a description, so a worker already allowed or waiting needs neither.
313
+ if (applying) {
314
+ if (!name)
315
+ throw new UsageError("--name is required, up to 60 characters");
316
+ if (!about)
317
+ 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");
318
+ if (await chain.applicationVetoed())
319
+ throw new Error("The guardian vetoed setWorkerAllowed on the task board, so the executor does not queue an application until the admin reopens that function. Nothing was sent.");
320
+ const taken = await chain.openApplication();
321
+ if (taken > 0n)
322
+ throw new Error(`Proposal ${taken} already asks to allow this worker and is still open, and the contract takes one at a time. Nothing was sent. Follow it with \`npx brainchain-worker proposal ${taken}\`.`);
323
+ }
324
+ else if (name || about) {
325
+ console.log("This worker is past its application, so --name and --about are not used.");
326
+ }
289
327
  // The agent id travels in the application, and Jev keeps the profile it decided on.
290
328
  if (agentId !== undefined) {
291
329
  if (allowed || (change && pending) || open) {
@@ -657,6 +695,8 @@ export async function main(argv) {
657
695
  }
658
696
  if (!usdc)
659
697
  throw new UsageError("--usdc is required: how much USDC on Solana to use, such as --usdc 5");
698
+ if (gasUsdc && !switches.has("stake") && parseUnits(gasUsdc, 6) > parseUnits(usdc, 6))
699
+ throw new UsageError(`--gas-usdc ${gasUsdc} is more than --usdc ${usdc}, the most this command may spend`);
660
700
  if (network.id !== "mainnet" || !network.bridge)
661
701
  throw new Error("fund --from-solana is mainnet only: Relay connects Solana with Robinhood Chain mainnet, not the testnets");
662
702
  const worker = signer();
@@ -671,17 +711,9 @@ export async function main(argv) {
671
711
  ...(network.bridge.tokens?.stake ? { stakeToken: network.bridge.tokens.stake } : {}),
672
712
  dry: switches.has("dry"),
673
713
  log: console.log,
674
- async bridgeOn(asset) {
675
- if (asset === "eth") {
676
- const eth = await bridgeChain.parentEth();
677
- if (eth <= PARENT_ETH_KEEP)
678
- return console.log("Not enough ETH to send on to BrainChain beyond what stays for gas on the parent chain.");
679
- return bridge(network, bridgeChain, { asset: "eth", amount: formatEther(eth - PARENT_ETH_KEEP), dry: false, log: console.log });
680
- }
681
- const held = await bridgeChain.tokenBalance("stake");
682
- const { decimals } = assetOf(network, "stake");
683
- return bridge(network, bridgeChain, { asset: "stake", amount: formatUnits(held, decimals), dry: false, log: console.log });
684
- },
714
+ parentEth: () => bridgeChain.parentEth(),
715
+ parentStake: () => bridgeChain.tokenBalance("stake"),
716
+ bridgeOn: (asset, amount) => bridge(network, bridgeChain, { asset, amount: formatUnits(amount, assetOf(network, asset).decimals), dry: false, log: console.log }),
685
717
  });
686
718
  }
687
719
  if (command === "credit") {
@@ -711,10 +743,7 @@ export async function main(argv) {
711
743
  set: { key: "active", release: "", fromBlock: Number(network.fromBlock), board: taskBoard, log, executor },
712
744
  briefs: new Map(),
713
745
  }, id);
714
- const findings = report.checks.filter((c) => c.status === "fail" || c.status === "error").map((c) => `${c.name}: ${c.message}`);
715
- // A verified decision is reported with the record it was checked against, fetched again as the service serves it.
716
- const record = findings.length === 0 && keyed && report.payloadURI ? await fetchRecord(report.payloadURI).catch(() => undefined) : undefined;
717
- return { findings, ...(record ? { record } : {}) };
746
+ return outcome(report, keyed ? { refetch: fetchRecord } : {});
718
747
  };
719
748
  const chain = createWatchChain(network, account);
720
749
  const watch = createWatch({
@@ -747,9 +776,8 @@ export async function main(argv) {
747
776
  const id = positional[0];
748
777
  if (id === undefined || !/^\d{1,18}$/.test(id) || id === "0")
749
778
  throw new UsageError("Usage: brainchain-worker proposal <id>, such as proposal 2");
750
- // Reading a proposal needs no key, so any address stands in for the signer.
751
- const reader = keyConfigured() ? signer().account : privateKeyToAccount(generatePrivateKey());
752
- return proposal(network, createProposals(network, reader), BigInt(id), console.log);
779
+ // Reading a proposal needs no key, so any address stands in for the signer, and a key that is set is never read.
780
+ return proposal(network, createProposals(network, privateKeyToAccount(generatePrivateKey())), BigInt(id), console.log);
753
781
  }
754
782
  const worker = signer();
755
783
  if (command === "dispute" || command === "appeal") {
package/dist/credit.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createPublicClient, http, parseAbi } from "viem";
1
+ import { BaseError, ContractFunctionRevertedError, createPublicClient, http, parseAbi } from "viem";
2
2
  import { reason } from "./chain.js";
3
3
  import { parentChainOf } from "./network.js";
4
4
  export const CONSENT_TYPES = {
@@ -47,6 +47,9 @@ export async function credit(network, chain, options) {
47
47
  owner = await chain.ownerOf(agentId);
48
48
  }
49
49
  catch (error) {
50
+ // The registry reverts ownerOf for an id it never minted; anything else says nothing about the agent.
51
+ if (error instanceof BaseError && error.walk((e) => e instanceof ContractFunctionRevertedError))
52
+ throw new Error(`There is no ERC-8004 agent ${agentId} on Robinhood Chain.`);
50
53
  throw new Error(`ERC-8004 agent ${agentId} could not be read on Robinhood Chain: ${reason(error)}`);
51
54
  }
52
55
  if (owner.toLowerCase() !== chain.account.toLowerCase()) {
package/dist/exit.js CHANGED
@@ -1,16 +1,32 @@
1
1
  // `exit`: the worker's USDG on BrainChain paid back within a minute instead of the bridge's seven days. The desk pays
2
2
  // it on Robinhood Chain, or, with `--to-solana`, as USDC on Solana through Relay: the transfer to the desk carries the
3
- // Solana address after its arguments, so nothing else is signed and nothing is needed on Robinhood Chain.
3
+ // Solana address after its arguments, so nothing else is signed and nothing is needed on Robinhood Chain. The token and
4
+ // the desk the service names are checked against this release before anything is signed.
5
+ import { PublicKey } from "@solana/web3.js";
4
6
  import bs58 from "bs58";
5
- import { createPublicClient, createWalletClient, encodeFunctionData, erc20Abi, formatUnits, http, parseUnits } from "viem";
6
- import { RECEIPT_MS } from "./chain.js";
7
+ import { createPublicClient, createWalletClient, encodeFunctionData, erc20Abi, formatUnits, http, isAddress, parseUnits } from "viem";
8
+ import { RECEIPT_MS, reason } from "./chain.js";
7
9
  import { chainOf } from "./network.js";
8
- /** A Solana address: 32 bytes in base58. */
10
+ /**
11
+ * Keys no one can spend USDC from. Program ids are often on the curve, so the
12
+ * curve alone does not catch them: the all-zero address, which the desk also
13
+ * reads as no destination, the token programs, USDC's mint and Relay's depository.
14
+ */
15
+ const NOT_WALLETS = new Set([
16
+ "11111111111111111111111111111111",
17
+ "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
18
+ "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
19
+ "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
20
+ "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
21
+ "99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2",
22
+ ]);
23
+ /** A Solana address a wallet can spend from: 32 bytes in base58, on the ed25519 curve. A program-derived address is off it, and USDC sent there is stranded. */
9
24
  export function isSolanaAddress(value) {
10
- if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(value))
25
+ if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(value) || NOT_WALLETS.has(value))
11
26
  return false;
12
27
  try {
13
- return bs58.decode(value).length === 32;
28
+ const bytes = bs58.decode(value);
29
+ return bytes.length === 32 && PublicKey.isOnCurve(bytes);
14
30
  }
15
31
  catch {
16
32
  return false;
@@ -27,13 +43,39 @@ export function exitFee(amount, terms) {
27
43
  const fee = proportional > floor ? proportional : floor;
28
44
  return fee > amount ? amount : fee;
29
45
  }
46
+ const units = (value) => typeof value === "string" && /^\d{1,30}$/.test(value);
47
+ /** What the service answers for its exit terms, refused here when it is not the shape the rest of `exit` reads. */
30
48
  export async function exitTerms(service, fetchImpl = fetch) {
31
- const response = await fetchImpl(`${service}/exits`, { signal: AbortSignal.timeout(20_000) });
49
+ const host = new URL(service).host;
50
+ const response = await fetchImpl(`${service}/exits`, { signal: AbortSignal.timeout(20_000) }).catch(() => {
51
+ throw new Error(`Could not reach the BrainChain service at ${host} for the exit terms. Nothing was sent.`);
52
+ });
32
53
  if (response.status === 404)
33
54
  throw new Error("Fast exits are off on this network");
34
55
  if (!response.ok)
35
- throw new Error(`${service}/exits answered ${response.status}`);
36
- return (await response.json());
56
+ throw new Error(`The BrainChain service at ${host} answered ${response.status} when asked for the exit terms. Nothing was sent.`);
57
+ const terms = (await response.json().catch(() => undefined));
58
+ const readable = typeof terms?.desk === "string" &&
59
+ isAddress(terms.desk, { strict: false }) &&
60
+ typeof terms.token === "string" &&
61
+ isAddress(terms.token, { strict: false }) &&
62
+ Number.isInteger(terms.feeBps) &&
63
+ terms.feeBps >= 0 &&
64
+ [terms.minFeeUnits, terms.minUnits, terms.maxUnits].every(units);
65
+ if (!readable)
66
+ throw new Error(`The BrainChain service at ${host} named the exit terms in a form exit cannot read. Nothing was sent.`);
67
+ return terms;
68
+ }
69
+ /** Why the terms cannot be signed for: a transfer goes to whatever token and desk they name, and only this release says which those are. */
70
+ export function termsProblem(network, terms) {
71
+ const host = new URL(network.service).host;
72
+ if (terms.token.toLowerCase() !== network.contracts.bountyToken.toLowerCase()) {
73
+ return `The BrainChain service at ${host} names the token ${terms.token} for an exit, and ${network.name}'s ${network.bountySymbol} is ${network.contracts.bountyToken}. Nothing was sent.`;
74
+ }
75
+ if (terms.desk.toLowerCase() !== network.exitDesk.toLowerCase()) {
76
+ return `The BrainChain service at ${host} names the desk ${terms.desk}, and this release of brainchain-worker pays only ${network.exitDesk}. Nothing was sent.`;
77
+ }
78
+ return undefined;
37
79
  }
38
80
  export async function exitRecord(service, tx, fetchImpl = fetch) {
39
81
  const response = await fetchImpl(`${service}/exits/${tx}`, { signal: AbortSignal.timeout(20_000) });
@@ -43,15 +85,22 @@ export async function exitRecord(service, tx, fetchImpl = fetch) {
43
85
  throw new Error(`${service}/exits answered ${response.status}`);
44
86
  return (await response.json()).exits[0];
45
87
  }
46
- export function createExitChain(network, account) {
47
- const read = createPublicClient({ chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000 }) });
88
+ export function createExitChain(network, account, receiptMs = RECEIPT_MS) {
89
+ const read = createPublicClient({ chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000 }), pollingInterval: 1_000 });
48
90
  const wallet = createWalletClient({ account, chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000 }) });
49
91
  return {
50
92
  account: account.address,
51
93
  bountyBalance: (token) => read.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
94
+ async unmined() {
95
+ const [pending, latest] = await Promise.all([read.getTransactionCount({ address: account.address, blockTag: "pending" }), read.getTransactionCount({ address: account.address })]);
96
+ return pending - latest;
97
+ },
52
98
  async sendToDesk(token, desk, amount, solana) {
53
99
  const hash = await wallet.sendTransaction({ to: token, data: exitCalldata(desk, amount, solana) });
54
- const receipt = await read.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
100
+ // Sent is not lost: the transfer can still land, so another run would pay the desk twice.
101
+ const receipt = await read.waitForTransactionReceipt({ hash, timeout: receiptMs }).catch((error) => {
102
+ throw new Error(`The transfer to the desk was sent (${hash}) and could not be confirmed (${reason(error)}). Follow it at ${network.service}/exits/${hash} before running exit again: another run sends another transfer.`);
103
+ });
55
104
  if (receipt.status !== "success")
56
105
  throw new Error(`the transfer to the desk reverted (${hash})`);
57
106
  return hash;
@@ -64,11 +113,14 @@ export async function exit(network, chain, options) {
64
113
  const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
65
114
  if (options.toSolana !== undefined) {
66
115
  if (!isSolanaAddress(options.toSolana))
67
- throw new Error(`"${options.toSolana}" is not a Solana address`);
116
+ throw new Error(`"${options.toSolana}" is not the address of a Solana wallet, so USDC sent there could not be spent`);
68
117
  if (network.id !== "mainnet")
69
118
  throw new Error("--to-solana is mainnet only: Relay connects Solana with Robinhood Chain mainnet, not the testnets");
70
119
  }
71
120
  const terms = await exitTerms(network.service, fetchImpl);
121
+ const refused = termsProblem(network, terms);
122
+ if (refused)
123
+ throw new Error(refused);
72
124
  const balance = await chain.bountyBalance(terms.token);
73
125
  const amount = options.amount === undefined ? balance : parseUnits(options.amount, 6);
74
126
  const min = BigInt(terms.minUnits);
@@ -82,34 +134,45 @@ export async function exit(network, chain, options) {
82
134
  if (amount < min)
83
135
  throw new Error(`An exit is at least ${show(min)}`);
84
136
  if (amount > max)
85
- throw new Error(`An exit is at most ${show(max)}; send the rest in another exit`);
137
+ throw new Error(`An exit is at most ${show(max)}, and nothing was sent. Give --amount up to that, and send the rest in another exit.`);
86
138
  const fee = exitFee(amount, terms);
87
139
  const net = amount - fee;
88
- log(`Exit ${show(amount)} from ${network.name}: ${show(net)} arrives on the parent chain (chain ${terms.payoutChainId}) after a ${show(fee)} fee.`);
89
- if (options.toSolana !== undefined)
90
- log(`Paid as USDC on Solana to ${options.toSolana}, less Relay's own fee, unless the move would cost more than 3% (or 0.50 USD); then it is paid on the parent chain.`);
140
+ const paid = options.toSolana === undefined
141
+ ? `${show(net)} arrives on the parent chain (chain ${terms.payoutChainId}) after a ${show(fee)} fee.`
142
+ : `${show(net)} after a ${show(fee)} fee is paid as USDC on Solana to ${options.toSolana}, less Relay's own fee. The desk prices that move once the transfer arrives; one that would cost more than 3% (or 0.50 USD) is paid in ${symbol} on the parent chain (chain ${terms.payoutChainId}) instead.`;
143
+ log(`Exit ${show(amount)} from ${network.name}: ${paid}`);
91
144
  if (dry) {
92
145
  log("Dry run, nothing sent.");
93
146
  return;
94
147
  }
148
+ const unmined = await chain.unmined();
149
+ if (unmined > 0)
150
+ throw new Error(`This wallet has a transaction on ${network.name} that is not mined yet, which may be an earlier exit, and another run would send another transfer. Nothing was sent; run exit again once it is mined.`);
95
151
  const tx = await chain.sendToDesk(terms.token, terms.desk, amount, options.toSolana);
152
+ const follow = `${network.service}/exits/${tx}`;
96
153
  log(`Sent to the desk on ${network.name} (${tx}). Waiting for the payout, usually under a minute.`);
97
154
  const end = Date.now() + (options.waitMs ?? 1_800_000);
98
155
  let last = "";
99
156
  let record;
100
157
  for (;;) {
101
158
  record = await exitRecord(network.service, tx, fetchImpl).catch(() => undefined);
102
- if (record?.state === "paid" || record?.state === "returned")
159
+ if (record?.state === "paid" || record?.state === "returned" || record?.state === "held")
103
160
  break;
104
161
  if (record?.reason && record.reason !== last)
105
162
  log(`Waiting: ${record.reason}.`);
106
163
  last = record?.reason ?? last;
107
164
  if (Date.now() > end) {
108
- log(`Still waiting after ${Math.round((options.waitMs ?? 1_800_000) / 60_000)} minutes. The desk pays it or sends it back within a day; follow it at ${network.service}/exits/${tx}.`);
165
+ const within = options.toSolana === undefined ? "The desk pays it or sends it back within a day" : "Within a day the desk pays it or sends it back, or holds it for a person if Relay has not settled the move";
166
+ log(`Still waiting after ${Math.round((options.waitMs ?? 1_800_000) / 60_000)} minutes. ${within}; follow it at ${follow}.`);
109
167
  return;
110
168
  }
111
169
  await sleep(options.pollMs ?? 10_000);
112
170
  }
171
+ if (record.state === "held") {
172
+ log(`The desk holds this exit for a person: ${record.reason ?? "no reason given"}. Nothing more is sent for it until it is settled; follow it at ${follow}.`);
173
+ process.exitCode = 1;
174
+ return;
175
+ }
113
176
  if (record.state === "returned") {
114
177
  log(`The desk sent it back on ${network.name}: ${record.reason ?? "no reason given"} (${record.returnTx}).`);
115
178
  process.exitCode = 1;
package/dist/key.js CHANGED
@@ -6,7 +6,7 @@ import { stateDir } from "./state.js";
6
6
  /** Names the file a run passes its key through when it restarts itself without the key in its environment. */
7
7
  const HANDOFF = "BRAINCHAIN_WORKER_KEY_HANDOFF";
8
8
  const HANDOFF_NAME = /^brainchain-worker-key-[0-9a-f]{32}$/;
9
- const expandHome = (path) => (path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path);
9
+ export const expandHome = (path) => (path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path);
10
10
  /** Whether the environment names a key, without reading it: a key file can be a pipe, which reads only once. */
11
11
  export const keyConfigured = (env = process.env) => Boolean(env[HANDOFF] || env.BRAINCHAIN_WORKER_KEY?.trim() || env.BRAINCHAIN_WORKER_KEY_FILE?.trim());
12
12
  /** What is wrong with a key file's text, in words that never repeat any of it. */
@@ -85,19 +85,33 @@ function handoffDir() {
85
85
  return stateDir();
86
86
  }
87
87
  }
88
+ /** A variable that holds a key or says where one is: the worker's and the Solana wallet's, which `run` never uses. */
89
+ const KEY_VARIABLE = /^BRAINCHAIN_(WORKER|SOLANA)_KEY(_FILE)?$/;
90
+ /** A key file that is a pipe has been read and holds nothing, so its name can stay: a restart would put a key piped in to stay out of files in one for a moment. */
91
+ function namesOnlyAPipe(names, env) {
92
+ if (names.length !== 1 || names[0] !== "BRAINCHAIN_WORKER_KEY_FILE")
93
+ return false;
94
+ try {
95
+ return !statSync(expandHome(env.BRAINCHAIN_WORKER_KEY_FILE.trim())).isFile();
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ }
88
101
  /**
89
- * Restarts this process in place without BRAINCHAIN_WORKER_KEY in its
90
- * environment. Deleting the variable would not do: `ps eww` and /proc read the
91
- * block the process started with, and a command run by `run` can reach both.
92
- * The pid, the output and the supervisor's view all stay as they were. Returns
93
- * when there is nothing to clear, or when it could not be cleared.
102
+ * Restarts this process in place without any key variable in its environment.
103
+ * Deleting them would not do: `ps eww` and /proc read the block the process
104
+ * started with, and a command run by `run` can reach both. The pid, the output
105
+ * and the supervisor's view all stay as they were. Returns when there is
106
+ * nothing to clear, or when it could not be cleared.
94
107
  */
95
108
  export function clearKeyFromEnvironment(key, warn, env = process.env, execve = process.execve) {
96
- if (env.BRAINCHAIN_WORKER_KEY === undefined)
109
+ const names = Object.keys(env).filter((name) => KEY_VARIABLE.test(name));
110
+ if (names.length === 0 || namesOnlyAPipe(names, env))
97
111
  return;
98
112
  if (typeof execve !== "function")
99
- return warn(`warning: Node.js ${process.version} cannot clear the key from this process's environment; Node.js 22.15 or later can`);
100
- const { BRAINCHAIN_WORKER_KEY: _, ...rest } = env;
113
+ return warn(`warning: Node.js ${process.version} cannot clear the key variables from this process's environment; Node.js 22.15 or later can`);
114
+ const rest = Object.fromEntries(Object.entries(env).filter(([name]) => !KEY_VARIABLE.test(name)));
101
115
  let file;
102
116
  try {
103
117
  file = join(handoffDir(), `brainchain-worker-key-${randomBytes(16).toString("hex")}`);
@@ -107,6 +121,6 @@ export function clearKeyFromEnvironment(key, warn, env = process.env, execve = p
107
121
  catch (error) {
108
122
  if (file !== undefined)
109
123
  rmSync(file, { force: true });
110
- warn(`warning: could not clear the key from this process's environment (${error.message})`);
124
+ warn(`warning: could not clear the key variables from this process's environment (${error.message})`);
111
125
  }
112
126
  }
package/dist/mint.js CHANGED
@@ -159,7 +159,7 @@ export async function mint(network, chain, options) {
159
159
  return log(`The BrainChain service lists no paid task of ${chain.account} waiting for a Proof, so there is nothing to mint.`);
160
160
  const batch = listed.slice(0, Math.min(options.count ?? MAX_MINT, MAX_MINT));
161
161
  const left = listed.length - batch.length;
162
- const rest = left > 0 ? ` ${left} more paid task${left === 1 ? " waits" : "s wait"} for a Proof: run mint again.` : "";
162
+ const rest = left > 0 ? ` ${left} more paid task${left === 1 ? " waits" : "s wait"} for a Proof, for a later mint.` : "";
163
163
  const funds = await afford(proofs, chain, batch.length, { dry: options.dry, claiming: () => log("Claiming test WETH from the faucet..."), now: options.now ?? wall });
164
164
  const each = `${formatEther(funds.price)} WETH each plus gas`;
165
165
  if (options.dry) {
@@ -180,6 +180,8 @@ export async function mint(network, chain, options) {
180
180
  }
181
181
  if (rest)
182
182
  log(rest.trim());
183
+ // The service scans in rounds and caches the list, so it still shows these tasks for a while.
184
+ log("`mint --dry` lists these tasks until the service has seen these mints, which can take minutes. A mint before then, here or by `run --mint`, pays for another piece.");
183
185
  }
184
186
  /**
185
187
  * What `run` does once a task is delivered: it watches the task until it
package/dist/network.js CHANGED
@@ -37,6 +37,7 @@ export const TESTNET = {
37
37
  router: "0x1BBe71Dd3af85309A5c7Ab00B190F38f34Fe3569",
38
38
  gas: "Get Robinhood Chain testnet ETH at https://faucet.testnet.chain.robinhood.com",
39
39
  },
40
+ exitDesk: "0xf848984c8A0D9E49Cc9C97380C62b34C6f3b97D1",
40
41
  contracts: {
41
42
  taskBoard: "0x7A7cC5D35A642CCc268C7B263fF800bc7eBD52A4",
42
43
  staking: "0x6aFC3EFB49CF9b14C2F7c6C4f6C0bb3eF0125e2b",
@@ -97,6 +98,7 @@ export const MAINNET = {
97
98
  gas: "Send it a little ETH on Robinhood Chain",
98
99
  tokens: { stake: "0xC80333bf7f4078e14Ce5b6e223aF607aDcA44418", bounty: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168" },
99
100
  },
101
+ exitDesk: "0xafB0C4Ef895D4895040cdC6435B96D3EE8350c6E",
100
102
  contracts: {
101
103
  taskBoard: MAINNET_TASK_BOARD,
102
104
  staking: MAINNET_WORKER_STAKING,