brainchain-worker 0.18.3 → 0.19.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/chain.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isIP } from "node:net";
2
- import { BaseError, ContractFunctionRevertedError, createPublicClient, createWalletClient, encodeFunctionData, http, HttpRequestError, InsufficientFundsError, keccak256, parseAbi, parseEventLogs, RpcRequestError, TimeoutError, toBytes, WaitForTransactionReceiptTimeoutError, } from "viem";
2
+ import { BaseError, ContractFunctionRevertedError, createPublicClient, createWalletClient, encodeAbiParameters, encodeFunctionData, http, HttpRequestError, InsufficientFundsError, keccak256, parseAbi, parseEventLogs, RpcRequestError, slice, TimeoutError, toBytes, WaitForTransactionReceiptTimeoutError, } from "viem";
3
3
  import { erc20Errors, taskBoardAbi, transferErrors, workerStakingAbi } from "./abi.js";
4
4
  import { chainOf, parentChainOf } from "./network.js";
5
5
  import { memoryStore } from "./state.js";
@@ -18,8 +18,10 @@ export const bondsAbi = [
18
18
  "event Proposed(uint256 indexed id, address indexed proposer, address indexed target, bytes data, uint128 bond, string uri)",
19
19
  "event Settled(uint256 indexed id, uint256 indexed decisionId, bool adopted)",
20
20
  "function propose(address target, bytes data, string uri) returns (uint256)",
21
- "function get(uint256 id) view returns ((address proposer, uint128 bond, uint64 createdAt, uint8 state, bytes32 subject))",
21
+ "function get(uint256 id) view returns ((address proposer, uint128 bond, uint64 createdAt, uint8 state, bytes32 subject, uint64 deadline, uint16 thresholdBps))",
22
22
  "function bond() view returns (uint128)",
23
+ "function openProposal(bytes32 subject) view returns (uint256)",
24
+ "error AlreadyProposed(uint256 id)",
23
25
  ]),
24
26
  ...erc20Errors,
25
27
  ...transferErrors,
@@ -28,6 +30,9 @@ export const executorAbi = parseAbi([
28
30
  "event Queued(uint256 indexed changeId, uint256 indexed decisionId, address indexed target, bytes data, uint64 eta)",
29
31
  "function getChange(uint256 changeId) view returns ((address target, bytes data, uint64 eta, bool executed, bool vetoed))",
30
32
  "function timelock() view returns (uint64)",
33
+ "function changeSubject(address target, bytes data) view returns (bytes32)",
34
+ "function vetoed(address target, bytes4 selector) view returns (bool)",
35
+ "function vetoedCall(bytes32) view returns (bool)",
31
36
  ]);
32
37
  /** The appeal terms of a task on set 3.5.0 and later; earlier boards have no appeals and no Ruled or Appealed status. */
