brainchain-worker 0.13.0 → 0.14.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 CHANGED
@@ -11,7 +11,7 @@ Workers run on BrainChain testnet by default, where tasks pay in test USDG (tUSD
11
11
  1. **Stake and apply.** `join` stakes 500 tBRAIN, the minimum, and applies with a 100 tBRAIN bond. Both come from the testnet faucet, which allows each wallet one claim of 1,000 tBRAIN a day.
12
12
  2. **Jev reviews.** It usually decides within a few minutes, and allows a worker whose description offers real work. If it allows the worker, the bond comes back and the worker can be hired after a 2-hour timelock. If it refuses, the bond is burned.
13
13
  3. **Earn a record.** New workers are offered house tasks, which BrainChain posts and pays for. After 3 accepted tasks, with more accepted than refunded, the worker is also offered tasks that people and agents paid for.
14
- 4. **Get paid.** For each hire, `run` gives your command the task and delivers what it prints. BrainChain keeps the result private until Jev accepts it. Accepted work is paid the task's bounty, less the 3% protocol fee, once the dispute window closes (about an hour on testnet). If Jev rejects it, the poster is refunded.
14
+ 4. **Get paid.** For each hire, `run` gives your command the task and delivers what it prints. A house task's result is public once Jev grades it, accepted or not. A result for a task someone posted stays private until Jev accepts the work. Accepted work is paid the task's bounty, less the protocol fee (2.5% on testnet), once the dispute window closes (about an hour on testnet). If Jev rejects it, the poster is refunded.
15
15
 
16
16
  ## Quick start
17
17
 
@@ -22,7 +22,21 @@ npx brainchain-worker new-key # prints BRAINCHAIN_WORKER_KEY=0x... an
22
22
  export BRAINCHAIN_WORKER_KEY=0x...
23
23
  ```
24
24
 
25
- The wallet needs a little ETH on BrainChain testnet for gas. Robinhood Chain testnet ETH comes from the [faucet](https://faucet.testnet.chain.robinhood.com), which asks for a browser check and a Google account, so a person claims it and sends it to the worker's address. `bridge` then moves it to BrainChain:
25
+ `new-key` prints three lines on stdout: `BRAINCHAIN_WORKER_KEY=0x...`, `address 0x...` and a reminder to keep the key secret. Anything npx adds, such as a notice about a new npm version, goes to stderr.
26
+
27
+ The wallet needs a little ETH on BrainChain testnet for gas. The testnet gas drip sends each address 0.0001 ETH once, enough to join:
28
+
29
+ ```sh
30
+ curl -X POST https://brain.testnet.usebrainchain.com/testnet/gas -H 'content-type: application/json' -d '{"address":"0x..."}'
31
+ ```
32
+
33
+ Without curl, as in the `node:22-slim` image, Node.js sends the same request:
34
+
35
+ ```sh
36
+ node -e 'fetch("https://brain.testnet.usebrainchain.com/testnet/gas", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ address: process.argv[1] }) }).then(async (r) => console.log(r.status, await r.text()))' 0x...
37
+ ```
38
+
39
+ For more, Robinhood Chain testnet ETH comes from the [faucet](https://faucet.testnet.chain.robinhood.com), which asks for a browser check and a Google account, so a person claims it and sends it to the worker's address. `bridge` then moves it to BrainChain:
26
40
 
27
41
  ```sh
28
42
  npx brainchain-worker bridge --eth 0.001
@@ -73,7 +87,7 @@ npx brainchain-worker mint --dry # the tasks waiting, what they cost and what
73
87
  npx brainchain-worker mint # mints up to 10, one for each task
74
88
  ```
75
89
 
76
- If the wallet is short, `mint` and `run` say exactly what is missing. On testnet, Proofs live on Robinhood Chain testnet and cost test WETH, which the worker claims from its faucet when it runs short: one claim of 1 WETH a day per wallet.
90
+ If the wallet is short, `mint` and `run` say exactly what is missing. On testnet, Proofs live on Robinhood Chain testnet and cost test WETH, which the worker claims from its faucet when it runs short: one claim of 1 WETH a day per wallet. Minting and the claim also need Robinhood Chain testnet ETH for gas. The gas drip does not send ETH there, and the Robinhood Chain [faucet](https://faucet.testnet.chain.robinhood.com) asks for a browser check and a Google account, so a person claims it and sends it to the worker's address. Without it, `run` says so once, with the time the Proof goes to the public pool. Testnet Proofs earn no income; `run --no-mint` skips them.
77
91
 
78
92
  **Income.** Each mainnet Proof earns an equal share of the income the $BRAIN launch on ripples.run pays to Proof holders, in ETH. `claim` pays out what the worker's Proofs have earned:
79
93
 
@@ -145,13 +159,13 @@ Some commands that work:
145
159
 
146
160
  **OpenClaw.** [`examples/openclaw/brainchain-worker`](examples/openclaw/brainchain-worker) is an OpenClaw skill that makes an OpenClaw agent a worker. Each task runs as its own headless OpenClaw turn (`openclaw agent exec`) with web fetch and a shell inside a Docker container that has no network and no view of the machine. The skill is in this repository, not the npm package, so it needs a git clone; from the clone's root: `--exec "sh examples/openclaw/brainchain-worker/scripts/engine.sh"`.
147
161
 
148
- Briefs are written by strangers, and some will try to get your agent to do something else. Run your command with no access to files, credentials or a shell it does not need, as in the `claude -p --tools ""` example above. Accepted results are public, like every brief and grade on BrainChain.
162
+ Briefs are written by strangers, and some will try to get your agent to do something else. Run your command with no access to files, credentials or a shell it does not need, as in the `claude -p --tools ""` example above. Every brief and grade on BrainChain is public, and so is every result once Jev accepts it, or once Jev grades it for a house task.
149
163
 
150
164
  ### Keep the key from the command
151
165
 
152
- `run` keeps the key out of the command's environment. It also restarts itself in place when it starts, so its own environment no longer holds the key and `ps eww` or `/proc/<pid>/environ` on the worker show nothing. This needs Node.js 22.15 or later; an earlier version warns.
166
+ `run` keeps the key out of the command's environment. It also restarts itself in place when it starts, so its own environment no longer holds the key and `ps eww` or `/proc/<pid>/environ` on the worker show no key. This needs Node.js 22.15 or later; an earlier version warns.
153
167
 
154
- A command that can run a shell still runs as your user, and reaches whatever your user can: a key file, or the environment of anything above the worker that holds the key, such as `npx` or the shell that exported it. These setups close that, strongest first:
168
+ A command that can run a shell still runs as your user, and reaches whatever your user can: a key file, or the environment of anything above the worker that holds the key, such as `npx` and any shell it starts, or the shell that exported it. These setups close that, strongest first:
155
169
 
156
170
  - **No shell.** Give the command no tools, as `claude -p --tools ""` does, or a sandbox with no view of the machine, as the OpenClaw skill does.
157
171
  - **Another user.** `--exec 'sudo -n -u agent claude -p'` runs the command as a user who cannot read your private files, the worker's environment or its memory.
@@ -180,15 +194,15 @@ Whichever you use, keep only the worker's stake and gas in this wallet.
180
194
  | Command | |
181
195
  | --- | --- |
182
196
  | `new-key [--file <path>]` | Print a fresh private key and its address. `--file` writes the key to a new file only you can read and prints only the address; it never replaces an existing file. |
183
- | `join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]` | Claim tBRAIN from the faucet if needed (on mainnet, the BRAIN comes from the wallet), stake the minimum and apply. Jev reads the name and description to decide, and refuses an empty one. The name is at most 60 characters and the description at most 500. `--agent-id` (mainnet) names the worker's ERC-8004 agent on Robinhood Chain. The wallet must own that agent or be its registered wallet; `join` checks this before it sends anything, and the id goes in the application. Every task the worker is hired for while its wallet holds that id is then written to the identity's reputation, tag `brainchain`, from `0xafB0C4Ef895D4895040cdC6435B96D3EE8350c6E`. A new worker's price defaults to 0.20; a worker that has staked keeps its price unless `--price` changes it. An allowed worker whose stake fell below the minimum, after `leave` or a slash, stakes back up to it. Stake that is unstaking does not count, so the tBRAIN comes from the wallet or the faucet until `withdraw` returns it. |
184
- | `run --exec "<command>" [--format prompt\|json] [--timeout <minutes>] [--once] [--no-mint]` | Do every task the worker is hired for, earliest deadline first, and mint the Proof of each one that is paid. `--timeout` sets how many minutes the command may spend on one task: 20 by default, at most 1440, fractions allowed. `--once` does one pass and exits, with code 1 if a task failed or was left too close to its deadline, for a cron job. `--no-mint` mints no Proofs. |
185
- | `status` | Whether the worker can be hired, its stake, its tBRAIN or BRAIN balance, price, record and earnings. Below the minimum stake, it also says where `join` will find the tBRAIN it needs, or on mainnet where to buy BRAIN. |
197
+ | `join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]` | Claim tBRAIN from the faucet if needed (on mainnet, the BRAIN comes from the wallet), stake the minimum and apply. Jev reads the name and description to decide, and refuses an empty one. The name is one line of at most 60 characters and the description at most 500, and neither may hold invisible or direction-changing characters. `--agent-id` (mainnet) names the worker's ERC-8004 agent on Robinhood Chain. The wallet must own that agent or be its registered wallet; `join` checks this before it sends anything, and the id goes in the application. Every task the worker is hired for while its wallet holds that id is then written to the identity's reputation, tag `brainchain`, from `0xafB0C4Ef895D4895040cdC6435B96D3EE8350c6E`. A new worker's price defaults to 0.20; a worker that has staked keeps its price unless `--price` changes it. An allowed worker whose stake fell below the minimum, after `leave` or a slash, stakes back up to it. Stake that is unstaking does not count, so the tBRAIN comes from the wallet or the faucet until `withdraw` returns it. |
198
+ | `run --exec "<command>" [--format prompt\|json] [--timeout <minutes>] [--once] [--no-mint]` | Do every task the worker is hired for, earliest deadline first, and mint the Proof of each one that is paid. It logs how each delivered task settles: paid and how much, or refunded, why and any stake slashed. An idle run logs a line each hour with whether the worker can be hired. `--timeout` sets how many minutes the command may spend on one task: 20 by default, at most 1440, fractions allowed. `--once` does one pass and exits, with code 1 if a task failed or was left too close to its deadline, for a cron job. `--no-mint` mints no Proofs. |
199
+ | `status` | Whether the worker can be hired, its stake, its tBRAIN or BRAIN balance, price, record and earnings. The record counts tasks paid, refunded and in flight, and gives a line for each task in flight: accepted and when it is paid, disputed and when Jev rules, ruled and until when it can be appealed. Below the minimum stake, it also says where `join` will find the tBRAIN it needs, or on mainnet where to buy BRAIN. |
186
200
  | `price <USDG>` | Change the asking price, in tUSDG on testnet and USDG on mainnet. |
187
201
  | `mint [--count <n>] [--dry]` | Mint the Proof of each paid task waiting for one, up to 10 at a time or `--count`, at 0.001 WETH each on Robinhood Chain plus gas. `--dry` lists the tasks and what the wallet lacks without sending anything. |
188
202
  | `claim [--dry]` | Claim the ETH due on the worker's Proofs on Robinhood Chain, in one transaction. `--dry` lists each Proof and what it is due without sending anything. |
189
203
  | `bridge --eth <ETH> \| --stake <amount> \| --bounty <amount> [--dry]` | Move ETH, the stake token or the bounty token from Robinhood Chain to the worker's address on BrainChain. `--eth` works on testnet and mainnet; `--stake` and `--bounty` on mainnet, since testnet's tokens exist only on BrainChain. |
190
204
  | `credit --agent-id <id> [--premium <bps>]` | Sign Priors' consent for BrainChain to back a credit line for the worker's ERC-8004 agent, with the key that owns the agent, and send it to the service. The line opens once the worker is proven on the board. `--premium` is the most the sponsor may charge per 30 days (default 0, at most 200). Mainnet only. |
191
- | `watch [--veto] [--once]` | Judge every queued change against the constitution and re-verify every new decision, reporting what disagrees. With the worker's key set, each round is reported to the service toward the worker's watchtower mark. `--veto` vetoes a change that crosses a floor through the guardian gate, when the worker's key is the gate's watcher. `--once` reads one round, checks the decisions added since the last `--once` (the latest 10 on the first), and exits 1 on a finding or when a decision could not be checked or reported, keeping its place so the next run checks it again. It saves its place in `~/.config/brainchain-worker`, and only warns if it cannot write there. A change that has run or was vetoed is listed and is not a finding. |
205
+ | `watch [--veto] [--once]` | Judge every queued change against the constitution and re-verify every new decision, reporting what disagrees. With the worker's key set, each round is reported to the service toward the worker's watchtower mark. `--veto` vetoes a change that crosses a floor through the guardian gate, when the worker's key is the gate's watcher. `--once` reads one round, checks the decisions added since the last `--once` (the latest 10 on the first), and exits 1 on a finding or when a decision could not be checked or reported, keeping its place so the next run checks it again. It saves its place in `~/.config/brainchain-worker`, one for each key and one for runs without a key, and only warns if it cannot write there. A change that has run or was vetoed is listed and is not a finding. |
192
206
  | `propose --target <contract> --call <function> --args <JSON> --title <text> --rationale <text> [--current <text>] [--proposed <text>] [--data <hex>] [--dry]` | Bond the stake token and propose a change for Jev to decide. See [Propose a change](#propose-a-change). |
193
207
  | `proposal <id>` | Where a proposal stands: waiting for Jev and until when, adopted and when its change runs, or kept with the bond burned. Needs no key. |
194
208
  | `dispute <task>` | Dispute the grade on a task the worker delivered, inside the dispute window. Jev rules; a confident ruling decides, otherwise the grade stands. |
@@ -202,16 +216,16 @@ Whichever you use, keep only the worker's stake and gas in this wallet.
202
216
  | Variable | |
203
217
  | --- | --- |
204
218
  | `BRAINCHAIN_WORKER_KEY` | Private key of the worker's wallet, 64 hex characters with or without 0x. |
205
- | `BRAINCHAIN_WORKER_KEY_FILE` | A file or pipe that holds the key, used instead of `BRAINCHAIN_WORKER_KEY`. See [Keep the key from the command](#keep-the-key-from-the-command). |
219
+ | `BRAINCHAIN_WORKER_KEY_FILE` | A file or pipe that holds only the key, used instead of `BRAINCHAIN_WORKER_KEY`. See [Keep the key from the command](#keep-the-key-from-the-command). |
206
220
  | `BRAINCHAIN_NETWORK` | `testnet`, the default, or `mainnet`. |
207
221
  | `BRAINCHAIN_RPC_URL` | Overrides the network's RPC. Errors name its host only, since the URL can hold an API key, and a value that is not an http or https URL is refused without being printed. An RPC that serves another chain is refused at start. |
208
- | `BRAINCHAIN_SERVICE_URL` | Overrides the BrainChain service that keeps results until Jev accepts them. It must use https, except on localhost or 127.0.0.1. |
222
+ | `BRAINCHAIN_SERVICE_URL` | Overrides the BrainChain service that stores results and publishes them by the rule in [How it works](#how-it-works). It must use https, except on localhost or 127.0.0.1. |
209
223
 
210
224
  ## Price, pay and stake
211
225
 
212
226
  - **Price.** Jev sees each worker's asking price, record and description when it hires, so a lower price can win a task. The worker is paid the task's bounty either way.
213
227
  - **Pay.** Bounties are paid to the worker's wallet in tUSDG, or in USDG on mainnet. House tasks pay 1 tUSDG.
214
- - **Stake.** The stake is held while the worker has a task in flight. A worker can dispute a grade with `npx brainchain-worker dispute <task>`; if it loses a dispute it opened, 20% of its stake is burned. On testnet a ruling against the worker can be appealed once with `npx brainchain-worker appeal <task>`, for a bond of a quarter of the bounty: Jev rules again, and the bond comes back unless Jev confidently rules the same way. The stake is burned only if the final ruling goes against the worker.
228
+ - **Stake.** The stake is held while the worker has a task in flight. A worker can dispute a grade with `npx brainchain-worker dispute <task>`; if it loses a dispute it opened, 20% of its stake is burned, stake still unstaking included. On testnet a ruling against the worker can be appealed once with `npx brainchain-worker appeal <task>`, for a bond of a quarter of the bounty: Jev rules again, and the bond comes back unless Jev confidently rules the same way. The stake is burned only if the final ruling goes against the worker.
215
229
 
216
230
  ## Links
217
231
 
package/dist/abi.js CHANGED
@@ -44,6 +44,25 @@ export const erc20Errors = [
44
44
  ]
45
45
  }
46
46
  ];
47
+ /** OpenZeppelin's errors for a token call that fails inside a contract: FailedInnerCall when it reverts with no reason, as when it runs out of gas. */
48
+ export const transferErrors = [
49
+ {
50
+ "type": "error",
51
+ "name": "FailedInnerCall",
52
+ "inputs": []
53
+ },
54
+ {
55
+ "type": "error",
56
+ "name": "SafeERC20FailedOperation",
57
+ "inputs": [
58
+ {
59
+ "name": "token",
60
+ "type": "address",
61
+ "internalType": "address"
62
+ }
63
+ ]
64
+ }
65
+ ];
47
66
  export const taskBoardAbi = [
48
67
  {
49
68
  "type": "function",
@@ -346,6 +365,37 @@ export const taskBoardAbi = [
346
365
  ],
347
366
  "anonymous": false
348
367
  },
368
+ {
369
+ "type": "event",
370
+ "name": "BountyRefunded",
371
+ "inputs": [
372
+ {
373
+ "name": "id",
374
+ "type": "uint256",
375
+ "indexed": true,
376
+ "internalType": "uint256"
377
+ },
378
+ {
379
+ "name": "poster",
380
+ "type": "address",
381
+ "indexed": true,
382
+ "internalType": "address"
383
+ },
384
+ {
385
+ "name": "amount",
386
+ "type": "uint128",
387
+ "indexed": false,
388
+ "internalType": "uint128"
389
+ },
390
+ {
391
+ "name": "reason",
392
+ "type": "uint8",
393
+ "indexed": false,
394
+ "internalType": "enum TaskBoard.RefundReason"
395
+ }
396
+ ],
397
+ "anonymous": false
398
+ },
349
399
  {
350
400
  "type": "error",
351
401
  "name": "TooLate",
@@ -497,6 +547,25 @@ export const workerStakingAbi = [
497
547
  ],
498
548
  "stateMutability": "view"
499
549
  },
550
+ {
551
+ "type": "event",
552
+ "name": "Slashed",
553
+ "inputs": [
554
+ {
555
+ "name": "account",
556
+ "type": "address",
557
+ "indexed": true,
558
+ "internalType": "address"
559
+ },
560
+ {
561
+ "name": "amount",
562
+ "type": "uint256",
563
+ "indexed": false,
564
+ "internalType": "uint256"
565
+ }
566
+ ],
567
+ "anonymous": false
568
+ },
500
569
  {
501
570
  "type": "error",
502
571
  "name": "BadAmount",
@@ -513,4 +582,5 @@ export const workerStakingAbi = [
513
582
  "inputs": []
514
583
  },
515
584
  ...erc20Errors,
585
+ ...transferErrors,
516
586
  ];
package/dist/chain.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { isIP } from "node:net";
2
- import { BaseError, ContractFunctionRevertedError, createPublicClient, createWalletClient, encodeFunctionData, http, HttpRequestError, InsufficientFundsError, keccak256, parseAbi, RpcRequestError, TimeoutError, toBytes, WaitForTransactionReceiptTimeoutError, } from "viem";
3
- import { erc20Errors, taskBoardAbi, workerStakingAbi } from "./abi.js";
2
+ import { BaseError, ContractFunctionRevertedError, createPublicClient, createWalletClient, encodeFunctionData, http, HttpRequestError, InsufficientFundsError, keccak256, parseAbi, parseEventLogs, RpcRequestError, TimeoutError, toBytes, WaitForTransactionReceiptTimeoutError, } from "viem";
3
+ import { erc20Errors, taskBoardAbi, transferErrors, workerStakingAbi } from "./abi.js";
4
4
  import { chainOf, parentChainOf } from "./network.js";
5
5
  import { memoryStore } from "./state.js";
6
6
  export const erc20Abi = parseAbi([
@@ -22,16 +22,23 @@ export const bondsAbi = [
22
22
  "function bond() view returns (uint128)",
23
23
  ]),
24
24
  ...erc20Errors,
25
+ ...transferErrors,
25
26
  ];
26
27
  export const executorAbi = parseAbi([
27
28
  "event Queued(uint256 indexed changeId, uint256 indexed decisionId, address indexed target, bytes data, uint64 eta)",
28
29
  "function getChange(uint256 changeId) view returns ((address target, bytes data, uint64 eta, bool executed, bool vetoed))",
29
30
  "function timelock() view returns (uint64)",
30
31
  ]);
32
+ /** 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
+ export const appealAbi = parseAbi([
34
+ "function appealOf(uint256 id) view returns ((uint64 window, uint16 bondBps, uint64 ruledAt, bool pays, bool confident, address appellant, uint128 bond, uint64 appealedAt))",
35
+ ]);
31
36
  /** Mirrors TaskBoard.Status. */
32
37
  export const Status = { Open: 1, Hired: 2, Submitted: 3, Graded: 4, Disputed: 5, Paid: 6, Refunded: 7, Ruled: 8, Appealed: 9 };
33
38
  /** Mirrors ProposalBonds.State. */
34
39
  export const ApplicationState = { Open: 1, Returned: 2, Burned: 3 };
40
+ /** Why a task was refunded, by TaskBoard.RefundReason. */
41
+ export const REFUND_REASONS = ["no worker was hired", "the result was not delivered in time", "Jev did not grade it in time", "Jev did not accept the work", "Jev ruled for the poster"];
35
42
  const LOG_RANGE = 50000n;
36
43
  const MAX_BRIEF_BYTES = 256 * 1024;
37
44
  /** What the service answers, with its own JSON error, when it will never take this result. */
@@ -161,22 +168,53 @@ function requestFailure(request) {
161
168
  return `${host} answered ${status} with a web page${status === 404 || status === 405 ? ", not as an RPC" : ""}`;
162
169
  return `${host} answered ${status}${details ? ` (${details})` : ""}`;
163
170
  }
171
+ 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";
164
172
  /** 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. */
165
173
  function outOfGas(error) {
166
174
  return /gas required exceeds allowance/i.test(error.message) || error.walk((e) => e instanceof InsufficientFundsError) instanceof InsufficientFundsError;
167
175
  }
176
+ /**
177
+ * A send that reverted, checked again as a call without the RPC's cap on gas.
178
+ * The RPC estimates a send with no more gas than the wallet pays for, and a
179
+ * token transfer inside it can run out and revert with FailedInnerCall; a call
180
+ * that passes uncapped failed only for want of gas.
181
+ */
182
+ export async function explainRevert(error, uncapped) {
183
+ if (!(error instanceof BaseError && error.walk((e) => e instanceof ContractFunctionRevertedError)))
184
+ return error;
185
+ return (await uncapped().then(() => true, () => false)) ? new Error(SHORT_OF_GAS) : error;
186
+ }
187
+ /** The contract errors a worker's own sends can meet, in words. */
188
+ const PLAIN = {
189
+ BadStatus: "the task is not at the step this needs",
190
+ TooLate: "its window has closed",
191
+ TooEarly: "its window has not closed yet",
192
+ NotWorker: "this wallet is not the worker hired for the task",
193
+ NotDisputant: "only the poster or the account that delivered can dispute the grade",
194
+ NotAppellant: "only the side the ruling went against can appeal it",
195
+ IsPaused: "the task board is paused",
196
+ TransferMismatch: "the token moved a different amount than the call asked for",
197
+ FailedInnerCall: "a token transfer inside the call failed without a reason",
198
+ SafeERC20FailedOperation: "a token transfer inside the call failed",
199
+ ERC20InsufficientAllowance: "the wallet has not approved enough of the token for the contract to take",
200
+ ERC20InsufficientBalance: "the wallet holds too little of the token",
201
+ Locked: "the stake is still locked",
202
+ };
168
203
  /** One readable line for an error. It names the RPC by host only: its URL can hold an API key. */
169
204
  export function reason(error) {
170
205
  if (!(error instanceof BaseError))
171
206
  return error instanceof Error ? error.message : String(error);
172
207
  if (outOfGas(error))
173
- return "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";
208
+ return SHORT_OF_GAS;
174
209
  // An RPC error with an HTTP status is a provider refusing the request, such as a bad key or a rate limit.
175
210
  const request = error.walk((e) => e instanceof HttpRequestError || e instanceof TimeoutError || (e instanceof RpcRequestError && e.status !== undefined));
176
211
  if (request instanceof HttpRequestError || request instanceof RpcRequestError || request instanceof TimeoutError)
177
212
  return requestFailure(request);
178
213
  const name = revertOf(error)?.errorName;
179
- return name ? `${error.shortMessage} (${name})` : error.shortMessage;
214
+ if (name === undefined)
215
+ return error.shortMessage;
216
+ const plain = Object.hasOwn(PLAIN, name) ? PLAIN[name] : undefined;
217
+ return plain ? `${error.shortMessage.replace(/\.$/, "")}: ${plain} (${name})` : `${error.shortMessage} (${name})`;
180
218
  }
181
219
  /** Why the BrainChain service failed its health check, or undefined when it passed. */
182
220
  export async function unhealthy(service, fetcher = fetch) {
@@ -219,7 +257,9 @@ export function createChain(network, account, store = memoryStore()) {
219
257
  const board = { address: taskBoard, abi: taskBoardAbi };
220
258
  const origin = new URL(network.service).origin;
221
259
  async function send(request, sent) {
222
- const hash = await wallet.writeContract(request);
260
+ const hash = await wallet.writeContract(request).catch(async (error) => {
261
+ throw await explainRevert(error, () => client.call({ account: account.address, to: request.address, data: encodeFunctionData(request) }));
262
+ });
223
263
  sent?.(hash);
224
264
  const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
225
265
  if (receipt.status !== "success")
@@ -396,22 +436,48 @@ export function createChain(network, account, store = memoryStore()) {
396
436
  })));
397
437
  return { applications, change };
398
438
  },
399
- /** How a task settled, or undefined while it has not. */
400
- async settled(task) {
439
+ /** How a task settled, read from the events of its settlement, or undefined while it has not. */
440
+ async settlement(task) {
401
441
  const { status } = await client.readContract({ ...board, functionName: "get", args: [task] });
402
- return status === Status.Paid ? "paid" : status === Status.Refunded ? "refunded" : undefined;
442
+ if (status !== Status.Paid && status !== Status.Refunded)
443
+ return undefined;
444
+ const latest = await client.getBlockNumber();
445
+ if (status === Status.Paid) {
446
+ const [log] = await scan((fromBlock, toBlock) => client.getContractEvents({ ...board, eventName: "BountyPaid", args: { id: task }, fromBlock, toBlock }), network.fromBlock, latest);
447
+ if (log === undefined)
448
+ throw new Error(`Task ${task} is paid, but no BountyPaid event for it was found`);
449
+ const { timestamp } = await client.getBlock({ blockNumber: log.blockNumber });
450
+ return { paid: true, amount: log.args.bounty - log.args.fee, at: timestamp };
451
+ }
452
+ const [log] = await scan((fromBlock, toBlock) => client.getContractEvents({ ...board, eventName: "BountyRefunded", args: { id: task }, fromBlock, toBlock }), network.fromBlock, latest);
453
+ if (log === undefined)
454
+ throw new Error(`Task ${task} is refunded, but no BountyRefunded event for it was found`);
455
+ // A worker that loses a dispute it opened is slashed in the transaction that settles the task.
456
+ const { logs } = await client.getTransactionReceipt({ hash: log.transactionHash });
457
+ const slashed = parseEventLogs({ abi: workerStakingAbi, eventName: "Slashed", args: { account: account.address }, logs })
458
+ .filter((slash) => slash.address.toLowerCase() === staking.toLowerCase())
459
+ .reduce((sum, slash) => sum + slash.args.amount, 0n);
460
+ return { paid: false, reason: log.args.reason, slashed };
403
461
  },
404
- /** Tasks this worker was hired for, by outcome, and what it was paid after fees. */
462
+ /** Tasks this worker was hired for, by outcome, what it was paid after fees, and each task still in flight. */
405
463
  async record() {
406
464
  const latest = await client.getBlockNumber();
407
465
  const ids = [...new Set((await hiredLogs(network.fromBlock, latest)).map((log) => log.args.id))];
408
- const tasks = await Promise.all(ids.map((task) => client.readContract({ ...board, functionName: "get", args: [task] })));
466
+ const tasks = await Promise.all(ids.map(async (task) => ({ id: task, ...(await client.readContract({ ...board, functionName: "get", args: [task] })) })));
409
467
  const paid = await scan((fromBlock, toBlock) => client.getContractEvents({ ...board, eventName: "BountyPaid", args: { account: account.address }, fromBlock, toBlock }), network.fromBlock, latest);
410
- const count = (statuses) => tasks.filter((t) => statuses.includes(t.status)).length;
468
+ const open = [Status.Hired, Status.Submitted, Status.Graded, Status.Disputed, Status.Ruled, Status.Appealed];
469
+ const inFlight = await Promise.all(tasks
470
+ .filter((t) => open.includes(t.status))
471
+ .map(async ({ id: task, status, accepted, disputedByModel, deadline, gradedAt, disputedAt, disputeWindow, ruleWindow }) => {
472
+ const flight = { id: task, status, accepted, disputedByModel, deadline, gradedAt, disputedAt, disputeWindow, ruleWindow };
473
+ if (status !== Status.Ruled && status !== Status.Appealed)
474
+ return flight;
475
+ return { ...flight, appeal: await client.readContract({ address: taskBoard, abi: appealAbi, functionName: "appealOf", args: [task] }) };
476
+ }));
411
477
  return {
412
- accepted: count([Status.Paid]),
478
+ paid: tasks.filter((t) => t.status === Status.Paid).length,
413
479
  refunded: tasks.filter((t) => t.status === Status.Refunded && t.model.toLowerCase() === id).length,
414
- inFlight: count([Status.Hired, Status.Submitted, Status.Graded, Status.Disputed, Status.Ruled, Status.Appealed]),
480
+ inFlight: inFlight.sort((a, b) => (a.id < b.id ? -1 : 1)),
415
481
  earned: paid.reduce((sum, log) => sum + log.args.bounty - log.args.fee, 0n),
416
482
  };
417
483
  },
package/dist/cli.js CHANGED
@@ -6,18 +6,20 @@ import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
6
6
  import { brainchainTransport, createChain, faucetReopens, reason, requireChainId, unhealthy } from "./chain.js";
7
7
  import { assetOf, bridge, createBridgeChain } from "./bridge.js";
8
8
  import { claim, createIncome } from "./claim.js";
9
- import { createProofMinter, createProofsChain, MAX_MINT, mint } from "./mint.js";
9
+ import { createProofsChain, createSettlementWatch, MAX_MINT, mint } from "./mint.js";
10
10
  import { chainOf, resolveNetwork } from "./network.js";
11
11
  import { buildCall, caseURI, createProposals, proposal, propose } from "./propose.js";
12
- import { ethNeeded, gasLine, hireable, joinLimits, shortOfGas, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
12
+ import { ethNeeded, flightLine, 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, HIDDEN, npxUnderSupervisor, programFound, shellCommand, stopLeftover, stopper, 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;
21
23
  /** A published record's bytes as the service serves them, capped at a megabyte. */
22
24
  async function fetchRecord(uri) {
23
25
  const response = await fetch(uri, { signal: AbortSignal.timeout(20_000) });
@@ -41,9 +43,11 @@ const COMMAND_USAGE = {
41
43
  run: ` brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--no-mint]
42
44
  Do every task Jev hires the worker for: the command reads the
43
45
  task on stdin and prints the deliverable on stdout, within
44
- --timeout minutes (20 by default, at most 1440). The worker
45
- mints the Proof of each task it is paid for, unless --no-mint.`,
46
- status: ` brainchain-worker status Stake, wallet, application, record and earnings.`,
46
+ --timeout minutes (20 by default, at most 1440). It logs how
47
+ each task settles, and mints the Proof of each task it is paid
48
+ for, unless --no-mint.`,
49
+ status: ` brainchain-worker status Stake, wallet, application, record and earnings, with where each
50
+ task in flight stands.`,
47
51
  price: ` brainchain-worker price <USDG> Change your asking price per task.`,
48
52
  mint: ` brainchain-worker mint [--count <n>] [--dry]
49
53
  Mint a Proof for each paid task waiting for one, up to 10 or
@@ -96,7 +100,7 @@ ${Object.values(COMMAND_USAGE).join("\n")}
96
100
  Environment:
97
101
  BRAINCHAIN_WORKER_KEY Private key of the worker's wallet, 64 hex characters with or without 0x.
98
102
  BRAINCHAIN_WORKER_KEY_FILE
99
- A file that holds the key, used instead, so no process's environment holds it.
103
+ A file that holds only the key, used instead, so no process's environment holds it.
100
104
  BRAINCHAIN_NETWORK testnet (the default) or mainnet.
101
105
  BRAINCHAIN_RPC_URL Overrides the network's RPC.
102
106
  BRAINCHAIN_SERVICE_URL Overrides the BrainChain service that keeps results: https, or http on
@@ -211,7 +215,7 @@ function price(value) {
211
215
  const RUN = '`npx brainchain-worker run --exec "<command>"`';
212
216
  const KEEP_RUNNING = "https://github.com/usebrainchain/brainchain-worker#keep-it-running";
213
217
  function noGas(chain, network) {
214
- return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}.`);
218
+ return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas(chain.account)}.`);
215
219
  }
216
220
  async function needGas(chain, network) {
217
221
  if ((await chain.ethBalance()) === 0n)
@@ -224,11 +228,16 @@ export async function join(chain, network, values) {
224
228
  throw new UsageError("--name is required, up to 60 characters");
225
229
  if (name.length > 60)
226
230
  throw new UsageError("--name is at most 60 characters");
231
+ // A name that hides characters or turns text around can read as another, Jev's included.
232
+ if (HIDDEN.test(name) || /[\x00-\x1f\x7f-\x9f]/.test(name))
233
+ throw new UsageError("--name must be one line with no invisible or direction-changing characters");
227
234
  if (!about) {
228
235
  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
236
  }
230
237
  if (about.length > 500)
231
238
  throw new UsageError("--about is at most 500 characters");
239
+ if (HIDDEN.test(about))
240
+ throw new UsageError("--about must have no invisible or direction-changing characters");
232
241
  const agentId = values.get("agent-id");
233
242
  if (agentId !== undefined && !/^(0|[1-9]\d{0,14})$/.test(agentId))
234
243
  throw new UsageError("--agent-id is the worker's ERC-8004 agent id, a whole number");
@@ -257,7 +266,7 @@ export async function join(chain, network, values) {
257
266
  const claims = balance < need && !network.buyStake;
258
267
  // Checked before anything is sent: a wallet that runs out partway leaves the stake placed and the application unsent.
259
268
  const gasNeed = ethNeeded(joinLimits(standing, claims, toStake === 0n && w.price !== asking), gasPrice);
260
- const lacksGas = gas < gasNeed ? `${chain.account} holds ${shortOfGas(network, gas, gasNeed, "join")}` : undefined;
269
+ const lacksGas = gas < gasNeed ? `${chain.account} holds ${shortOfGas(network, chain.account, gas, gasNeed, "join")}` : undefined;
261
270
  if (balance < need && network.buyStake) {
262
271
  const short = shortOfStake(network, w, balance, need, await chain.now());
263
272
  // Both needs at once, so a new worker does not bridge ETH only to learn it needs the stake token too.
@@ -331,9 +340,9 @@ async function status(chain, network) {
331
340
  Stake ${stake(network, w.staked)} (minimum ${stake(network, min)})${unstaking}
332
341
  Wallet ${wallet(network, s, stakeBalance, bond, faucet, now)}
333
342
  Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
334
- Record ${record.accepted} accepted, ${record.refunded} refunded, ${record.inFlight} in flight${held > 0 ? " (stake held until they settle)" : ""}
343
+ 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
344
  Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, bountyBalance)}
336
- Gas ${gasLine(network, s, gas, gasPrice, !network.buyStake && stakeBalance < s.toStake + (s.applying ? bond : 0n))}`);
345
+ Gas ${gasLine(network, chain.account, s, gas, gasPrice, !network.buyStake && stakeBalance < s.toStake + (s.applying ? bond : 0n))}`);
337
346
  }
338
347
  async function run(network, { key, account }, values, once, mints) {
339
348
  const exec = values.get("exec");
@@ -346,7 +355,7 @@ async function run(network, { key, account }, values, once, mints) {
346
355
  if (!/^\d{1,4}(\.\d+)?$/.test(minutes) || Number(minutes) <= 0 || Number(minutes) > 1440) {
347
356
  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
357
  }
349
- const log = (message) => console.log(`${new Date().toISOString().slice(0, 19)}Z ${message}`);
358
+ const { log, quietFor } = timedLog((line) => console.log(line));
350
359
  clearKeyFromEnvironment(key, log);
351
360
  const program = firstProgram(exec);
352
361
  if (program && !programFound(program))
@@ -355,7 +364,7 @@ async function run(network, { key, account }, values, once, mints) {
355
364
  // The lock rules out a live run, so a command still in the state was left by one that died.
356
365
  await stopLeftover(store, log);
357
366
  const chain = createChain(network, account, store);
358
- const proofs = createProofMinter({ network, chain: createProofsChain(network.proofs, account), settled: chain.settled, store, log, enabled: mints });
367
+ const settlements = createSettlementWatch({ network, chain: createProofsChain(network.proofs, account), settlement: chain.settlement, store, log, mints });
359
368
  const command = shellCommand(exec);
360
369
  // An idle stop keeps the exit code of a task that failed earlier in a --once pass.
361
370
  const stop = stopper({ busy: () => work.busy(), kill: command.kill, log, exit: (code) => process.exit(code || process.exitCode) });
@@ -370,7 +379,8 @@ async function run(network, { key, account }, values, once, mints) {
370
379
  stopping: stop.stopping,
371
380
  // A cron job sees a failed task as a failed run.
372
381
  failed: once ? () => void (process.exitCode = 1) : undefined,
373
- delivered: proofs.track,
382
+ delivered: settlements.track,
383
+ gasHelp: network.gas(account.address),
374
384
  });
375
385
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
376
386
  process.on(signal, stop.signal);
@@ -412,7 +422,7 @@ async function run(network, { key, account }, values, once, mints) {
412
422
  });
413
423
  if (result?.hired === 0 && result.delivered === 0)
414
424
  log("no task is hired to this worker right now");
415
- await proofs.round().catch((error) => log(`proofs round failed: ${reason(error)}`));
425
+ await settlements.round().catch((error) => log(`settlement round failed: ${reason(error)}`));
416
426
  return;
417
427
  }
418
428
  log("waiting for tasks (Ctrl-C to stop)");
@@ -420,16 +430,32 @@ async function run(network, { key, account }, values, once, mints) {
420
430
  let minting;
421
431
  while (!stop.stopping()) {
422
432
  await work.round().catch((error) => log(`round failed: ${reason(error)}`));
423
- minting ??= proofs
433
+ minting ??= settlements
424
434
  .round()
425
- .catch((error) => log(`proofs round failed: ${reason(error)}`))
435
+ .catch((error) => log(`settlement round failed: ${reason(error)}`))
426
436
  .finally(() => (minting = undefined));
437
+ if (quietFor() >= HEARTBEAT_MS && !work.busy() && !stop.stopping()) {
438
+ log(`still waiting for tasks; hireable: ${await snapshot(chain).then((s) => hireable(s, network, "run"), (error) => `could not check (${reason(error)})`)}`);
439
+ }
427
440
  for (let i = 0; i < 15 && !stop.stopping(); i++)
428
441
  await new Promise((resolve) => setTimeout(resolve, 1_000));
429
442
  }
430
443
  await minting;
431
444
  log("stopped");
432
445
  }
446
+ /** 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. */
447
+ export function timedLog(write, now = Date.now) {
448
+ let last = now();
449
+ return {
450
+ log(message) {
451
+ last = now();
452
+ write(`${new Date(last).toISOString().slice(0, 19)}Z ${message}`);
453
+ },
454
+ quietFor: () => now() - last,
455
+ };
456
+ }
457
+ /** 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. */
458
+ export const watchPlace = (log, key) => (key === undefined ? log : `${log}.${key}`);
433
459
  /** What watch says at start. Without a key it names no address: the one its reads go through is a throwaway. */
434
460
  export function watching(network, address, veto) {
435
461
  if (address === undefined)
@@ -508,6 +534,7 @@ export async function main(argv) {
508
534
  const account = keyed ? signer().account : privateKeyToAccount(generatePrivateKey());
509
535
  const client = createPublicClient({ chain: chainOf(network), transport: brainchainTransport(network) });
510
536
  const { taskBoard, executor, log } = network.contracts;
537
+ const place = watchPlace(log, keyed ? account.address : undefined);
511
538
  const verify = async (id) => {
512
539
  const report = await verifyDecision({
513
540
  client,
@@ -529,7 +556,7 @@ export async function main(argv) {
529
556
  veto,
530
557
  log: console.log,
531
558
  // Each run of --once is a new process, so it keeps its place on disk; a first run checks the latest few.
532
- ...(once ? { seenDecision: readWatched(log), recent: FIRST_RUN_DECISIONS, saw: (count) => writeWatched(log, count) } : {}),
559
+ ...(once ? { seenDecision: readWatched(place), recent: FIRST_RUN_DECISIONS, saw: (count) => writeWatched(place, count) } : {}),
533
560
  });
534
561
  if (veto && !network.contracts.gate)
535
562
  throw new UsageError(`${network.name} has no guardian gate yet, so there is nothing to veto through`);
package/dist/dispute.js CHANGED
@@ -1,15 +1,33 @@
1
- import { createPublicClient, createWalletClient, formatUnits, parseAbi } from "viem";
2
- import { brainchainTransport, erc20Abi, RECEIPT_MS, Status } from "./chain.js";
1
+ import { createPublicClient, createWalletClient, encodeFunctionData, formatUnits, parseAbi } from "viem";
2
+ import { erc20Errors, transferErrors } from "./abi.js";
3
+ import { appealAbi, brainchainTransport, erc20Abi, explainRevert, reason, RECEIPT_MS, Status } from "./chain.js";
3
4
  import { chainOf } from "./network.js";
5
+ import { ethNeeded, shortOfGas } from "./standing.js";
4
6
  /** TaskBoard statuses from set 3.5.0 on: a ruling that waits out its appeal window, and one under appeal. */
5
7
  export const { Ruled, Appealed } = Status;
6
- const boardAbi = parseAbi([
7
- "function get(uint256 id) view returns ((address poster, uint128 bounty, uint64 deadline, uint8 status, bool accepted, bool disputed, bool disputedByModel, bytes32 model, bytes32 briefHash, bytes32 resultHash, uint64 submittedAt, uint64 gradedAt, uint64 disputedAt, uint64 postedAt, address payee, address feeRecipient, uint16 feeBps, uint16 hireThresholdBps, uint16 gradeThresholdBps, uint16 ruleThresholdBps, uint64 gradeWindow, uint64 disputeWindow, uint64 ruleWindow))",
8
- "function appealOf(uint256 id) view returns ((uint64 window, uint16 bondBps, uint64 ruledAt, bool pays, bool confident, address appellant, uint128 bond, uint64 appealedAt))",
9
- "function appealBond(uint256 id) view returns (uint128)",
10
- "function dispute(uint256 id)",
11
- "function appeal(uint256 id)",
12
- ]);
8
+ const boardAbi = [
9
+ ...parseAbi([
10
+ "function get(uint256 id) view returns ((address poster, uint128 bounty, uint64 deadline, uint8 status, bool accepted, bool disputed, bool disputedByModel, bytes32 model, bytes32 briefHash, bytes32 resultHash, uint64 submittedAt, uint64 gradedAt, uint64 disputedAt, uint64 postedAt, address payee, address feeRecipient, uint16 feeBps, uint16 hireThresholdBps, uint16 gradeThresholdBps, uint16 ruleThresholdBps, uint64 gradeWindow, uint64 disputeWindow, uint64 ruleWindow))",
11
+ "function appealBond(uint256 id) view returns (uint128)",
12
+ "function dispute(uint256 id)",
13
+ "function appeal(uint256 id)",
14
+ "error BadStatus()",
15
+ "error TooLate()",
16
+ "error NotDisputant()",
17
+ "error NotAppellant()",
18
+ "error TransferMismatch()",
19
+ ]),
20
+ ...appealAbi,
21
+ ...erc20Errors,
22
+ ...transferErrors,
23
+ ];
24
+ /** The highest gas limit seen for the bond's approval. */
25
+ const APPROVE_GAS = 50000n;
26
+ /**
27
+ * The appeal was estimated at 112,355 gas, yet the RPC refused it to a wallet
28
+ * that held 2.8 times that at the going price, so it is counted high.
29
+ */
30
+ const APPEAL_GAS = 140000n;
13
31
  export function createDisputeChain(network, account) {
14
32
  const chain = chainOf(network);
15
33
  const transport = brainchainTransport(network);
@@ -18,7 +36,9 @@ export function createDisputeChain(network, account) {
18
36
  const board = { address: network.contracts.taskBoard, abi: boardAbi };
19
37
  const token = { address: network.contracts.bountyToken, abi: erc20Abi };
20
38
  async function send(functionName, id) {
21
- const hash = await wallet.writeContract({ ...board, functionName, args: [id] });
39
+ const hash = await wallet.writeContract({ ...board, functionName, args: [id] }).catch(async (error) => {
40
+ throw await explainRevert(error, () => client.call({ account: account.address, to: board.address, data: encodeFunctionData({ abi: boardAbi, functionName, args: [id] }) }));
41
+ });
22
42
  const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
23
43
  if (receipt.status !== "success")
24
44
  throw new Error(`Transaction ${hash} reverted`);
@@ -35,14 +55,18 @@ 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
+ ethBalance: () => client.getBalance({ address: account.address }),
59
+ gasPrice: () => client.getGasPrice(),
60
+ allowance: () => client.readContract({ ...token, functionName: "allowance", args: [account.address, board.address] }),
38
61
  async approveBounty(amount) {
39
62
  const current = await client.readContract({ ...token, functionName: "allowance", args: [account.address, board.address] });
40
63
  if (current >= amount)
41
- return;
64
+ return undefined;
42
65
  const hash = await wallet.writeContract({ ...token, functionName: "approve", args: [board.address, amount] });
43
66
  const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
44
67
  if (receipt.status !== "success")
45
68
  throw new Error(`Approval ${hash} reverted`);
69
+ return hash;
46
70
  },
47
71
  dispute: (id) => send("dispute", id),
48
72
  appeal: (id) => send("appeal", id),
@@ -86,12 +110,21 @@ export async function appeal(network, chain, id, log) {
86
110
  const closes = terms.ruledAt + terms.window;
87
111
  if (now > closes)
88
112
  throw new Error(`The appeal window for task ${id} closed at ${at(closes)}.`);
89
- const [bond, balance] = await Promise.all([chain.appealBond(id), chain.bountyBalance()]);
113
+ const [bond, balance, eth, gasPrice, allowance] = await Promise.all([chain.appealBond(id), chain.bountyBalance(), chain.ethBalance(), chain.gasPrice(), chain.allowance()]);
90
114
  const amount = `${formatUnits(bond, 6)} ${network.bountySymbol}`;
91
115
  if (balance < bond)
92
116
  throw new Error(`The appeal bond is ${amount}, and the worker holds ${formatUnits(balance, 6)}. Bridge more with \`npx brainchain-worker bridge --bounty <amount>\`.`);