33
38
  export const appealAbi = parseAbi([
@@ -59,6 +64,25 @@ export function workerId(account) {
59
64
  export function applicationData(account) {
60
65
  return encodeFunctionData({ abi: taskBoardAbi, functionName: "setWorkerAllowed", args: [account, true] });
61
66
  }
67
+ /** Whether the guardian's veto shuts a call. A set 3.4.0 executor shuts a function (target and selector) until the admin reopens it; earlier ones block the exact call. */
68
+ export async function callVetoed(client, executor, target, data) {
69
+ try {
70
+ return await client.readContract({ address: executor, abi: executorAbi, functionName: "vetoed", args: [target, slice(data, 0, 4)] });
71
+ }
72
+ catch {
73
+ return client.readContract({
74
+ address: executor,
75
+ abi: executorAbi,
76
+ functionName: "vetoedCall",
77
+ args: [keccak256(encodeAbiParameters([{ type: "address" }, { type: "bytes" }], [target, data]))],
78
+ });
79
+ }
80
+ }
81
+ /** The open proposal for this exact call, from any wallet, or 0n: the bond contract takes one at a time. */
82
+ export async function openProposalOf(client, network, target, data) {
83
+ const subject = await client.readContract({ address: network.contracts.executor, abi: executorAbi, functionName: "changeSubject", args: [target, data] });
84
+ return client.readContract({ address: network.contracts.bonds, abi: bondsAbi, functionName: "openProposal", args: [subject] });
85
+ }
62
86
  /** The contract error a call reverted with, when the ABI knows it. */
63
87
  function revertOf(error) {
64
88
  const revert = error instanceof BaseError ? error.walk((e) => e instanceof ContractFunctionRevertedError) : null;
@@ -119,6 +143,7 @@ function excerpt(details, url) {
119
143
  for (const part of secretParts(url))
120
144
  line = line.replaceAll(part, "…");
121
145
  line = line
146
+ .replace(/\b(Basic|Bearer)\s+[\w+/=.~-]+/gi, "$1 …")
122
147
  .replace(/\s+/g, " ")
123
148
  .replace(/[\x00-\x1f\x7f-\x9f]/g, "")
124
149
  .trim();
@@ -151,6 +176,19 @@ async function checkReply(response, url) {
151
176
  if (!(Array.isArray(value) ? value.every(isReply) : isReply(value)))
152
177
  throw new NotRpcError(excerpt(text, url));
153
178
  }
179
+ /**
180
+ * Whether BRAINCHAIN_RPC_URL holds anything but a host. viem leaves a URL's user
181
+ * and password out of what it reports, so they cannot be masked once an answer
182
+ * echoes them, and an echo can take any form: with such a URL, no answer's text
183
+ * is printed.
184
+ */
185
+ function rpcHoldsSecret() {
186
+ const value = process.env.BRAINCHAIN_RPC_URL?.trim();
187
+ if (!value || !URL.canParse(value))
188
+ return false;
189
+ const { username, password, pathname, search } = new URL(value);
190
+ return Boolean(username || password || search || pathname.length > 1);
191
+ }
154
192
  /** A request that failed, named by the RPC's host and the status it answered with. */
155
193
  function requestFailure(request) {
156
194
  const host = hostOf(request.url);
@@ -166,7 +204,14 @@ function requestFailure(request) {
166
204
  // A proxy or CDN in front of the RPC answers an outage or a rate limit with its own page.
167
205
  if (details.startsWith("<"))
168
206
  return `${host} answered ${status} with a web page${status === 404 || status === 405 ? ", not as an RPC" : ""}`;
169
- return `${host} answered ${status}${details ? ` (${details})` : ""}`;
207
+ return `${host} answered ${status}${details && !rpcHoldsSecret() ? ` (${details})` : ""}`;
208
+ }
209
+ /** A JSON-RPC error, in the RPC's own words: viem's line for an error code is generic and can send the reader after the wrong thing. */
210
+ function rpcRefusal(answer) {
211
+ const host = hostOf(answer.url);
212
+ if (rpcHoldsSecret())
213
+ return `${host} refused the request (JSON-RPC error ${answer.code})`;
214
+ return `${host} refused the request: ${excerpt(answer.details, answer.url)}`;
170
215
  }
171
216
  const SHORT_OF_GAS = "The wallet holds too little ETH to pay for this transaction's gas, so it was not sent. `brainchain-worker status` shows what it holds";
172
217
  /** Whether a send failed because the wallet cannot cover its gas: the RPC's gas estimate says so as a revert, and the send itself as insufficient funds. */
@@ -199,6 +244,7 @@ const PLAIN = {
199
244
  ERC20InsufficientAllowance: "the wallet has not approved enough of the token for the contract to take",
200
245
  ERC20InsufficientBalance: "the wallet holds too little of the token",
201
246
  Locked: "the stake is still locked",
247
+ AlreadyProposed: "that exact call already has an open proposal, and the contract takes one at a time",
202
248
  };
203
249
  /** One readable line for an error. It names the RPC by host only: its URL can hold an API key. */
204
250
  export function reason(error) {
@@ -211,8 +257,11 @@ export function reason(error) {
211
257
  if (request instanceof HttpRequestError || request instanceof RpcRequestError || request instanceof TimeoutError)
212
258
  return requestFailure(request);
213
259
  const name = revertOf(error)?.errorName;
214
- if (name === undefined)
215
- return error.shortMessage;
260
+ if (name === undefined) {
261
+ const answer = error.walk((e) => e instanceof RpcRequestError);
262
+ const reverted = error.walk((e) => e instanceof ContractFunctionRevertedError) instanceof ContractFunctionRevertedError;
263
+ return answer instanceof RpcRequestError && !reverted ? rpcRefusal(answer) : error.shortMessage;
264
+ }
216
265
  const plain = Object.hasOwn(PLAIN, name) ? PLAIN[name] : undefined;
217
266
  return plain ? `${error.shortMessage.replace(/\.$/, "")}: ${plain} (${name})` : `${error.shortMessage} (${name})`;
218
267
  }
@@ -389,8 +438,14 @@ export function createChain(network, account, store = memoryStore()) {
389
438
  },
390
439
  stakeTokens: (amount, price) => send({ address: staking, abi: workerStakingAbi, functionName: "stake", args: [amount, price] }),
391
440
  setPrice: (price) => send({ address: staking, abi: workerStakingAbi, functionName: "setPrice", args: [price] }),
392
- requestUnstake: (amount) => send({ address: staking, abi: workerStakingAbi, functionName: "requestUnstake", args: [amount] }),
393
- withdraw: () => send({ address: staking, abi: workerStakingAbi, functionName: "withdraw" }),
441
+ requestUnstake: (amount, at = staking) => send({ address: at, abi: workerStakingAbi, functionName: "requestUnstake", args: [amount] }),
442
+ withdraw: (at = staking) => send({ address: at, abi: workerStakingAbi, functionName: "withdraw" }),
443
+ /** Stake left on earlier sets' contracts, which this set's reads do not see. */
444
+ async retiredStakes() {
445
+ const sets = network.retired ?? [];
446
+ const stakes = await Promise.all(sets.map((s) => client.readContract({ address: s.staking, abi: workerStakingAbi, functionName: "get", args: [account.address] })));
447
+ return sets.flatMap((s, i) => (stakes[i].staked > 0n || stakes[i].unstaking > 0n ? [{ ...s, w: stakes[i] }] : []));
448
+ },
394
449
  /**
395
450
  * Whether this wallet owns the ERC-8004 agent `agentId` on the parent chain or is
396
451
  * its registered wallet; undefined when the registry could not be read.
@@ -409,6 +464,10 @@ export function createChain(network, account, store = memoryStore()) {
409
464
  }
410
465
  },
411
466
  apply: (profileURI) => send({ address: bonds, abi: bondsAbi, functionName: "propose", args: [taskBoard, applicationData(account.address), profileURI] }),
467
+ /** Whether the guardian's veto shuts allowing a worker, which is what an application asks for. */
468
+ applicationVetoed: () => callVetoed(client, executor, taskBoard, applicationData(account.address)),
469
+ /** An open proposal to allow this worker, from any wallet, or 0n. */
470
+ openApplication: () => openProposalOf(client, network, taskBoard, applicationData(account.address)),
412
471
  /**
413
472
  * This worker's applications, newest last, with their state and the
414
473
  * decision that settled each, and the last change any of them queued.
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
@@ -111,9 +137,9 @@ const COMMAND_USAGE = {
111
137
  window. Jev rules on it; a confident ruling decides.`,
112
138
  appeal: ` brainchain-worker appeal <task> Appeal the ruling on a task, when it went against the worker, within the
113
139
  appeal window, with the bond the task's terms fix (a quarter of the
114
- bounty), claimed from the test USDG faucet when the wallet holds less.
115
- Jev rules again; the bond comes back unless Jev confidently rules the
116
- same way. Testnet only for now.`,
140
+ bounty); on testnet it is claimed from the test USDG faucet when the
141
+ wallet holds less. Jev rules again; the bond comes back unless Jev
142
+ confidently rules the same way.`,
117
143
  leave: ` brainchain-worker leave Start unstaking (3-day cooldown).`,
118
144
  withdraw: ` brainchain-worker withdraw Withdraw stake once the cooldown has passed.`,
119
145
  };
@@ -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) {
@@ -366,7 +404,7 @@ export async function join(chain, network, values) {
366
404
  "the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
367
405
  }
368
406
  async function status(chain, network) {
369
- const [gas, gasPrice, sent, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
407
+ const [gas, gasPrice, sent, s, held, record, bountyBalance, stakeBalance, bond, faucet, now, oldStakes] = await Promise.all([
370
408
  chain.ethBalance(),
371
409
  chain.gasPrice(),
372
410
  chain.sent(),
@@ -379,12 +417,13 @@ async function status(chain, network) {
379
417
  // A stake token without a faucet, as on mainnet, leaves out what the faucet would give.
380
418
  network.buyStake ? undefined : chain.faucet().catch(() => undefined),
381
419
  chain.now(),
420
+ chain.retiredStakes(),
382
421
  ]);
383
422
  const { w, min } = s;
384
423
  const unstaking = w.unstaking > 0n ? `, ${stake(network, w.unstaking)} unstaking until ${when(w.unlocksAt)}` : "";
385
424
  console.log(`Worker ${chain.account} on ${network.name}
386
425
  Hireable ${hireable(s, network, "status")}
387
- Stake ${stake(network, w.staked)} (minimum ${stake(network, min)})${unstaking}
426
+ Stake ${stake(network, w.staked)} (minimum ${stake(network, min)})${unstaking}${oldStakes.map((o) => `\n Old stake ${stake(network, o.w.staked + o.w.unstaking)} on set ${o.release}, which does not count here; leave and withdraw collect it`).join("")}
388
427
  Wallet ${wallet(network, s, stakeBalance, bond, faucet, now)}
389
428
  Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
390
429
  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("")}
@@ -584,6 +623,12 @@ export function watching(network, address, veto) {
584
623
  return `watching ${network.name} (read-only, no key)`;
585
624
  return `watching ${network.name} as ${address}${veto ? ", vetoing through the guardian gate" : ""}, reporting rounds to the service`;
586
625
  }
626
+ /** `run` starts the worker's command in its own process group, which Windows does not have, and loses its output there. */
627
+ export function requirePosix(platform) {
628
+ if (platform === "win32") {
629
+ throw new Error("run works on macOS and Linux. On Windows, run the worker inside WSL: https://learn.microsoft.com/windows/wsl/install. Every other command works on Windows as it is.");
630
+ }
631
+ }
587
632
  export async function main(argv) {
588
633
  const [command, ...rest] = argv;
589
634
  if (command === undefined || command === "help" || command === "--help" || command === "-h") {
@@ -611,6 +656,8 @@ export async function main(argv) {
611
656
  return;
612
657
  }
613
658
  const { positional, values, switches } = parse(command, rest);
659
+ if (command === "run")
660
+ requirePosix(process.platform);
614
661
  const network = resolveNetwork(process.env);
615
662
  if (process.env.BRAINCHAIN_RPC_URL?.trim()) {
616
663
  await requireChainId(network, () => createPublicClient({ transport: http(network.rpc, { timeout: 10_000, retryCount: 0 }) }).getChainId());
@@ -657,6 +704,8 @@ export async function main(argv) {
657
704
  }
658
705
  if (!usdc)
659
706
  throw new UsageError("--usdc is required: how much USDC on Solana to use, such as --usdc 5");
707
+ if (gasUsdc && !switches.has("stake") && parseUnits(gasUsdc, 6) > parseUnits(usdc, 6))
708
+ throw new UsageError(`--gas-usdc ${gasUsdc} is more than --usdc ${usdc}, the most this command may spend`);
660
709
  if (network.id !== "mainnet" || !network.bridge)
661
710
  throw new Error("fund --from-solana is mainnet only: Relay connects Solana with Robinhood Chain mainnet, not the testnets");
662
711
  const worker = signer();
@@ -671,17 +720,9 @@ export async function main(argv) {
671
720
  ...(network.bridge.tokens?.stake ? { stakeToken: network.bridge.tokens.stake } : {}),
672
721
  dry: switches.has("dry"),
673
722
  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
- },
723
+ parentEth: () => bridgeChain.parentEth(),
724
+ parentStake: () => bridgeChain.tokenBalance("stake"),
725
+ bridgeOn: (asset, amount) => bridge(network, bridgeChain, { asset, amount: formatUnits(amount, assetOf(network, asset).decimals), dry: false, log: console.log }),
685
726
  });
686
727
  }
687
728
  if (command === "credit") {
@@ -711,10 +752,7 @@ export async function main(argv) {
711
752
  set: { key: "active", release: "", fromBlock: Number(network.fromBlock), board: taskBoard, log, executor },
712
753
  briefs: new Map(),
713
754
  }, 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 } : {}) };
755
+ return outcome(report, keyed ? { refetch: fetchRecord } : {});
718
756
  };
719
757
  const chain = createWatchChain(network, account);
720
758
  const watch = createWatch({
@@ -747,9 +785,8 @@ export async function main(argv) {
747
785
  const id = positional[0];
748
786
  if (id === undefined || !/^\d{1,18}$/.test(id) || id === "0")
749
787
  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);
788
+ // Reading a proposal needs no key, so any address stands in for the signer, and a key that is set is never read.
789
+ return proposal(network, createProposals(network, privateKeyToAccount(generatePrivateKey())), BigInt(id), console.log);
753
790
  }
754
791
  const worker = signer();
755
792
  if (command === "dispute" || command === "appeal") {
@@ -795,6 +832,12 @@ export async function main(argv) {
795
832
  case "leave": {
796
833
  const w = await chain.stake();
797
834
  if (w.staked === 0n) {
835
+ const old = (await chain.retiredStakes()).find((o) => o.w.staked > 0n);
836
+ if (old) {
837
+ await needGas(chain, network);
838
+ await chain.requestUnstake(old.w.staked, old.staking);
839
+ return console.log(`Unstaking ${stake(network, old.w.staked)} from set ${old.release}. Run \`npx brainchain-worker withdraw\` once its cooldown has passed and no task of the worker's on that set is in flight.`);
840
+ }
798
841
  throw new Error(w.unstaking > 0n ? `Nothing left to unstake: ${stake(network, w.unstaking)} is already unstaking until ${when(w.unlocksAt)}.` : "Nothing is staked.");
799
842
  }
800
843
  await needGas(chain, network);
@@ -805,8 +848,16 @@ export async function main(argv) {
805
848
  }
806
849
  case "withdraw": {
807
850
  const [w, held, now] = await Promise.all([chain.stake(), chain.held(), chain.now()]);
808
- if (w.unstaking === 0n)
809
- throw new Error("Nothing is unstaking. Run leave first.");
851
+ if (w.unstaking === 0n) {
852
+ const old = (await chain.retiredStakes()).find((o) => o.w.unstaking > 0n);
853
+ if (!old)
854
+ throw new Error("Nothing is unstaking. Run leave first.");
855
+ if (now < old.w.unlocksAt)
856
+ throw new Error(`${stake(network, old.w.unstaking)} on set ${old.release} unlocks at ${when(old.w.unlocksAt)}. Withdraw after that.`);
857
+ await needGas(chain, network);
858
+ await chain.withdraw(old.staking);
859
+ return console.log(`Withdrew ${stake(network, old.w.unstaking)} from set ${old.release}.`);
860
+ }
810
861
  if (now < w.unlocksAt)
811
862
  throw new Error(`${stake(network, w.unstaking)} unlocks at ${when(w.unlocksAt)}. Withdraw after that.`);
812
863
  if (held > 0)
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()) {