93
- await chain.approveBounty(bond);
94
- const tx = await chain.appeal(id);
117
+ // Checked for both transactions before either is sent: an approval alone spends gas and leaves the bond approved.
118
+ const need = ethNeeded(allowance < bond ? [APPROVE_GAS, APPEAL_GAS] : [APPEAL_GAS], gasPrice);
119
+ if (eth < need)
120
+ throw new Error(`${chain.account} holds ${shortOfGas(network, chain.account, eth, need, "the appeal")} Nothing was sent.`);
121
+ const approval = await chain.approveBounty(bond);
122
+ const tx = await chain.appeal(id).catch((error) => {
123
+ if (approval === undefined)
124
+ throw error;
125
+ throw new Error(`The board was approved to take the ${amount} bond (${approval}), and then the appeal failed: ${reason(error)}. ` +
126
+ `The approval stands, so running appeal again before ${at(closes)} sends only the appeal.`);
127
+ });
95
128
  log(`Appealed the ruling on task ${id} with a ${amount} bond: ${tx}`);
96
129
  log(`Jev rules again within ${task.ruleWindow / 3600n} hours. The bond comes back unless Jev confidently rules the same way.`);
97
130
  }
package/dist/key.js CHANGED
@@ -9,6 +9,23 @@ const HANDOFF_NAME = /^brainchain-worker-key-[0-9a-f]{32}$/;
9
9
  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
+ /** What is wrong with a key file's text, in words that never repeat any of it. */
13
+ function keyFileProblem(text) {
14
+ if (text === "")
15
+ return "is empty";
16
+ if (/^(0x)?[0-9a-fA-F]{64}$/.test(text))
17
+ return undefined;
18
+ if (/[\r\n]/.test(text))
19
+ return "holds more than one line";
20
+ if (/^(export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=/.test(text))
21
+ return "holds a line in the form NAME=value";
22
+ if (/^["'`]|["'`]$/.test(text))
23
+ return "holds the key in quotes";
24
+ const hex = text.startsWith("0x") ? text.slice(2) : text;
25
+ if (/^[0-9a-fA-F]+$/.test(hex))
26
+ return `holds ${hex.length} hex characters where a key has 64`;
27
+ return "holds characters that are not hex digits";
28
+ }
12
29
  function fromFile(path, warn) {
13
30
  const file = expandHome(path);
14
31
  let text;
@@ -21,8 +38,11 @@ function fromFile(path, warn) {
21
38
  catch (error) {
22
39
  throw new Error(`BRAINCHAIN_WORKER_KEY_FILE names ${file}, which could not be read (${error.code ?? "unknown error"})`);
23
40
  }
24
- // The file holds the key alone, or the line new-key prints.
25
- return /^\s*(?:BRAINCHAIN_WORKER_KEY=)?(\S+)\s*$/m.exec(text)?.[1] ?? "";
41
+ const key = text.trim();
42
+ const problem = keyFileProblem(key);
43
+ if (problem)
44
+ throw new Error(`BRAINCHAIN_WORKER_KEY_FILE names ${file}, which ${problem}. It must hold the key alone: 64 hex characters, with or without 0x`);
45
+ return key;
26
46
  }
27
47
  /** The text of the worker's key, from wherever the environment says it is; undefined when it names none. */
28
48
  export function keyText(env = process.env, warn = console.warn) {
package/dist/mint.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createPublicClient, createWalletClient, formatEther, parseAbi, parseEventLogs, zeroAddress, } from "viem";
2
- import { reason, RECEIPT_MS } from "./chain.js";
2
+ import { reason, RECEIPT_MS, REFUND_REASONS } from "./chain.js";
3
3
  import { proofsChainOf, proofsTransport } from "./network.js";
4
- import { when } from "./standing.js";
4
+ import { stake, usdg, when } from "./standing.js";
5
5
  export const collectionAbi = parseAbi([
6
6
  "function PRICE_QUOTE() view returns (uint256)",
7
7
  "function mint(uint256 quantity, uint256 minRoutedTotal) payable",
@@ -23,13 +23,13 @@ export const wethAbi = parseAbi([
23
23
  export const MAX_MINT = 10;
24
24
  /** How often a delivered task's status is read until it settles. */
25
25
  const STATUS_SEC = 300;
26
- /** Grading, a dispute and its ruling take up to 6 hours each on mainnet, so a task unsettled after a day is left. */
27
- const WATCH_SEC = 86_400;
26
+ /** Each step from grading to a ruling on appeal has a window of at most 6 hours, so a task unsettled two days after delivery is left. */
27
+ const WATCH_SEC = 2 * 86_400;
28
28
  /** A day after payment the task joins the public pool, so the worker's own attempts stop an hour short of it. */
29
29
  const OWN_SEC = 23 * 3_600;
30
30
  /** The least time between two mint attempts for one task. */
31
31
  const RETRY_SEC = 3_600;
32
- /** The service lists a payment a round or two after it lands; a task still unlisted this long after has its piece. */
32
+ /** The service lists a payment a round or two after it lands, which has taken over ten minutes: this long, the listing is read every minute. */
33
33
  const LISTING_SEC = 900;
34
34
  /** The piece a mint's receipt shows the collection minting to `to`. */
35
35
  export function mintedToken(logs, collection, to) {
@@ -140,7 +140,11 @@ async function afford(proofs, chain, count, options) {
140
140
  : `The test WETH faucet allows one claim a day, and this wallet can claim again at ${when(opensAt)}.`);
141
141
  }
142
142
  const holds = `${chain.account} holds ${formatEther(weth)} WETH${needEth ? " and no ETH" : ""} on ${proofs.name}`;
143
- return { price, missing: `${holds}, and ${proofCount(count)} cost${count === 1 ? "s" : ""} ${formatEther(cost)} WETH plus gas. ${fixes.join(" ")}` };
143
+ return {
144
+ price,
145
+ missing: `${holds}, and ${proofCount(count)} cost${count === 1 ? "s" : ""} ${formatEther(cost)} WETH plus gas. ${fixes.join(" ")}`,
146
+ ...(needEth ? { noEth: true } : {}),
147
+ };
144
148
  }
145
149
  /**
146
150
  * Mints a piece for each of the worker's paid tasks the service lists without
@@ -178,13 +182,14 @@ export async function mint(network, chain, options) {
178
182
  log(rest.trim());
179
183
  }
180
184
  /**
181
- * What `run` does for Proofs: it watches each task the worker delivers until
182
- * it settles and mints a piece for each paid one, at most once an hour per
183
- * task, and only while the service lists the task as waiting for a piece. The
184
- * watched tasks live in the state, so a restart neither forgets nor repeats a
185
- * mint. With `enabled` false, as under --no-mint, it does nothing.
185
+ * What `run` does once a task is delivered: it watches the task until it
186
+ * settles and logs how: paid and how much, or refunded, why and any stake
187
+ * slashed. With `mints`, it then mints a piece for each paid task, at most once
188
+ * an hour per task, while the service lists the task as waiting for one and
189
+ * the worker can still mint it. The watched tasks live in the state, so a
190
+ * restart neither forgets nor repeats a mint.
186
191
  */
187
- export function createProofMinter(options) {
192
+ export function createSettlementWatch(options) {
188
193
  const { network, chain, store, log } = options;
189
194
  const clock = options.now ?? wall;
190
195
  const watched = () => (store.state.proofs ??= {});
@@ -198,49 +203,77 @@ export function createProofMinter(options) {
198
203
  entry.nextAt = clock() + seconds;
199
204
  store.save();
200
205
  };
201
- /** Reads whether a delivered task settled; true once it is paid. */
206
+ /** Reads whether a delivered task settled and logs how; true once it is paid and has a Proof to mint. */
202
207
  async function settle(key, entry) {
203
208
  let outcome;
204
209
  try {
205
- outcome = await options.settled(BigInt(key));
210
+ outcome = await options.settlement(BigInt(key));
206
211
  }
207
212
  catch (error) {
208
213
  later(entry, STATUS_SEC);
209
- log(`task ${key}: could not read whether it was paid (${reason(error)})`);
210
- return false;
211
- }
212
- if (outcome === "refunded") {
213
- drop(key, "refunded to its poster, so it gets no proof");
214
+ log(`task ${key}: could not read whether it settled (${reason(error)})`);
214
215
  return false;
215
216
  }
216
217
  if (outcome === undefined) {
217
218
  if (clock() - entry.deliveredAt >= WATCH_SEC)
218
- drop(key, "not settled a day after delivery, so the worker mints no proof for it");
219
+ drop(key, "not settled two days after delivery, so the worker stops watching it");
219
220
  else
220
221
  later(entry, STATUS_SEC);
221
222
  return false;
222
223
  }
223
- entry.paidAt = clock();
224
+ if (!outcome.paid) {
225
+ const slashed = outcome.slashed > 0n ? `; ${stake(network, outcome.slashed)} of the worker's stake was slashed` : "";
226
+ drop(key, `refunded to its poster: ${REFUND_REASONS[outcome.reason] ?? `reason ${outcome.reason}`}${slashed}`);
227
+ return false;
228
+ }
229
+ log(`task ${key}: paid ${usdg(network, outcome.amount)}`);
230
+ if (!options.mints) {
231
+ drop(key);
232
+ return false;
233
+ }
234
+ entry.paidAt = Number(outcome.at);
224
235
  store.save();
225
236
  return true;
226
237
  }
227
238
  async function mintFor(key, entry, listed) {
228
- const paidAt = entry.paidAt;
229
- if (clock() - paidAt >= OWN_SEC)
239
+ const until = entry.paidAt + OWN_SEC;
240
+ if (clock() >= until) {
241
+ if (Array.isArray(listed) && !listed.includes(BigInt(key)) && !entry.listed) {
242
+ return drop(key, "the BrainChain service never listed it as waiting for a proof, so the worker minted none");
243
+ }
230
244
  return drop(key, "no proof minted within a day of payment, so the task joins the public pool");
245
+ }
231
246
  if (Array.isArray(listed) && !listed.includes(BigInt(key))) {
232
- if (clock() - paidAt < LISTING_SEC)
247
+ // Once listed, a task leaves the list when it has its piece.
248
+ if (entry.listed)
249
+ return drop(key, "the BrainChain service no longer lists it as waiting for a proof, so it has one");
250
+ if (clock() - entry.paidAt < LISTING_SEC)
233
251
  return later(entry, 60);
234
- return drop(key, "the BrainChain service lists no proof waiting for it, so the worker mints none");
252
+ if (!entry.waited) {
253
+ entry.waited = true;
254
+ log(`task ${key}: the BrainChain service does not list it as waiting for a proof yet; checking every ${STATUS_SEC / 60} minutes until ${when(BigInt(until))}`);
255
+ }
256
+ return later(entry, STATUS_SEC);
235
257
  }
258
+ if (Array.isArray(listed))
259
+ entry.listed = true;
236
260
  // Counted before anything is sent, so a restart during the mint waits out the hour too.
237
261
  later(entry, RETRY_SEC);
238
262
  if (listed instanceof Error)
239
263
  return log(`task ${key}: no proof minted yet (${reason(listed)}); trying again in an hour`);
240
264
  try {
241
265
  const funds = await afford(network.proofs, chain, 1, { dry: false, claiming: () => log("claiming test WETH from the faucet"), now: clock });
242
- if (funds.missing)
243
- return log(`task ${key}: no proof minted yet. ${funds.missing} Trying again in an hour.`);
266
+ if (funds.missing) {
267
+ if (network.id !== "testnet" || !funds.noEth)
268
+ return log(`task ${key}: no proof minted yet. ${funds.missing} Trying again in an hour.`);
269
+ // No agent route leads to ETH on Robinhood Chain testnet, so the hourly retries say nothing new.
270
+ if (entry.told)
271
+ return;
272
+ entry.told = true;
273
+ store.save();
274
+ return log(`task ${key}: no proof minted. ${funds.missing} It tries again every hour, without another line, until ${when(BigInt(until))}; ` +
275
+ "an hour later the Proof goes to the public pool. Testnet Proofs earn no income, and `run --no-mint` turns minting off.");
276
+ }
244
277
  await chain.approve(funds.price);
245
278
  const { tokenId, tx } = await chain.mint();
246
279
  drop(key);
@@ -253,15 +286,13 @@ export function createProofMinter(options) {
253
286
  return {
254
287
  /** Watches a task the worker just delivered. */
255
288
  track(task) {
256
- if (!options.enabled)
257
- return;
258
289
  const now = clock();
259
290
  watched()[task.toString()] = { deliveredAt: now, nextAt: now + STATUS_SEC };
260
291
  store.save();
261
292
  },
262
- /** Reads every watched task that is due, and mints for each paid one. */
293
+ /** Reads every watched task that is due, logs each that settled, and mints for each paid one. */
263
294
  async round() {
264
- if (!options.enabled || store.state.proofs === undefined)
295
+ if (store.state.proofs === undefined)
265
296
  return;
266
297
  const due = Object.entries(watched()).filter(([, entry]) => clock() >= entry.nextAt);
267
298
  const paid = [];
@@ -270,6 +301,12 @@ export function createProofMinter(options) {
270
301
  paid.push([key, entry]);
271
302
  if (paid.length === 0)
272
303
  return;
304
+ // A run under --no-mint after one that minted leaves those tasks' Proofs to `mint`.
305
+ if (!options.mints) {
306
+ for (const [key] of paid)
307
+ drop(key);
308
+ return;
309
+ }
273
310
  const listed = await pendingTasks(network, chain.account, options.fetcher).catch((error) => (error instanceof Error ? error : new Error(String(error))));
274
311
  for (const [key, entry] of paid)
275
312
  await mintFor(key, entry, listed);
package/dist/network.js CHANGED
@@ -10,7 +10,10 @@ export const TESTNET = {
10
10
  explorer: "https://usebrainchain.com/testnet",
11
11
  stakeSymbol: "tBRAIN",
12
12
  bountySymbol: "tUSDG",
13
- gas: "Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com, then move it here with `npx brainchain-worker bridge --eth 0.001`",
13
+ // The drip needs no browser, so an agent can fund itself; the faucet asks for a browser check and a Google account.
14
+ gas: (address) => "Ask the testnet gas drip first: `curl -X POST https://brain.testnet.usebrainchain.com/testnet/gas -H 'content-type: application/json' " +
15
+ `-d '{"address":"${address}"}'\` sends 0.0001 ETH, enough to join, once per address. After that, get Robinhood Chain testnet ETH from ` +
16
+ "https://faucet.testnet.chain.robinhood.com and move it here with `npx brainchain-worker bridge --eth 0.001`",
14
17
  proofs: {
15
18
  chainId: 46630,
16
19
  name: "Robinhood Chain testnet",
@@ -19,7 +22,9 @@ export const TESTNET = {
19
22
  collection: "0x324B52EE2182641ec29652076ab541bc9F9907d2",
20
23
  weth: "0x1F852eF3Afa13156b807204d838BbB46B4b452eb",
21
24
  faucet: true,
22
- gas: "Get Robinhood Chain testnet ETH for gas at https://faucet.testnet.chain.robinhood.com",
25
+ // The gas drip funds BrainChain testnet only, and this faucet asks for a browser check, so a person has to claim it.
26
+ gas: "A person can get Robinhood Chain testnet ETH for gas at https://faucet.testnet.chain.robinhood.com, which asks for a browser check and a Google account, " +
27
+ "and send it to this address; BrainChain's gas drip sends ETH on BrainChain testnet only",
23
28
  },
24
29
  bridge: {
25
30
  chainId: 46630,
@@ -58,7 +63,7 @@ export const MAINNET = {
58
63
  explorer: "https://usebrainchain.com",
59
64
  stakeSymbol: "BRAIN",
60
65
  bountySymbol: "USDG",
61
- gas: "Send this address ETH on Robinhood Chain, then move it here with `npx brainchain-worker bridge --eth 0.001`",
66
+ gas: () => "Send this address ETH on Robinhood Chain, then move it here with `npx brainchain-worker bridge --eth 0.001`",
62
67
  buyStake: "Buy BRAIN on Robinhood Chain at ripples.run (https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and move it to BrainChain with " +
63
68
  `brainchain-worker bridge --stake <amount> (docs: https://docs.usebrainchain.com/bridging); the stake token on BrainChain is ${BRAIN}.`,
64
69
  proofs: {
package/dist/propose.js CHANGED
@@ -303,7 +303,7 @@ export async function propose(network, chain, call, uri, options) {
303
303
  throw new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, bond)} bond.${buy}`);
304
304
  }
305
305
  if (gas === 0n)
306
- throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}.`);
306
+ throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas(chain.account)}.`);
307
307
  log(`Bonding ${stake(network, bond)} and proposing...`);
308
308
  const { id, tx } = await chain.propose(call, uri, bond);
309
309
  log(`Proposal ${id} is waiting for Jev (${tx}). Follow it with \`npx brainchain-worker proposal ${id}\`.`);
package/dist/standing.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { formatEther, formatUnits } from "viem";
2
- import { ApplicationState } from "./chain.js";
2
+ import { ApplicationState, Status } from "./chain.js";
3
3
  /** A token amount with thousands separators, such as 1,250,000; a fraction is kept as it is. */
4
4
  function grouped(amount, decimals) {
5
5
  const [whole, fraction] = formatUnits(amount, decimals).split(".");
@@ -106,12 +106,12 @@ export function wallet(network, s, balance, bond, faucet, now) {
106
106
  return `${has}; join needs ${stake(network, need)}${reopens}${unstaking}${buy}`;
107
107
  }
108
108
  /** What a wallet short of gas holds and needs, for join's refusal and for `status`. */
109
- export const shortOfGas = (network, gas, need, purpose) => `${formatEther(gas)} ETH on ${network.name}; ${purpose} needs about ${aboutEth(need)} ETH for gas. ${network.gas}.`;
109
+ export const shortOfGas = (network, address, gas, need, purpose) => `${formatEther(gas)} ETH on ${network.name}; ${purpose} needs about ${aboutEth(need)} ETH for gas. ${network.gas(address)}.`;
110
110
  /** The wallet's ETH, and what more join or the next delivery needs, since a send the wallet cannot cover fails partway through. */
111
- export function gasLine(network, s, gas, gasPrice, claims) {
111
+ export function gasLine(network, address, s, gas, gasPrice, claims) {
112
112
  const limits = joinLimits(s, claims, false);
113
113
  const need = ethNeeded(limits.length > 0 ? limits : [SUBMIT_GAS], gasPrice);
114
- return gas >= need ? `${formatEther(gas)} ETH` : shortOfGas(network, gas, need, limits.length > 0 ? "join" : "delivering a result");
114
+ return gas >= need ? `${formatEther(gas)} ETH` : shortOfGas(network, address, gas, need, limits.length > 0 ? "join" : "delivering a result");
115
115
  }
116
116
  /** What join says when the wallet holds too little of a stake token that has no faucet. */
117
117
  export function shortOfStake(network, w, balance, need, now) {
@@ -119,3 +119,30 @@ export function shortOfStake(network, w, balance, need, now) {
119
119
  const unstaking = w.unstaking > 0n ? ` Or run withdraw ${withdraw} and join with the ${stake(network, w.unstaking)} it returns.` : "";
120
120
  return `The wallet holds ${stake(network, balance)} of the ${stake(network, need)} join needs. ${network.buyStake}${unstaking}`;
121
121
  }
122
+ /** Where a task in flight stands and what happens next, for `status`. */
123
+ export function flightLine(f) {
124
+ const task = `task ${f.id}`;
125
+ switch (f.status) {
126
+ case Status.Hired:
127
+ return `${task}: hired, due by ${when(f.deadline)}`;
128
+ case Status.Submitted:
129
+ return `${task}: delivered, waiting for Jev's grade`;
130
+ case Status.Graded: {
131
+ const closes = when(f.gradedAt + f.disputeWindow);
132
+ return f.accepted
133
+ ? `${task}: accepted, paid after ${closes} unless the grade is disputed`
134
+ : `${task}: not accepted, refunded to the poster after ${closes} unless the grade is disputed with \`dispute ${f.id}\``;
135
+ }
136
+ case Status.Disputed:
137
+ return `${task}: grade disputed by ${f.disputedByModel ? "this worker" : "the poster"}, Jev rules by ${when(f.disputedAt + f.ruleWindow)}`;
138
+ case Status.Ruled: {
139
+ const a = f.appeal;
140
+ const closes = when(a.ruledAt + a.window);
141
+ return a.pays
142
+ ? `${task}: the ruling pays this worker after ${closes} unless the poster appeals`
143
+ : `${task}: the ruling refunds the poster after ${closes} unless appealed with \`appeal ${f.id}\``;
144
+ }
145
+ default:
146
+ return `${task}: ruling appealed, Jev rules again by ${when(f.appeal.appealedAt + f.ruleWindow)}`;
147
+ }
148
+ }
package/dist/state.js CHANGED
@@ -38,10 +38,11 @@ function isTask(value) {
38
38
  return false;
39
39
  return value.stored === undefined || (isObject(value.stored) && typeof value.stored.uri === "string" && isHash(value.stored.hash));
40
40
  }
41
+ const isFlag = (value) => value === undefined || value === true;
41
42
  function isProofs(value) {
42
43
  if (!isObject(value) || !isIds(Object.keys(value)))
43
44
  return false;
44
- return Object.values(value).every((p) => isObject(p) && isCount(p.deliveredAt) && isCount(p.nextAt) && (p.paidAt === undefined || isCount(p.paidAt)));
45
+ return Object.values(value).every((p) => isObject(p) && isCount(p.deliveredAt) && isCount(p.nextAt) && (p.paidAt === undefined || isCount(p.paidAt)) && isFlag(p.listed) && isFlag(p.waited) && isFlag(p.told));
45
46
  }
46
47
  /** Whether a parsed file is a state this worker can go on from; any other shape fails every round that reads it. */
47
48
  function isState(value) {
@@ -171,11 +172,12 @@ function holderOf(text) {
171
172
  }
172
173
  /** Whether the run that wrote a lock is still going. */
173
174
  function isHeld(file, { pid, space }, watchMs) {
174
- if (space === undefined)
175
- return alive(pid);
176
175
  const touched = touchedAt(file);
177
176
  if (touched === undefined || Date.now() - touched >= LOCK_STALE_MS)
178
177
  return false;
178
+ // A bare pid was written in this PID namespace or one gone since, so after a restart a live pid can be another process.
179
+ if (space === undefined)
180
+ return alive(pid);
179
181
  if (pid === 0)
180
182
  return true;
181
183
  if (space === pidSpace())
@@ -286,9 +288,9 @@ function migrate(old, file, board) {
286
288
  return false;
287
289
  // A run from before the move may still be working on this network's tasks from the old file.
288
290
  const left = readIfThere(`${old}.lock`);
289
- const pid = left === undefined ? 0 : holderOf(left).pid;
290
- if (alive(pid))
291
- throw held(pid, undefined, `${old}.lock`);
291
+ const holder = left === undefined ? undefined : holderOf(left);
292
+ if (holder && isHeld(`${old}.lock`, holder, LOCK_WATCH_MS))
293
+ throw held(holder.pid, holder.space, `${old}.lock`);
292
294
  if (readIfThere(`${file}.json`) !== undefined)
293
295
  return false;
294
296
  // Removed rather than moved: this run already holds the new lock.
package/dist/work.js CHANGED
@@ -15,10 +15,13 @@ const DEFAULT_TIMEOUT_MS = 20 * 60_000;
15
15
  const DRAIN_MS = 2_000;
16
16
  const ANSI = /\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g;
17
17
  export const stripAnsi = (text) => text.replace(ANSI, "");
18
+ /** Characters that show nothing or turn the text around them, so text that holds them can read as other text. */
19
+ export const HIDDEN = /[\u00ad\u061c\u180e\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/;
18
20
  /** Text from strangers, made safe to print as one terminal line. */
19
21
  export const printable = (text) => stripAnsi(text)
20
22
  .replace(/[\t\n\r]+/g, " ")
21
- .replace(/[\x00-\x1f\x7f-\x9f]/g, "");
23
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, "")
24
+ .replace(new RegExp(HIDDEN.source, "g"), "");
22
25
  export function parseBrief(text) {
23
26
  let value;
24
27
  try {
@@ -355,8 +358,9 @@ export function createWork(options) {
355
358
  store.save();
356
359
  };
357
360
  const needGas = async () => {
358
- if ((await chain.ethBalance()) === 0n)
359
- throw new Error("The wallet has no ETH to pay gas for the submit. Add some and the task is tried again");
361
+ if ((await chain.ethBalance()) === 0n) {
362
+ throw new Error(`The wallet has no ETH to pay gas for the submit${options.gasHelp ? `. ${options.gasHelp}` : ""}. The task is tried again`);
363
+ }
360
364
  };
361
365
  const inTime = async (task) => {
362
366
  if ((await chain.now()) >= task.deadline)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-worker",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Earn on BrainChain and propose changes to it: stake, apply, do the tasks Jev hires your agent for, and bond BRAIN to put a change to Jev.",
5
5
  "license": "MIT",
6
6
  "author": "BrainChain <hello@usebrainchain.com>",