brainchain-worker 0.14.0 → 0.16.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
@@ -36,13 +36,13 @@ Without curl, as in the `node:22-slim` image, Node.js sends the same request:
36
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
37
  ```
38
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:
39
+ The drip serves each address once. 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:
40
40
 
41
41
  ```sh
42
42
  npx brainchain-worker bridge --eth 0.001
43
43
  ```
44
44
 
45
- Joining spends about 0.00005 ETH, and each delivery about 0.00001. `join` checks for about 0.00009 ETH before it sends anything, since each transaction must be covered at more than the price it pays. 0.001 ETH covers joining and about 90 deliveries at today's gas price.
45
+ Joining spends about 0.00005 ETH, and each delivery about 0.00001. `join` checks for about 0.00009 ETH before it sends anything, since each transaction must be covered at more than the price it pays. 0.001 ETH covers joining and about 90 deliveries at today's gas price. What the drip leaves after joining delivers a few results: `status` says how many, and `run` says so after each delivery once fewer than 3 are left, with where more ETH comes from.
46
46
 
47
47
  ```sh
48
48
  npx brainchain-worker join --name "Atlas" --about "Summaries, data extraction and code review" --price 0.30
@@ -76,9 +76,9 @@ npx brainchain-worker run --exec 'claude -p --tools ""'
76
76
 
77
77
  ## Proofs
78
78
 
79
- Every task BrainChain pays for becomes a Proof: an NFT in the BrainChain Proofs collection on Robinhood Chain that shows the task's title, Jev's hire and grade, and the hashes that check them against the chain.
79
+ Every task BrainChain pays for can become a Proof: an NFT in the BrainChain Proofs collection on Robinhood Chain that shows the task's title, Jev's hire and grade, and the hashes that check them against the chain. A Proof records the work; it earns no share of $BRAIN trading.
80
80
 
81
- **The worker mints its own.** Once a task it delivered is paid, `run` mints that task's Proof from the worker's key, so the worker holds the piece and the income it earns. Each Proof costs 0.001 WETH on Robinhood Chain, plus gas, so the wallet needs WETH and a little ETH there. A Proof the worker has not minted a day after payment goes to a public pool instead. `run --no-mint` mints nothing.
81
+ **The worker can mint its own.** With `run --mint`, once a task it delivered is paid, the worker mints that task's Proof from its key and holds the piece. Each Proof costs 0.001 WETH on Robinhood Chain, plus gas, so the wallet needs WETH and a little ETH there. A Proof the worker has not minted a day after payment goes to a public pool instead. Without `--mint`, `run` mints nothing.
82
82
 
83
83
  `mint` catches up on paid tasks still waiting for their Proof, such as tasks paid while `run` was stopped:
84
84
 
@@ -87,9 +87,11 @@ npx brainchain-worker mint --dry # the tasks waiting, what they cost and what
87
87
  npx brainchain-worker mint # mints up to 10, one for each task
88
88
  ```
89
89
 
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.
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 --mint` says so once, with the time the Proof goes to the public pool. `run` without `--mint` mints nothing and needs none of this.
91
91
 
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:
92
+ **Weekly rewards.** On mainnet, each week BrainChain sends the worker up to 0.0001 ETH on Robinhood Chain for every 1 USDG of protocol fee its accepted tasks paid, a day after the week closes. There is nothing to claim.
93
+
94
+ **Earlier income.** Until 4 November 2026, mainnet Proofs earned a share of the $BRAIN launch's income. `claim` pays out what the worker's Proofs earned by then:
93
95
 
94
96
  ```sh
95
97
  BRAINCHAIN_NETWORK=mainnet npx brainchain-worker claim --dry # each Proof and what it is due, nothing sent
@@ -98,6 +100,24 @@ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker claim # pays it in one
98
100
 
99
101
  `claim` asks the BrainChain service which Proofs the wallet holds, reads what each is due and claims every one with income due in a single transaction, up to 200 at a time. Income always goes to the Proof's current holder. The transaction is sent from the worker's key on Robinhood Chain, so the wallet needs a little ETH there for gas. Testnet Proofs earn no income.
100
102
 
103
+ ## Bond a task
104
+
105
+ A worker can put money behind its word. `bond` locks USDG on [Shipped](https://shipped.bond) behind a task: if the task is paid and its Proof is minted, the bond comes back less Shipped's 1% fee; if the task is not paid by its deadline, the bond goes to the agent that paid for the task (the poster on the board, or the agent that paid BrainChain's desk, which the bond names). Jev reads the bond when it hires, and a worker that bonded the task is offered first.
106
+
107
+ ```sh
108
+ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker bond 12 5 # bond 5 USDG on task 12; shows the cost and asks
109
+ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker bond 12 5 --yes # the same with no terminal
110
+ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker bond status 12 # where the bond stands
111
+ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker bond claim 12 # collect a settled bond
112
+ ```
113
+
114
+ - **Amount.** At least the task's bounty and at least 2 USDG, from the worker's wallet on Robinhood Chain, which also pays the gas: about 1M gas for the approval, the bond and the claim together, under a cent at today's prices. `bond` prints the fee, the gas and both outcomes before it asks.
115
+ - **When.** While the task is open or hired to this worker, and never on a task the worker's own wallet posted or paid for. The bond's deadline is the task's plus 48 hours.
116
+ - **How it settles.** Shipped's on-chain check reads BrainChain's ProofIndex on Robinhood Chain: `proofOf(board, task)` is the Proof's token id once the task is paid and its Proof minted. BrainChain writes the index, marks the check and confirms it an hour later; `bond claim` then collects the bond less 1%. With no Proof by the deadline, the bond settles to the poster 25 hours later.
117
+ - **Not open yet.** Bonds need the ProofIndex on Robinhood Chain; until a release of this package names it, `bond` says so and sends nothing. Testnet has no Shipped registry.
118
+
119
+ [Bond a task](https://docs.usebrainchain.com/bond-a-task) has the rest, including what Jev is told.
120
+
101
121
  ## Watch the chain
102
122
 
103
123
  Any agent can watch BrainChain for the rest, with no stake. `watch` judges every change Jev queues against the [constitution](https://docs.usebrainchain.com/constitution) and re-checks every new decision the way `brainchain-verify` does: the published record, its hashes and its confidence against what the chain recorded.
@@ -170,7 +190,7 @@ A command that can run a shell still runs as your user, and reaches whatever you
170
190
  - **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.
171
191
  - **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.
172
192
  - **A key that lives in no file.** Pipe it in from your secret store: `op read op://worker/key | BRAINCHAIN_WORKER_KEY_FILE=/dev/stdin brainchain-worker run --exec "<command>"`. That leaves only the worker's memory, which Linux keeps from a command it started while `kernel.yama.ptrace_scope` is 1 or more.
173
- - **A key file.** `new-key --file <path>` writes the key to a file only you can read and prints just the address. Set `BRAINCHAIN_WORKER_KEY_FILE=<path>`, and no process's environment holds the key. A command that finds the path can still read it.
193
+ - **A key file.** `new-key --file <path>` writes the key to a file only you can read and prints `BRAINCHAIN_WORKER_KEY_FILE=<path>` in place of the key, then the address and the reminder. Set `BRAINCHAIN_WORKER_KEY_FILE=<path>`, and no process's environment holds the key. A command that finds the path can still read it.
174
194
 
175
195
  Whichever you use, keep only the worker's stake and gas in this wallet.
176
196
 
@@ -193,20 +213,23 @@ Whichever you use, keep only the worker's stake and gas in this wallet.
193
213
 
194
214
  | Command | |
195
215
  | --- | --- |
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. |
216
+ | `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 `BRAINCHAIN_WORKER_KEY_FILE=<path>` in place of the key; it never replaces an existing file. |
197
217
  | `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. |
218
+ | `run --exec "<command>" [--format prompt\|json] [--timeout <minutes>] [--once] [--mint]` | Do every task the worker is hired for, earliest deadline first, and with `--mint` 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. Until it can be, the run checks every 5 minutes and logs each change, such as Jev allowing the worker and the timelock passing. Once the wallet's ETH covers fewer than 3 deliveries, it says so after each one. `--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` is still accepted and changes nothing; minting happens only with `--mint`. |
219
+ | `status` | Whether the worker can be hired, its stake, its tBRAIN or BRAIN balance, price, record, earnings and ETH for gas, with about how many deliveries that ETH pays for. 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. |
200
220
  | `price <USDG>` | Change the asking price, in tUSDG on testnet and USDG on mainnet. |
201
221
  | `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. |
202
222
  | `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. |
223
+ | `bond <task> <USDG> [--yes]` | Lock USDG on Shipped behind a task, at least its bounty and 2 USDG. Prints the fee, the gas and both outcomes, then asks; `--yes` sends without a terminal. See [Bond a task](#bond-a-task). Mainnet. |
224
+ | `bond status <task>` | Where the worker's bond on a task stands: open, marked, delivered or missed. |
225
+ | `bond claim <task>` | Collect what a settled bond left the worker, with `withdraw` on Shipped's registry. |
203
226
  | `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. |
204
227
  | `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. |
205
228
  | `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. |
206
229
  | `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). |
207
230
  | `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. |
208
231
  | `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. |
209
- | `appeal <task>` | Appeal a ruling that went against the worker, inside the appeal window, approving the bond (a quarter of the bounty) first. Jev rules again; the bond comes back unless Jev confidently rules the same way. Testnet only for now. |
232
+ | `appeal <task>` | Appeal a ruling that went against the worker, inside the appeal window, approving the bond (a quarter of the bounty) first. A wallet holding less tUSDG than the bond claims it from the test USDG faucet before that, which gives 100 tUSDG once a day. Jev rules again; the bond comes back unless Jev confidently rules the same way. Testnet only for now. |
210
233
  | `leave` | Start unstaking. The stake unlocks after 3 days, once no task of the worker's is in flight. |
211
234
  | `withdraw` | Withdraw the unlocked stake. |
212
235
  | `--version` | Print the version. |
package/dist/bond.js ADDED
@@ -0,0 +1,300 @@
1
+ // A delivery bond on Shipped: the worker locks USDG on Robinhood Chain behind
2
+ // a task, and the task's Proof is what releases it. Shipped's check reads
3
+ // BrainChain's ProofIndex, proofOf(task) >= 1, so a task that is paid and gets
4
+ // its Proof returns the bond less Shipped's 1% fee, and one that is not pays
5
+ // the bond to the task's poster.
6
+ import { createPublicClient, createWalletClient, decodeAbiParameters, decodeFunctionData, encodeAbiParameters, encodeFunctionData, formatEther, http, isAddress, parseAbi, parseEventLogs, } from "viem";
7
+ import { taskBoardAbi } from "./abi.js";
8
+ import { erc20Abi, RECEIPT_MS, Status } from "./chain.js";
9
+ import { chainOf, proofsChainOf, proofsTransport } from "./network.js";
10
+ import { usdg, when } from "./standing.js";
11
+ export const proofIndexAbi = parseAbi(["function proofOf(address board, uint256 taskId) view returns (uint256)"]);
12
+ const TERMS = "(string title, string milestone, address project, uint8 projectKind, address asset, uint64 deadline, address verifier, address backupVerifier, address appealPanel, address check, bytes checkData, address refundTo, uint16 defaultRemainderBps, uint16 refundCapBps, bool allowPartial, uint32 verifierGrace, uint32 backupGrace, uint32 appealWindow, uint32 panelWindow, uint128 appealBond, uint128 verifierFee, address[] refundExcluded)";
13
+ export const shippedAbi = parseAbi([
14
+ `function create(${TERMS} t, uint256 deposit) returns (uint256 deedId)`,
15
+ "function withdraw(address asset) returns (uint256)",
16
+ "function owed(address account, address asset) view returns (uint256)",
17
+ "function deedCount() view returns (uint256)",
18
+ "function core(uint256 deedId) view returns (address creator, uint8 status, address asset, uint64 deadline)",
19
+ `function getDeed(uint256 deedId) view returns ((address creator, uint8 status, uint8 ruling, uint16 remainderBps, uint16 feeBps, bool overturned, uint64 createdAt, uint64 ruledAt, uint64 appealedAt, uint64 settledAt, uint64 checkMetAt, uint64 windowEnd, address ruledBy, address appellant, uint256 balance, ${TERMS} terms))`,
20
+ `event DeedCreated(uint256 indexed deedId, address indexed creator, address indexed project, ${TERMS} terms, uint256 deposit, uint16 feeBps)`,
21
+ "error AssetNotAllowed()",
22
+ "error CreationIsPaused()",
23
+ "error InvalidDeadline()",
24
+ "error InvalidRefund()",
25
+ "error NothingOwed()",
26
+ ]);
27
+ /** ShippedDeeds.Status and Ruling. */
28
+ export const DeedStatus = { None: 0, Open: 1, Ruled: 2, Appealed: 3, Settled: 4 };
29
+ export const Ruling = { None: 0, Accepted: 1, Rejected: 2, Partial: 3, Invalid: 4, Conceded: 5, Default: 6, CheckMet: 7 };
30
+ const BPS = 10000n;
31
+ const ZERO = "0x0000000000000000000000000000000000000000";
32
+ /** The bond's deadline is the task's plus this: grading, the worker's day to mint and the check's hold all fit. */
33
+ export const BOND_LEAD_SEC = 48n * 3600n;
34
+ /** A marked check is confirmed an hour later; a bond with no Proof can be settled a day after that. */
35
+ export const CHECK_HOLD_SEC = 3600n;
36
+ export const CHECK_CONFIRM_SEC = 86400n;
37
+ /** Shipped's fee on a released bond, in basis points, as the registry charges it today. */
38
+ export const FEE_BPS = 100n;
39
+ /** Below this the fee and the gas are too large a share of the bond. */
40
+ export const MIN_BOND = 2000000n;
41
+ /** Gas each transaction takes, measured on a fork of Robinhood Chain (approve 57,976; create 978,948 on a 496-byte milestone; withdraw 61,603), with room. */
42
+ export const GAS = { approve: 60000n, create: 1100000n, withdraw: 85000n };
43
+ /** Deeds read per batch, and the most read when the worker's bonds are looked for. */
44
+ const DEED_BATCH = 200;
45
+ const DEED_SCAN = 5_000;
46
+ const checkDataTypes = [{ type: "address" }, { type: "bytes" }, { type: "uint8" }, { type: "uint256" }];
47
+ /** StaticCallCheck data: proofOf(board, task) on the index, at least 1. Task ids restart with each board, so the board is part of the key. */
48
+ export function encodeCheckData(proofIndex, board, task) {
49
+ return encodeAbiParameters(checkDataTypes, [proofIndex, encodeFunctionData({ abi: proofIndexAbi, functionName: "proofOf", args: [board, task] }), 0, 1n]);
50
+ }
51
+ /** The task a bond's check reads on `proofIndex` for `board`, or undefined for any other check. */
52
+ export function taskOfCheck(data, proofIndex, board) {
53
+ try {
54
+ const [target, call, op, threshold] = decodeAbiParameters(checkDataTypes, data);
55
+ if (target.toLowerCase() !== proofIndex.toLowerCase() || op !== 0 || threshold !== 1n)
56
+ return undefined;
57
+ const { functionName, args } = decodeFunctionData({ abi: proofIndexAbi, data: call });
58
+ return functionName === "proofOf" && args[0].toLowerCase() === board.toLowerCase() ? args[1] : undefined;
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
64
+ /**
65
+ * The terms of a delivery bond on one task. Shipped's own check decides it, so
66
+ * there is no verifier and no appeal; the refund side is the agent that paid
67
+ * for the task: the poster on the board, or the payer the desk recorded.
68
+ */
69
+ export function bondTerms(config, task, board) {
70
+ return {
71
+ title: `BrainChain task #${task.id}`,
72
+ milestone: `The Proof NFT for BrainChain task #${task.id} (task board ${board.address} on BrainChain, chain ${board.chainId}) is recorded in BrainChain's ProofIndex at ${config.proofIndex} on Robinhood Chain, ` +
73
+ `read as proofOf(${board.address}, ${task.id}) >= 1. BrainChain records the Proof once the task is paid and the Proof is minted. ` +
74
+ `If no Proof is recorded by the deadline, the bond is paid to ${task.refundTo}, ${task.paidThroughDesk ? "the agent that paid BrainChain's desk for the task" : "the task's poster"}.`,
75
+ project: ZERO,
76
+ projectKind: 0,
77
+ asset: config.usdg,
78
+ deadline: task.deadline + BOND_LEAD_SEC,
79
+ verifier: ZERO,
80
+ backupVerifier: ZERO,
81
+ appealPanel: ZERO,
82
+ check: config.check,
83
+ checkData: encodeCheckData(config.proofIndex, board.address, task.id),
84
+ refundTo: task.refundTo,
85
+ defaultRemainderBps: 0,
86
+ refundCapBps: 0,
87
+ allowPartial: false,
88
+ verifierGrace: 0,
89
+ backupGrace: 0,
90
+ appealWindow: 0,
91
+ panelWindow: 0,
92
+ appealBond: 0n,
93
+ verifierFee: 0n,
94
+ refundExcluded: [],
95
+ };
96
+ }
97
+ export function createBondChain(network, config, account, fetcher = fetch) {
98
+ const brainchain = createPublicClient({ chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000, retryCount: 2 }) });
99
+ const chain = proofsChainOf(network.proofs);
100
+ const transport = proofsTransport(network.proofs);
101
+ const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
102
+ const wallet = createWalletClient({ account, chain, transport });
103
+ const registry = { address: config.registry, abi: shippedAbi };
104
+ const token = { address: config.usdg, abi: erc20Abi };
105
+ async function send(request) {
106
+ const hash = await wallet.writeContract(request);
107
+ const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
108
+ if (receipt.status !== "success")
109
+ throw new Error(`Transaction ${hash} reverted`);
110
+ return { hash, receipt };
111
+ }
112
+ return {
113
+ account: account.address,
114
+ async task(id) {
115
+ const t = await brainchain.readContract({ address: network.contracts.taskBoard, abi: taskBoardAbi, functionName: "get", args: [id] });
116
+ return t.status === 0 ? undefined : { poster: t.poster, bounty: t.bounty, deadline: t.deadline, status: t.status, model: t.model };
117
+ },
118
+ now: async () => (await client.getBlock()).timestamp,
119
+ usdgBalance: () => client.readContract({ ...token, functionName: "balanceOf", args: [account.address] }),
120
+ ethBalance: () => client.getBalance({ address: account.address }),
121
+ gasPrice: () => client.getGasPrice(),
122
+ allowance: () => client.readContract({ ...token, functionName: "allowance", args: [account.address, config.registry] }),
123
+ async approve(amount) {
124
+ return (await send({ ...token, functionName: "approve", args: [config.registry, amount] })).hash;
125
+ },
126
+ async create(terms, amount) {
127
+ const { hash, receipt } = await send({ ...registry, functionName: "create", args: [terms, amount] });
128
+ const created = parseEventLogs({ abi: shippedAbi, eventName: "DeedCreated", args: { creator: account.address }, logs: receipt.logs }).find((log) => log.address.toLowerCase() === config.registry.toLowerCase());
129
+ if (!created)
130
+ throw new Error(`The bond ${hash} created no deed for ${account.address}`);
131
+ return { deed: created.args.deedId, tx: hash };
132
+ },
133
+ async bondsOn(task) {
134
+ if (!config.proofIndex)
135
+ return [];
136
+ const count = Number(await client.readContract({ ...registry, functionName: "deedCount" }));
137
+ const mine = [];
138
+ for (let to = count; to > Math.max(0, count - DEED_SCAN); to -= DEED_BATCH) {
139
+ const ids = Array.from({ length: Math.min(DEED_BATCH, to - Math.max(0, count - DEED_SCAN)) }, (_, i) => BigInt(to - i));
140
+ const cores = await client.multicall({ contracts: ids.map((id) => ({ ...registry, functionName: "core", args: [id] })), allowFailure: false });
141
+ ids.forEach((id, i) => {
142
+ if (cores[i][0].toLowerCase() === account.address.toLowerCase())
143
+ mine.push(id);
144
+ });
145
+ }
146
+ if (mine.length === 0)
147
+ return [];
148
+ const deeds = await client.multicall({ contracts: mine.map((id) => ({ ...registry, functionName: "getDeed", args: [id] })), allowFailure: false });
149
+ return mine.flatMap((deed, i) => {
150
+ const d = deeds[i];
151
+ if (d.terms.check.toLowerCase() !== config.check.toLowerCase() || taskOfCheck(d.terms.checkData, config.proofIndex, network.contracts.taskBoard) !== task)
152
+ return [];
153
+ return [{ deed, task, amount: d.balance, deadline: d.terms.deadline, status: d.status, ruling: d.ruling, remainderBps: d.remainderBps, checkMetAt: d.checkMetAt, settledAt: d.settledAt, refundTo: d.terms.refundTo }];
154
+ });
155
+ },
156
+ proofOf: (task) => client.readContract({ address: config.proofIndex, abi: proofIndexAbi, functionName: "proofOf", args: [network.contracts.taskBoard, task] }),
157
+ async payerOf(task) {
158
+ const host = new URL(network.service).host;
159
+ const response = await fetcher(`${network.service}/bonds/refund-to/${task}`, { signal: AbortSignal.timeout(15_000) }).catch(() => {
160
+ throw new Error(`Could not reach the BrainChain service at ${host} to ask who paid for task ${task}`);
161
+ });
162
+ if (!response.ok) {
163
+ await response.body?.cancel();
164
+ throw new Error(`The BrainChain service at ${host} answered ${response.status} when asked who paid for task ${task}`);
165
+ }
166
+ const { refundTo } = (await response.json().catch(() => undefined)) ?? {};
167
+ if (refundTo === null || refundTo === undefined)
168
+ return undefined;
169
+ if (typeof refundTo !== "string" || !isAddress(refundTo))
170
+ throw new Error(`The BrainChain service at ${host} named the payer of task ${task} in a form bond cannot read`);
171
+ return refundTo;
172
+ },
173
+ owed: () => client.readContract({ ...registry, functionName: "owed", args: [account.address, config.usdg] }),
174
+ async withdraw() {
175
+ const amount = await client.readContract({ ...registry, functionName: "owed", args: [account.address, config.usdg] });
176
+ const { hash } = await send({ ...registry, functionName: "withdraw", args: [config.usdg] });
177
+ return { amount, tx: hash };
178
+ },
179
+ };
180
+ }
181
+ /** The ETH the bond's transactions need at this gas price, approval included when one is needed. */
182
+ export const bondGas = (gasPrice, approves) => (GAS.create + (approves ? GAS.approve : 0n)) * gasPrice;
183
+ const fee = (amount) => (amount * FEE_BPS) / BPS;
184
+ /** Where a bond stands, in one line. */
185
+ export function bondLine(network, b, indexed, now) {
186
+ const head = `Bond No. ${b.deed} on task ${b.task}: ${usdg(network, b.amount)}`;
187
+ if (b.status === DeedStatus.Settled) {
188
+ if (b.remainderBps === Number(BPS))
189
+ return `${head}, delivered at ${when(b.settledAt)}: ${usdg(network, b.amount - fee(b.amount))} back after Shipped's 1% fee (bond claim ${b.task} collects it)`;
190
+ return `${head}, missed: paid to the poster ${b.refundTo} at ${when(b.settledAt)}`;
191
+ }
192
+ if (b.checkMetAt > 0n) {
193
+ const confirmAt = b.checkMetAt + CHECK_HOLD_SEC;
194
+ return `${head}, the Proof is in the index and the check was marked at ${when(b.checkMetAt)}; BrainChain confirms it ${now < confirmAt ? `after ${when(confirmAt)}` : "shortly"}`;
195
+ }
196
+ if (now > b.deadline)
197
+ return `${head}, deadline ${when(b.deadline)} passed with no Proof recorded; it settles to the poster ${b.refundTo} after ${when(b.deadline + CHECK_HOLD_SEC + CHECK_CONFIRM_SEC)}`;
198
+ return `${head}, open until ${when(b.deadline)}: ${indexed ? "the Proof is in the index and BrainChain marks the check shortly" : "waiting for the task to be paid and its Proof recorded"}`;
199
+ }
200
+ function closed(network, config) {
201
+ if (!config)
202
+ return `Bonds run on Robinhood Chain against Shipped's registry, which ${network.name} has no counterpart of. Set BRAINCHAIN_NETWORK=mainnet.`;
203
+ if (!config.proofIndex)
204
+ return "Bonds are not open yet: BrainChain's ProofIndex on Robinhood Chain, which a bond's check reads, is not deployed. A later release of brainchain-worker names it.";
205
+ return undefined;
206
+ }
207
+ /** Reads the task, says what the bond costs and what it does, and sends the approval and the bond. */
208
+ export async function bond(network, config, chain, task, options) {
209
+ const { log } = options;
210
+ const shut = closed(network, config);
211
+ if (shut)
212
+ throw new Error(shut);
213
+ const open = config;
214
+ const t = await chain.task(task);
215
+ if (!t)
216
+ throw new Error(`There is no task ${task} on ${network.name}.`);
217
+ if (t.poster.toLowerCase() === chain.account.toLowerCase())
218
+ throw new Error(`Task ${task} was posted by this wallet. A bond guarantees delivery to someone else: it earns no standing on a task the worker posted itself.`);
219
+ const hiredHere = t.status === Status.Hired && t.model.toLowerCase().endsWith(chain.account.slice(2).toLowerCase());
220
+ if (t.status !== Status.Open && !hiredHere)
221
+ throw new Error(`Task ${task} is not open and not hired to this worker, so there is nothing to bond.`);
222
+ const now = await chain.now();
223
+ if (t.deadline <= now)
224
+ throw new Error(`Task ${task}'s deadline, ${when(t.deadline)}, has passed.`);
225
+ if (options.amount < t.bounty)
226
+ throw new Error(`The bond must be at least the task's bounty, ${usdg(network, t.bounty)}; ${usdg(network, options.amount)} is under it.`);
227
+ if (options.amount < MIN_BOND)
228
+ throw new Error(`The bond must be at least ${usdg(network, MIN_BOND)}: under that, Shipped's fee and the gas take too much of it.`);
229
+ const existing = (await chain.bondsOn(task)).find((b) => b.status !== DeedStatus.Settled);
230
+ if (existing)
231
+ throw new Error(`This worker already has bond No. ${existing.deed} open on task ${task}. \`bond status ${task}\` shows where it stands.`);
232
+ // A task paid through the desk has the desk as its poster on the board; the bond pays the agent that paid.
233
+ const payer = await chain.payerOf(task);
234
+ if (payer?.toLowerCase() === chain.account.toLowerCase())
235
+ throw new Error(`Task ${task} was paid for by this wallet. A bond guarantees delivery to someone else: it earns no standing on a task the worker paid for itself.`);
236
+ const refundTo = payer ?? t.poster;
237
+ const [balance, allowance, gasPrice, eth] = await Promise.all([chain.usdgBalance(), chain.allowance(), chain.gasPrice(), chain.ethBalance()]);
238
+ const approves = allowance < options.amount;
239
+ const gas = bondGas(gasPrice, approves);
240
+ const terms = bondTerms(open, { id: task, refundTo, paidThroughDesk: payer !== undefined, deadline: t.deadline }, { address: network.contracts.taskBoard, chainId: network.chainId });
241
+ const back = options.amount - fee(options.amount);
242
+ log([
243
+ `Bond ${usdg(network, options.amount)} on task ${task} (bounty ${usdg(network, t.bounty)}, deadline ${when(t.deadline)}).`,
244
+ ` Released by the task's Proof: ${usdg(network, back)} back after Shipped's 1% fee (${usdg(network, fee(options.amount))}), collected with \`bond claim ${task}\`.`,
245
+ ` Missed: if no Proof is recorded by ${when(terms.deadline)}, the ${usdg(network, options.amount)} goes to ${refundTo}, ${payer ? "the agent that paid for the task" : "the poster"}.`,
246
+ ` Gas now: about ${formatEther(gas)} ETH on ${network.proofs.name} for ${approves ? "the approval and the bond" : "the bond"} at ${formatEther(gasPrice * 1000000000n)} gwei; the claim later costs about ${formatEther(GAS.withdraw * gasPrice)} ETH.`,
247
+ ].join("\n"));
248
+ if (balance < options.amount)
249
+ throw new Error(`${chain.account} holds ${usdg(network, balance)} on ${network.proofs.name}, under the ${usdg(network, options.amount)} to bond. Nothing was sent.`);
250
+ if (eth < gas)
251
+ throw new Error(`${chain.account} holds ${formatEther(eth)} ETH on ${network.proofs.name}, under the ${formatEther(gas)} ETH the transactions need. Nothing was sent.`);
252
+ if (!options.yes) {
253
+ if (!options.ask)
254
+ throw new Error("There is no terminal to confirm on. Run again with --yes to send without asking.");
255
+ const answer = (await options.ask("Send it? [y/N] ")).trim().toLowerCase();
256
+ if (answer !== "y" && answer !== "yes")
257
+ return log("Nothing sent.");
258
+ }
259
+ if (approves) {
260
+ log(`Approving ${usdg(network, options.amount)} for Shipped's registry...`);
261
+ await chain.approve(options.amount);
262
+ }
263
+ log(`Bonding ${usdg(network, options.amount)} on task ${task}...`);
264
+ const { deed, tx } = await chain.create(terms, options.amount);
265
+ log(`Bond No. ${deed} is open on Shipped (${tx}). Jev reads it when it hires; \`bond status ${task}\` follows it.`);
266
+ }
267
+ /** Prints where the worker's bonds on a task stand. */
268
+ export async function bondStatus(network, config, chain, task, log) {
269
+ const shut = closed(network, config);
270
+ if (shut)
271
+ throw new Error(shut);
272
+ const bonds = await chain.bondsOn(task);
273
+ if (bonds.length === 0)
274
+ return log(`${chain.account} has no bond on task ${task}.`);
275
+ const [now, indexed] = await Promise.all([chain.now(), chain.proofOf(task)]);
276
+ for (const b of bonds)
277
+ log(bondLine(network, b, indexed > 0n, now));
278
+ }
279
+ /** Collects what a settled bond left the worker: withdraw(USDG) on the registry. */
280
+ export async function bondClaim(network, config, chain, task, log) {
281
+ const shut = closed(network, config);
282
+ if (shut)
283
+ throw new Error(shut);
284
+ const [latest] = await chain.bondsOn(task);
285
+ if (!latest)
286
+ throw new Error(`${chain.account} has no bond on task ${task}.`);
287
+ if (latest.status !== DeedStatus.Settled) {
288
+ const [now, indexed] = await Promise.all([chain.now(), chain.proofOf(task)]);
289
+ throw new Error(`${bondLine(network, latest, indexed > 0n, now)}. It can be claimed once it has settled.`);
290
+ }
291
+ const owed = await chain.owed();
292
+ if (owed === 0n) {
293
+ return log(latest.remainderBps === Number(BPS) ? `Bond No. ${latest.deed} was delivered and its ${usdg(network, latest.amount - fee(latest.amount))} already withdrawn.` : `Bond No. ${latest.deed} was missed: its ${usdg(network, latest.amount)} went to the poster ${latest.refundTo}, so there is nothing to claim.`);
294
+ }
295
+ if ((await chain.ethBalance()) === 0n)
296
+ throw new Error(`${chain.account} has no ETH on ${network.proofs.name} to pay gas for the claim.`);
297
+ log(`Claiming ${usdg(network, owed)} from Shipped's registry on ${network.proofs.name}...`);
298
+ const { amount, tx } = await chain.withdraw();
299
+ log(`Paid ${usdg(network, amount)} to ${chain.account} (${tx}).`);
300
+ }
package/dist/bridge.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createPublicClient, createWalletClient, encodeAbiParameters, formatEther, formatUnits, http, parseAbi, parseUnits, } from "viem";
2
2
  import { reason, RECEIPT_MS } from "./chain.js";
3
3
  import { chainOf, parentChainOf } from "./network.js";
4
+ import { grouped } from "./standing.js";
4
5
  const inboxAbi = parseAbi([
5
6
  "function depositEth() payable returns (uint256)",
6
7
  "function calculateRetryableSubmissionFee(uint256 dataLength, uint256 baseFee) view returns (uint256)",
@@ -100,7 +101,8 @@ export async function bridge(network, chain, options) {
100
101
  const amount = parseUnits(options.amount, decimals);
101
102
  if (amount <= 0n)
102
103
  throw new Error("the amount must be above zero");
103
- const shown = `${formatUnits(amount, decimals)} ${symbol}`;
104
+ // Token amounts are grouped as status shows them; ETH amounts are small.
105
+ const shown = asset === "eth" ? `${formatUnits(amount, decimals)} ${symbol}` : `${grouped(amount, decimals)} ${symbol}`;
104
106
  let eth;
105
107
  try {
106
108
  eth = await chain.parentEth();
@@ -120,7 +122,7 @@ export async function bridge(network, chain, options) {
120
122
  }
121
123
  const held = await chain.tokenBalance(asset);
122
124
  if (held < amount) {
123
- throw new Error(`${chain.account} holds ${formatUnits(held, decimals)} ${symbol} on ${parent.name}, ${formatUnits(amount - held, decimals)} short of ${shown}`);
125
+ throw new Error(`${chain.account} holds ${grouped(held, decimals)} ${symbol} on ${parent.name}, ${grouped(amount - held, decimals)} short of ${shown}`);
124
126
  }
125
127
  const value = await chain.arrivalCost(asset, amount);
126
128
  if (eth < value + parseUnits("0.0001", 18)) {
package/dist/chain.js CHANGED
@@ -359,6 +359,8 @@ export function createChain(network, account, store = memoryStore()) {
359
359
  ...work,
360
360
  account: account.address,
361
361
  gasPrice: () => client.getGasPrice(),
362
+ /** How many transactions the wallet has sent. */
363
+ sent: () => client.getTransactionCount({ address: account.address }),
362
364
  stakeBalance: () => client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
363
365
  bountyBalance: () => client.readContract({ address: bountyToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
364
366
  stake: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "get", args: [account.address] }),
package/dist/cli.js CHANGED
@@ -1,25 +1,31 @@
1
1
  import { createRequire } from "node:module";
2
+ import { createInterface } from "node:readline/promises";
2
3
  import { verifyDecision } from "brainchain-verify";
3
4
  import { createPublicClient, http } from "viem";
4
- import { parseUnits } from "viem";
5
+ import { formatEther, parseUnits, zeroAddress } from "viem";
5
6
  import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
6
7
  import { brainchainTransport, createChain, faucetReopens, reason, requireChainId, unhealthy } from "./chain.js";
8
+ import { bond, bondClaim, bondStatus, createBondChain } from "./bond.js";
7
9
  import { assetOf, bridge, createBridgeChain } from "./bridge.js";
8
10
  import { claim, createIncome } from "./claim.js";
9
11
  import { createProofsChain, createSettlementWatch, MAX_MINT, mint } from "./mint.js";
10
12
  import { chainOf, resolveNetwork } from "./network.js";
11
13
  import { buildCall, caseURI, createProposals, proposal, propose } from "./propose.js";
12
- import { ethNeeded, flightLine, gasLine, hireable, joinLimits, shortOfGas, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
14
+ import { deliveries, deliveriesLeft, ethNeeded, flightLine, gasHelp, gasLine, hireable, joinLimits, shortOfGas, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
13
15
  import { openStore, readWatched, writeWatched } from "./state.js";
14
16
  import { clearKeyFromEnvironment, keyConfigured, keyText, writeKeyFile } from "./key.js";
15
17
  import { createCreditChain, credit } from "./credit.js";
16
18
  import { appeal, createDisputeChain, dispute } from "./dispute.js";
17
19
  import { createReporter, createWatch, createWatchChain, requireWatcher } from "./watch.js";
18
- import { createWork, firstProgram, HIDDEN, npxUnderSupervisor, programFound, shellCommand, stopLeftover, stopper, watchStarter } from "./work.js";
20
+ import { createWork, firstProgram, npxUnderSupervisor, programFound, shellCommand, stopLeftover, stopper, unseen, watchStarter } from "./work.js";
19
21
  /** How many of the latest decisions a first watch --once checks. */
20
22
  const FIRST_RUN_DECISIONS = 10n;
21
23
  /** How long an idle run goes without a line before it says it is still waiting. */
22
24
  const HEARTBEAT_MS = 3_600_000;
25
+ /** Below this many deliveries' worth of gas, run warns after each delivery. */
26
+ const LOW_DELIVERIES = 3n;
27
+ /** How often a run that cannot be hired yet reads again whether it can. */
28
+ const HIREABLE_CHECK_MS = 300_000;
23
29
  /** A published record's bytes as the service serves them, capped at a megabyte. */
24
30
  async function fetchRecord(uri) {
25
31
  const response = await fetch(uri, { signal: AbortSignal.timeout(20_000) });
@@ -34,19 +40,20 @@ const { version } = createRequire(import.meta.url)("../package.json");
34
40
  /** Each command's lines in the usage text, in the order the text lists them. */
35
41
  const COMMAND_USAGE = {
36
42
  "new-key": ` brainchain-worker new-key [--file <path>] Print a fresh private key and its address. --file writes the key
37
- to a new file only you can read and prints only the address.`,
43
+ to a new file only you can read and prints the file's path in
44
+ place of the key.`,
38
45
  join: ` brainchain-worker join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]
39
46
  Stake the minimum and apply to take tasks. The name is at
40
47
  most 60 characters, the description at most 500. --agent-id
41
48
  (mainnet) names the worker's ERC-8004 agent on Robinhood Chain,
42
49
  so its BrainChain record is written to that identity.`,
43
- run: ` brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--no-mint]
50
+ run: ` brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--mint]
44
51
  Do every task Jev hires the worker for: the command reads the
45
52
  task on stdin and prints the deliverable on stdout, within
46
53
  --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
54
+ each task settles. --mint also mints the Proof of each task it is
55
+ paid for.`,
56
+ status: ` brainchain-worker status Stake, wallet, application, record, earnings and gas, with where each
50
57
  task in flight stands.`,
51
58
  price: ` brainchain-worker price <USDG> Change your asking price per task.`,
52
59
  mint: ` brainchain-worker mint [--count <n>] [--dry]
@@ -54,6 +61,13 @@ const COMMAND_USAGE = {
54
61
  --count, at 0.001 WETH each on Robinhood Chain. --dry sends nothing.`,
55
62
  claim: ` brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
56
63
  --dry lists what is due and sends nothing.`,
64
+ bond: ` brainchain-worker bond <task> <USDG> [--yes] | bond status <task> | bond claim <task>
65
+ Bond USDG on Shipped behind a task, at least its bounty and 2 USDG:
66
+ the task's Proof releases it less Shipped's 1% fee, and a task that
67
+ is not paid by its deadline pays the bond to the poster. Shows the
68
+ cost and asks before sending; --yes sends without asking. status
69
+ says where the bond stands and claim collects what a settled one
70
+ left the worker. Mainnet.`,
57
71
  bridge: ` brainchain-worker bridge --eth <ETH> | --stake <amount> | --bounty <amount> [--dry]
58
72
  Move ETH for gas, the stake token or the bounty token from Robinhood
59
73
  Chain to the worker's address on BrainChain; it arrives in about a
@@ -84,8 +98,9 @@ const COMMAND_USAGE = {
84
98
  window. Jev rules on it; a confident ruling decides.`,
85
99
  appeal: ` brainchain-worker appeal <task> Appeal the ruling on a task, when it went against the worker, within the
86
100
  appeal window, with the bond the task's terms fix (a quarter of the
87
- bounty). Jev rules again; the bond comes back unless Jev confidently
88
- rules the same way. Testnet only for now.`,
101
+ bounty), claimed from the test USDG faucet when the wallet holds less.
102
+ Jev rules again; the bond comes back unless Jev confidently rules the
103
+ same way. Testnet only for now.`,
89
104
  leave: ` brainchain-worker leave Start unstaking (3-day cooldown).`,
90
105
  withdraw: ` brainchain-worker withdraw Withdraw stake once the cooldown has passed.`,
91
106
  };
@@ -119,12 +134,14 @@ export class UsageError extends Error {
119
134
  const FLAGS = {
120
135
  "new-key": { file: false },
121
136
  join: { name: false, about: false, price: false, "agent-id": false },
122
- run: { exec: false, format: false, timeout: false, once: true, "no-mint": true },
137
+ // --no-mint is still accepted from before minting became opt-in; it changes nothing.
138
+ run: { exec: false, format: false, timeout: false, once: true, mint: true, "no-mint": true },
123
139
  status: {},
124
140
  price: {},
125
141
  leave: {},
126
142
  withdraw: {},
127
143
  claim: { dry: true },
144
+ bond: { yes: true },
128
145
  mint: { count: false, dry: true },
129
146
  bridge: { eth: false, stake: false, bounty: false, dry: true },
130
147
  watch: { veto: true, once: true },
@@ -134,7 +151,7 @@ const FLAGS = {
134
151
  dispute: {},
135
152
  appeal: {},
136
153
  };
137
- const POSITIONAL = { price: 1, proposal: 1, dispute: 1, appeal: 1 };
154
+ const POSITIONAL = { price: 1, proposal: 1, dispute: 1, appeal: 1, bond: 2 };
138
155
  /** A command's flags; a name from argv can match what every object inherits, such as constructor. */
139
156
  const flagsOf = (command) => (Object.hasOwn(FLAGS, command) ? FLAGS[command] : undefined);
140
157
  export function parse(command, args) {
@@ -193,7 +210,7 @@ function signer() {
193
210
  const raw = keyText()?.trim();
194
211
  if (!raw)
195
212
  throw new Error("Set BRAINCHAIN_WORKER_KEY to the worker's private key, or BRAINCHAIN_WORKER_KEY_FILE to a file that holds it. `npx brainchain-worker new-key` makes one.");
196
- const hex = raw.startsWith("0x") ? raw.slice(2) : raw;
213
+ const hex = raw.replace(/^0x/i, "");
197
214
  if (!/^[0-9a-fA-F]{64}$/.test(hex))
198
215
  throw new Error("The worker's key must be 64 hex characters, with or without 0x");
199
216
  const key = `0x${hex.toLowerCase()}`;
@@ -214,12 +231,12 @@ function price(value) {
214
231
  }
215
232
  const RUN = '`npx brainchain-worker run --exec "<command>"`';
216
233
  const KEEP_RUNNING = "https://github.com/usebrainchain/brainchain-worker#keep-it-running";
217
- function noGas(chain, network) {
218
- return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas(chain.account)}.`);
234
+ async function noGas(chain, network) {
235
+ return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${gasHelp(network, chain.account, 0n, await chain.sent())}.`);
219
236
  }
220
237
  async function needGas(chain, network) {
221
238
  if ((await chain.ethBalance()) === 0n)
222
- throw noGas(chain, network);
239
+ throw await noGas(chain, network);
223
240
  }
224
241
  export async function join(chain, network, values) {
225
242
  const name = values.get("name")?.trim() ?? "";
@@ -229,15 +246,18 @@ export async function join(chain, network, values) {
229
246
  if (name.length > 60)
230
247
  throw new UsageError("--name is at most 60 characters");
231
248
  // 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");
249
+ const nameHides = unseen(name);
250
+ if (nameHides)
251
+ throw new UsageError(`--name must be one line with no invisible or direction-changing characters, and it holds ${nameHides}`);
234
252
  if (!about) {
235
253
  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");
236
254
  }
237
255
  if (about.length > 500)
238
256
  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");
257
+ // A description may run over several lines.
258
+ const aboutHides = unseen(about.replace(/[\t\n]/g, " "));
259
+ if (aboutHides)
260
+ throw new UsageError(`--about must have no invisible or direction-changing characters, and it holds ${aboutHides}`);
241
261
  const agentId = values.get("agent-id");
242
262
  if (agentId !== undefined && !/^(0|[1-9]\d{0,14})$/.test(agentId))
243
263
  throw new UsageError("--agent-id is the worker's ERC-8004 agent id, a whole number");
@@ -261,12 +281,19 @@ export async function join(chain, network, values) {
261
281
  }
262
282
  // A worker that has staked keeps its price unless --price changes it.
263
283
  const asking = requested ?? (w.registered ? w.price : price(DEFAULT_PRICE));
264
- const [gas, gasPrice, bond, balance, timelock] = await Promise.all([chain.ethBalance(), chain.gasPrice(), chain.bond(), chain.stakeBalance(), chain.timelock()]);
284
+ const [gas, gasPrice, bond, balance, timelock, sent] = await Promise.all([
285
+ chain.ethBalance(),
286
+ chain.gasPrice(),
287
+ chain.bond(),
288
+ chain.stakeBalance(),
289
+ chain.timelock(),
290
+ chain.sent(),
291
+ ]);
265
292
  const need = toStake + (applying ? bond : 0n);
266
293
  const claims = balance < need && !network.buyStake;
267
294
  // Checked before anything is sent: a wallet that runs out partway leaves the stake placed and the application unsent.
268
295
  const gasNeed = ethNeeded(joinLimits(standing, claims, toStake === 0n && w.price !== asking), gasPrice);
269
- const lacksGas = gas < gasNeed ? `${chain.account} holds ${shortOfGas(network, chain.account, gas, gasNeed, "join")}` : undefined;
296
+ const lacksGas = gas < gasNeed ? `${chain.account} holds ${shortOfGas(network, chain.account, gas, sent, gasNeed, "join")}` : undefined;
270
297
  if (balance < need && network.buyStake) {
271
298
  const short = shortOfStake(network, w, balance, need, await chain.now());
272
299
  // Both needs at once, so a new worker does not bridge ETH only to learn it needs the stake token too.
@@ -320,9 +347,10 @@ export async function join(chain, network, values) {
320
347
  "the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
321
348
  }
322
349
  async function status(chain, network) {
323
- const [gas, gasPrice, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
350
+ const [gas, gasPrice, sent, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
324
351
  chain.ethBalance(),
325
352
  chain.gasPrice(),
353
+ chain.sent(),
326
354
  snapshot(chain),
327
355
  chain.held(),
328
356
  chain.record(),
@@ -342,7 +370,7 @@ async function status(chain, network) {
342
370
  Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
343
371
  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("")}
344
372
  Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, bountyBalance)}
345
- Gas ${gasLine(network, chain.account, s, gas, gasPrice, !network.buyStake && stakeBalance < s.toStake + (s.applying ? bond : 0n))}`);
373
+ Gas ${gasLine(network, chain.account, s, gas, gasPrice, !network.buyStake && stakeBalance < s.toStake + (s.applying ? bond : 0n), sent)}`);
346
374
  }
347
375
  async function run(network, { key, account }, values, once, mints) {
348
376
  const exec = values.get("exec");
@@ -379,9 +407,23 @@ async function run(network, { key, account }, values, once, mints) {
379
407
  stopping: stop.stopping,
380
408
  // A cron job sees a failed task as a failed run.
381
409
  failed: once ? () => void (process.exitCode = 1) : undefined,
382
- delivered: settlements.track,
383
- gasHelp: network.gas(account.address),
410
+ delivered(task) {
411
+ settlements.track(task);
412
+ void lowGas();
413
+ },
414
+ gasHelp: async () => gasHelp(network, account.address, 0n, await chain.sent()),
384
415
  });
416
+ // The drip pays for only a few deliveries, so the log says when the wallet is down to its last ones.
417
+ const lowGas = async () => {
418
+ const [gas, price, sent] = await Promise.all([chain.ethBalance(), chain.gasPrice(), chain.sent()]).catch(() => []);
419
+ if (gas === undefined || price === undefined || sent === undefined)
420
+ return;
421
+ const left = deliveriesLeft(gas, price);
422
+ if (left >= LOW_DELIVERIES)
423
+ return;
424
+ const enough = left === 0n ? "not enough for another delivery" : `enough for about ${left} more ${left === 1n ? "delivery" : "deliveries"}`;
425
+ log(`warning: the wallet holds ${formatEther(gas)} ETH for gas, ${enough}. ${gasHelp(network, account.address, gas, sent)}.`);
426
+ };
385
427
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
386
428
  process.on(signal, stop.signal);
387
429
  if (process.env.npm_execpath !== undefined) {
@@ -406,12 +448,14 @@ async function run(network, { key, account }, values, once, mints) {
406
448
  log(`could not read the wallet's ETH balance: ${reason(error)}`);
407
449
  }
408
450
  if (gas === 0n)
409
- throw noGas(chain, network);
410
- const [line, sick] = await Promise.all([
451
+ throw await noGas(chain, network);
452
+ const held = gas;
453
+ const [line, sick, left] = await Promise.all([
411
454
  snapshot(chain).then((s) => hireable(s, network, "run"), (error) => `could not check (${reason(error)})`),
412
455
  unhealthy(network.service),
456
+ held === undefined ? undefined : chain.gasPrice().then((price) => deliveriesLeft(held, price), () => undefined),
413
457
  ]);
414
- log(`worker ${chain.account} on ${network.name}; hireable: ${line}`);
458
+ log(`worker ${chain.account} on ${network.name}; hireable: ${line}${left === undefined ? "" : `; gas for ${deliveries(left)}`}`);
415
459
  if (sick)
416
460
  log(`warning: ${sick}`);
417
461
  if (once) {
@@ -428,12 +472,16 @@ async function run(network, { key, account }, values, once, mints) {
428
472
  log("waiting for tasks (Ctrl-C to stop)");
429
473
  // Minting waits on receipts from another chain, so it runs beside the work, one round at a time.
430
474
  let minting;
475
+ const hireChange = hireWatch(line, () => snapshot(chain).then((s) => hireable(s, network, "run"), () => undefined));
431
476
  while (!stop.stopping()) {
432
477
  await work.round().catch((error) => log(`round failed: ${reason(error)}`));
433
478
  minting ??= settlements
434
479
  .round()
435
480
  .catch((error) => log(`settlement round failed: ${reason(error)}`))
436
481
  .finally(() => (minting = undefined));
482
+ const changed = stop.stopping() ? undefined : await hireChange();
483
+ if (changed !== undefined)
484
+ log(`hireable: ${changed}`);
437
485
  if (quietFor() >= HEARTBEAT_MS && !work.busy() && !stop.stopping()) {
438
486
  log(`still waiting for tasks; hireable: ${await snapshot(chain).then((s) => hireable(s, network, "run"), (error) => `could not check (${reason(error)})`)}`);
439
487
  }
@@ -443,6 +491,26 @@ async function run(network, { key, account }, values, once, mints) {
443
491
  await minting;
444
492
  log("stopped");
445
493
  }
494
+ /**
495
+ * Reads again, every few minutes until the worker can be hired, whether it can,
496
+ * and returns the new answer when it changed. A run started while Jev decides
497
+ * on the application then says when Jev allows the worker, and when its
498
+ * timelock has passed.
499
+ */
500
+ export function hireWatch(first, check, everyMs = HIREABLE_CHECK_MS, now = Date.now) {
501
+ let last = first;
502
+ let checkedAt = now();
503
+ return async () => {
504
+ if (last === "yes" || now() - checkedAt < everyMs)
505
+ return undefined;
506
+ checkedAt = now();
507
+ const line = await check();
508
+ if (line === undefined || line === last)
509
+ return undefined;
510
+ last = line;
511
+ return line;
512
+ };
513
+ }
446
514
  /** 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
515
  export function timedLog(write, now = Date.now) {
448
516
  let last = now();
@@ -454,6 +522,41 @@ export function timedLog(write, now = Date.now) {
454
522
  quietFor: () => now() - last,
455
523
  };
456
524
  }
525
+ /** The task id and amount a bond command names, or what is wrong with them. */
526
+ export function bondArgs(positional) {
527
+ const [first, second] = positional;
528
+ const taskId = (value, usage) => {
529
+ if (value === undefined || !/^[1-9]\d{0,17}$/.test(value))
530
+ throw new UsageError(usage);
531
+ return BigInt(value);
532
+ };
533
+ if (first === "status" || first === "claim")
534
+ return { action: first, task: taskId(second, `Usage: brainchain-worker bond ${first} <task>, such as bond ${first} 12`) };
535
+ const task = taskId(first, "Usage: brainchain-worker bond <task> <USDG>, such as bond 12 5, or bond status <task>, or bond claim <task>");
536
+ if (second === undefined || !/^\d{1,9}(\.\d{1,6})?$/.test(second) || Number(second) <= 0) {
537
+ throw new UsageError("The amount is in USDG, above zero with at most 6 decimals, such as bond 12 5");
538
+ }
539
+ return { action: "bond", task, amount: parseUnits(second, 6) };
540
+ }
541
+ async function bondCommand(network, positional, yes) {
542
+ const args = bondArgs(positional);
543
+ const config = network.proofs.bonds;
544
+ const chain = createBondChain(network, config ?? { registry: zeroAddress, check: zeroAddress, usdg: zeroAddress }, signer().account);
545
+ if (args.action !== "bond")
546
+ return (args.action === "status" ? bondStatus : bondClaim)(network, config, chain, args.task, console.log);
547
+ const ask = process.stdin.isTTY
548
+ ? async (question) => {
549
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
550
+ try {
551
+ return await rl.question(question);
552
+ }
553
+ finally {
554
+ rl.close();
555
+ }
556
+ }
557
+ : undefined;
558
+ return bond(network, config, chain, args.task, { amount: args.amount, yes, ...(ask ? { ask } : {}), log: console.log });
559
+ }
457
560
  /** 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
561
  export const watchPlace = (log, key) => (key === undefined ? log : `${log}.${key}`);
459
562
  /** What watch says at start. Without a key it names no address: the one its reads go through is a throwaway. */
@@ -496,6 +599,8 @@ export async function main(argv) {
496
599
  if (command === "claim") {
497
600
  return claim(network, (income) => createIncome(network.proofs, income, signer().account), { dry: switches.has("dry"), log: console.log });
498
601
  }
602
+ if (command === "bond")
603
+ return bondCommand(network, positional, switches.has("yes"));
499
604
  if (command === "mint") {
500
605
  const count = values.get("count");
501
606
  if (count !== undefined && !(/^\d{1,2}$/.test(count) && Number(count) >= 1 && Number(count) <= MAX_MINT)) {
@@ -602,7 +707,7 @@ export async function main(argv) {
602
707
  return propose(network, createProposals(network, worker.account), call, uri, { dry: switches.has("dry"), log: console.log });
603
708
  }
604
709
  if (command === "run")
605
- return run(network, worker, values, switches.has("once"), !switches.has("no-mint"));
710
+ return run(network, worker, values, switches.has("once"), switches.has("mint"));
606
711
  const chain = createChain(network, worker.account);
607
712
  switch (command) {
608
713
  case "join":
package/dist/dispute.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createPublicClient, createWalletClient, encodeFunctionData, formatUnits, parseAbi } from "viem";
2
2
  import { erc20Errors, transferErrors } from "./abi.js";
3
- import { appealAbi, brainchainTransport, erc20Abi, explainRevert, reason, RECEIPT_MS, Status } from "./chain.js";
3
+ import { appealAbi, brainchainTransport, erc20Abi, explainRevert, faucetReopens, reason, RECEIPT_MS, Status } from "./chain.js";
4
4
  import { chainOf } from "./network.js";
5
- import { ethNeeded, shortOfGas } from "./standing.js";
5
+ import { ethNeeded, GAS, shortOfGas } from "./standing.js";
6
6
  /** TaskBoard statuses from set 3.5.0 on: a ruling that waits out its appeal window, and one under appeal. */
7
7
  export const { Ruled, Appealed } = Status;
8
8
  const boardAbi = [
@@ -55,7 +55,23 @@ export function createDisputeChain(network, account) {
55
55
  appealOf: (id) => client.readContract({ ...board, functionName: "appealOf", args: [id] }),
56
56
  appealBond: (id) => client.readContract({ ...board, functionName: "appealBond", args: [id] }),
57
57
  bountyBalance: () => client.readContract({ ...token, functionName: "balanceOf", args: [account.address] }),
58
+ async bountyFaucet() {
59
+ const [amount, interval, last] = await Promise.all([
60
+ client.readContract({ ...token, functionName: "FAUCET_AMOUNT" }),
61
+ client.readContract({ ...token, functionName: "FAUCET_INTERVAL" }),
62
+ client.readContract({ ...token, functionName: "lastClaim", args: [account.address] }),
63
+ ]);
64
+ return { amount, opensAt: last === 0n ? 0n : last + interval };
65
+ },
66
+ async claimBounty() {
67
+ const hash = await wallet.writeContract({ ...token, functionName: "faucet" });
68
+ const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
69
+ if (receipt.status !== "success")
70
+ throw new Error(`The faucet claim ${hash} reverted`);
71
+ return hash;
72
+ },
58
73
  ethBalance: () => client.getBalance({ address: account.address }),
74
+ sent: () => client.getTransactionCount({ address: account.address }),
59
75
  gasPrice: () => client.getGasPrice(),
60
76
  allowance: () => client.readContract({ ...token, functionName: "allowance", args: [account.address, board.address] }),
61
77
  async approveBounty(amount) {
@@ -110,14 +126,41 @@ export async function appeal(network, chain, id, log) {
110
126
  const closes = terms.ruledAt + terms.window;
111
127
  if (now > closes)
112
128
  throw new Error(`The appeal window for task ${id} closed at ${at(closes)}.`);
113
- const [bond, balance, eth, gasPrice, allowance] = await Promise.all([chain.appealBond(id), chain.bountyBalance(), chain.ethBalance(), chain.gasPrice(), chain.allowance()]);
129
+ const [bond, balance, eth, gasPrice, allowance, sent] = await Promise.all([
130
+ chain.appealBond(id),
131
+ chain.bountyBalance(),
132
+ chain.ethBalance(),
133
+ chain.gasPrice(),
134
+ chain.allowance(),
135
+ chain.sent(),
136
+ ]);
114
137
  const amount = `${formatUnits(bond, 6)} ${network.bountySymbol}`;
115
- if (balance < bond)
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>\`.`);
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);
138
+ const holds = `The appeal bond is ${amount}, and this wallet holds ${formatUnits(balance, 6)}.`;
139
+ // Test USDG comes from its faucet, as tBRAIN does for join; only mainnet's USDG is bridged in.
140
+ const claims = balance < bond && network.bountyFaucet === true;
141
+ if (balance < bond && !claims)
142
+ throw new Error(`${holds} Bridge more with \`npx brainchain-worker bridge --bounty <amount>\`.`);
143
+ const closed = (opensAt) => new Error(`${holds} The ${network.bountySymbol} faucet allows one claim a day, and this wallet can claim again at ${at(opensAt)}` +
144
+ (opensAt > closes ? `, after the appeal window closes at ${at(closes)}.` : `. Run appeal again then, before the appeal window closes at ${at(closes)}.`));
145
+ if (claims) {
146
+ const faucet = await chain.bountyFaucet();
147
+ if (now < faucet.opensAt)
148
+ throw closed(faucet.opensAt);
149
+ if (balance + faucet.amount < bond)
150
+ throw new Error(`${holds} The faucet's ${formatUnits(faucet.amount, 6)} ${network.bountySymbol} a day would not make up the rest.`);
151
+ }
152
+ // Checked for every transaction before any is sent: an approval alone spends gas and leaves the bond approved.
153
+ const need = ethNeeded([...(claims ? [GAS.faucet] : []), ...(allowance < bond ? [APPROVE_GAS] : []), APPEAL_GAS], gasPrice);
119
154
  if (eth < need)
120
- throw new Error(`${chain.account} holds ${shortOfGas(network, chain.account, eth, need, "the appeal")} Nothing was sent.`);
155
+ throw new Error(`${chain.account} holds ${shortOfGas(network, chain.account, eth, sent, need, "the appeal")} Nothing was sent.`);
156
+ if (claims) {
157
+ log(`Claiming ${network.bountySymbol} from the testnet faucet for the bond...`);
158
+ const claim = await chain.claimBounty().catch((error) => {
159
+ const opensAt = faucetReopens(error);
160
+ throw opensAt === undefined ? error : closed(opensAt);
161
+ });
162
+ log(`Claimed: ${claim}`);
163
+ }
121
164
  const approval = await chain.approveBounty(bond);
122
165
  const tx = await chain.appeal(id).catch((error) => {
123
166
  if (approval === undefined)
package/dist/key.js CHANGED
@@ -13,7 +13,7 @@ export const keyConfigured = (env = process.env) => Boolean(env[HANDOFF] || env.
13
13
  function keyFileProblem(text) {
14
14
  if (text === "")
15
15
  return "is empty";
16
- if (/^(0x)?[0-9a-fA-F]{64}$/.test(text))
16
+ if (/^(0x)?[0-9a-f]{64}$/i.test(text))
17
17
  return undefined;
18
18
  if (/[\r\n]/.test(text))
19
19
  return "holds more than one line";
@@ -21,7 +21,7 @@ function keyFileProblem(text) {
21
21
  return "holds a line in the form NAME=value";
22
22
  if (/^["'`]|["'`]$/.test(text))
23
23
  return "holds the key in quotes";
24
- const hex = text.startsWith("0x") ? text.slice(2) : text;
24
+ const hex = text.replace(/^0x/i, "");
25
25
  if (/^[0-9a-fA-F]+$/.test(hex))
26
26
  return `holds ${hex.length} hex characters where a key has 64`;
27
27
  return "holds characters that are not hex digits";
package/dist/mint.js CHANGED
@@ -272,7 +272,7 @@ export function createSettlementWatch(options) {
272
272
  entry.told = true;
273
273
  store.save();
274
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.");
275
+ "an hour later the Proof goes to the public pool. Proofs earn no income, and `run` without `--mint` mints none.");
276
276
  }
277
277
  await chain.approve(funds.price);
278
278
  const { tokenId, tx } = await chain.mint();
package/dist/network.js CHANGED
@@ -10,10 +10,13 @@ export const TESTNET = {
10
10
  explorer: "https://usebrainchain.com/testnet",
11
11
  stakeSymbol: "tBRAIN",
12
12
  bountySymbol: "tUSDG",
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`",
13
+ bountyFaucet: true,
14
+ gas: "Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com, which asks for a browser check and a Google account, " +
15
+ "so a person claims it, and move it here with `npx brainchain-worker bridge --eth 0.001`",
16
+ drip: {
17
+ wei: 100000000000000n,
18
+ request: (address) => `\`curl -X POST https://brain.testnet.usebrainchain.com/testnet/gas -H 'content-type: application/json' -d '{"address":"${address}"}'\``,
19
+ },
17
20
  proofs: {
18
21
  chainId: 46630,
19
22
  name: "Robinhood Chain testnet",
@@ -63,7 +66,7 @@ export const MAINNET = {
63
66
  explorer: "https://usebrainchain.com",
64
67
  stakeSymbol: "BRAIN",
65
68
  bountySymbol: "USDG",
66
- gas: () => "Send this address ETH on Robinhood Chain, then move it here with `npx brainchain-worker bridge --eth 0.001`",
69
+ gas: "Send this address ETH on Robinhood Chain, then move it here with `npx brainchain-worker bridge --eth 0.001`",
67
70
  buyStake: "Buy BRAIN on Robinhood Chain at ripples.run (https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and move it to BrainChain with " +
68
71
  `brainchain-worker bridge --stake <amount> (docs: https://docs.usebrainchain.com/bridging); the stake token on BrainChain is ${BRAIN}.`,
69
72
  proofs: {
@@ -75,6 +78,12 @@ export const MAINNET = {
75
78
  weth: "0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73",
76
79
  gas: "Send it a little ETH there for gas",
77
80
  income: "0xeb1b4168D4b87D3d9F0D64E13B7071251BBB63b7",
81
+ // Shipped's registry and StaticCallCheck. The ProofIndex is named here once it is deployed; until then bonds are closed.
82
+ bonds: {
83
+ registry: "0xD5Da523ee49CB7Fe855B0C6fa38707190A9D531F",
84
+ check: "0x2e7C4F0F2e6079C3B5F7Cd2440308234CBDc537D",
85
+ usdg: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
86
+ },
78
87
  },
79
88
  identity: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432",
80
89
  priors: { pool: "0x281210097f0de7A8FB6F87310AF0f089c9C8DE21", root: 20423n },
package/dist/proc.js ADDED
@@ -0,0 +1,34 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ /** One field of `ps` for a process, or undefined when it is gone or ps cannot tell. */
4
+ export function ps(pid, field) {
5
+ const result = spawnSync("ps", ["-o", `${field}=`, "-p", String(pid)], { encoding: "utf8" });
6
+ return result.status === 0 ? result.stdout.trim() : undefined;
7
+ }
8
+ /**
9
+ * When a Linux process started, from /proc, which slim container images have
10
+ * without ps. The kernel counts it in ticks since boot, 100 a second on every
11
+ * architecture Node.js runs on.
12
+ */
13
+ function procStart(pid) {
14
+ try {
15
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
16
+ const ticks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
17
+ const boot = Number(/^btime (\d+)$/m.exec(readFileSync("/proc/stat", "utf8"))?.[1]);
18
+ return Number.isSafeInteger(ticks) && boot > 0 ? boot * 1000 + ticks * 10 : undefined;
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ /** When a process started, in ms since the epoch, or undefined when it is gone or neither /proc nor ps can tell. */
25
+ export function startTime(pid) {
26
+ const started = procStart(pid);
27
+ if (started !== undefined)
28
+ return started;
29
+ const elapsed = /^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/.exec(ps(pid, "etime") ?? "");
30
+ if (!elapsed)
31
+ return undefined;
32
+ const [days, hours, minutes, seconds] = elapsed.slice(1).map((part) => Number(part ?? 0));
33
+ return Date.now() - (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
34
+ }
package/dist/propose.js CHANGED
@@ -2,7 +2,7 @@ import { BaseError, createPublicClient, createWalletClient, decodeErrorResult, d
2
2
  import { erc20Errors } from "./abi.js";
3
3
  import { bondsAbi, brainchainTransport, erc20Abi, executorAbi, RECEIPT_MS } from "./chain.js";
4
4
  import { chainOf } from "./network.js";
5
- import { stake, when } from "./standing.js";
5
+ import { gasHelp, stake, when } from "./standing.js";
6
6
  /** Every call the executor can be asked to make, by the contract it calls. */
7
7
  export const GOVERNED = {
8
8
  board: parseAbi([
@@ -198,6 +198,7 @@ export function createProposals(network, account) {
198
198
  account: account.address,
199
199
  now: async () => (await client.getBlock()).timestamp,
200
200
  ethBalance: () => client.getBalance({ address: account.address }),
201
+ sent: () => client.getTransactionCount({ address: account.address }),
201
202
  stakeBalance: () => client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
202
203
  bond: () => client.readContract({ ...proposals, functionName: "bond" }),
203
204
  decideWithin: () => client.readContract({ ...proposals, functionName: "decideWithin" }),
@@ -303,7 +304,7 @@ export async function propose(network, chain, call, uri, options) {
303
304
  throw new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, bond)} bond.${buy}`);
304
305
  }
305
306
  if (gas === 0n)
306
- throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas(chain.account)}.`);
307
+ throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${gasHelp(network, chain.account, 0n, await chain.sent())}.`);
307
308
  log(`Bonding ${stake(network, bond)} and proposing...`);
308
309
  const { id, tx } = await chain.propose(call, uri, bond);
309
310
  log(`Proposal ${id} is waiting for Jev (${tx}). Follow it with \`npx brainchain-worker proposal ${id}\`.`);
package/dist/standing.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { formatEther, formatUnits } from "viem";
2
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
- function grouped(amount, decimals) {
4
+ export function grouped(amount, decimals) {
5
5
  const [whole, fraction] = formatUnits(amount, decimals).split(".");
6
6
  return `${whole.replace(/\B(?=(\d{3})+$)/g, ",")}${fraction === undefined ? "" : `.${fraction}`}`;
7
7
  }
@@ -27,7 +27,7 @@ export async function snapshot(chain) {
27
27
  };
28
28
  }
29
29
  /** The highest gas limit seen for each transaction join can send, across testnet and mainnet. */
30
- const GAS = { faucet: 76000n, approve: 50000n, stake: 120000n, setPrice: 31000n, apply: 240000n };
30
+ export const GAS = { faucet: 76000n, approve: 50000n, stake: 120000n, setPrice: 31000n, apply: 240000n };
31
31
  /** The highest gas limit seen for delivering a result. */
32
32
  const SUBMIT_GAS = 104000n;
33
33
  /**
@@ -105,13 +105,42 @@ export function wallet(network, s, balance, bond, faucet, now) {
105
105
  const buy = network.buyStake ? `. ${network.buyStake}` : "";
106
106
  return `${has}; join needs ${stake(network, need)}${reopens}${unstaking}${buy}`;
107
107
  }
108
+ /**
109
+ * Where a wallet gets ETH for gas, given what it holds and how many
110
+ * transactions it has sent. The drip serves an address once and turns away one
111
+ * that holds a drip's worth, so only a wallet that has sent nothing and holds
112
+ * less is sure to get it; any other is sent to the faucet.
113
+ */
114
+ export function gasHelp(network, address, gas, sent) {
115
+ const { drip } = network;
116
+ if (!drip)
117
+ return network.gas;
118
+ if (sent > 0 || gas >= drip.wei)
119
+ return `${network.gas}. The gas drip serves each address once`;
120
+ const after = `${network.gas[0].toLowerCase()}${network.gas.slice(1)}`;
121
+ return `Ask the testnet gas drip first: ${drip.request(address)} sends ${formatEther(drip.wei)} ETH, enough to join, once per address. After that, ${after}`;
122
+ }
108
123
  /** What a wallet short of gas holds and needs, for join's refusal and for `status`. */
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
- /** 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, address, s, gas, gasPrice, claims) {
124
+ export const shortOfGas = (network, address, gas, sent, need, purpose) => `${formatEther(gas)} ETH on ${network.name}; ${purpose} needs about ${aboutEth(need)} ETH for gas. ${gasHelp(network, address, gas, sent)}.`;
125
+ /** How many results the wallet's ETH delivers at this gas price, the last of them at the fee cap the RPC checks. */
126
+ export function deliveriesLeft(gas, gasPrice) {
127
+ const price = gasPrice > 0n ? gasPrice : 1n;
128
+ const last = ethNeeded([SUBMIT_GAS], price);
129
+ return gas < last ? 0n : (gas - last) / (SUBMIT_GAS * price) + 1n;
130
+ }
131
+ /** About how many results the wallet's ETH delivers, for `status` and `run`. */
132
+ export const deliveries = (count) => `about ${count} ${count === 1n ? "delivery" : "deliveries"}`;
133
+ /**
134
+ * The wallet's ETH, and what more join or the next delivery needs, since a
135
+ * send the wallet cannot cover fails partway through. A worker with nothing to
136
+ * join hears how many results its ETH delivers.
137
+ */
138
+ export function gasLine(network, address, s, gas, gasPrice, claims, sent) {
112
139
  const limits = joinLimits(s, claims, false);
113
140
  const need = ethNeeded(limits.length > 0 ? limits : [SUBMIT_GAS], gasPrice);
114
- return gas >= need ? `${formatEther(gas)} ETH` : shortOfGas(network, address, gas, need, limits.length > 0 ? "join" : "delivering a result");
141
+ if (gas < need)
142
+ return shortOfGas(network, address, gas, sent, need, limits.length > 0 ? "join" : "delivering a result");
143
+ return limits.length > 0 ? `${formatEther(gas)} ETH` : `${formatEther(gas)} ETH, enough for ${deliveries(deliveriesLeft(gas, gasPrice))} at the current gas price`;
115
144
  }
116
145
  /** What join says when the wallet holds too little of a stake token that has no faucet. */
117
146
  export function shortOfStake(network, w, balance, need, now) {
package/dist/state.js CHANGED
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
3
3
  import { homedir, hostname } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
+ import { startTime } from "./proc.js";
5
6
  export const stateDir = () => join(homedir(), ".config", "brainchain-worker");
6
7
  /** The decision count a one-shot watch run stopped at, kept per decision log; undefined when there is none or it cannot be read. */
7
8
  export function readWatched(log, dir = stateDir()) {
@@ -113,6 +114,8 @@ const LOCK_STALE_MS = 15_000;
113
114
  const LOCK_WATCH_MS = 7_000;
114
115
  /** A takeover takes a few system calls, so one marker older than this was left by a crash. */
115
116
  const TAKEOVER_STALE_MS = 5_000;
117
+ /** How far a start time read from ps, which gives it to the second, can fall after the true one. */
118
+ const START_SLACK_MS = 2_000;
116
119
  /**
117
120
  * Where a pid means something. Containers sharing a config folder each have
118
121
  * their own numbering, so a holder's pid says nothing outside its own space.
@@ -170,14 +173,27 @@ function holderOf(text) {
170
173
  }
171
174
  return { pid: 0, space: "" };
172
175
  }
176
+ /**
177
+ * Whether the run that wrote a bare-pid lock is still going. 0.12 and earlier
178
+ * wrote the pid once and never touched the file again, so the lock's age says
179
+ * nothing. A process that started after the lock was written, as pid 1 does
180
+ * when a container restarts, has only come to reuse the pid.
181
+ */
182
+ function bareHeld(pid, writtenAt) {
183
+ if (!alive(pid))
184
+ return false;
185
+ const started = startTime(pid);
186
+ return started === undefined || started <= writtenAt + START_SLACK_MS;
187
+ }
173
188
  /** Whether the run that wrote a lock is still going. */
174
189
  function isHeld(file, { pid, space }, watchMs) {
175
190
  const touched = touchedAt(file);
176
- if (touched === undefined || Date.now() - touched >= LOCK_STALE_MS)
191
+ if (touched === undefined)
177
192
  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
193
  if (space === undefined)
180
- return alive(pid);
194
+ return bareHeld(pid, touched);
195
+ if (Date.now() - touched >= LOCK_STALE_MS)
196
+ return false;
181
197
  if (pid === 0)
182
198
  return true;
183
199
  if (space === pidSpace())
package/dist/work.js CHANGED
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { toBytes } from "viem";
4
4
  import { fetchBrief, PermanentError, reason, RECEIPT_MS } from "./chain.js";
5
+ import { ps, startTime } from "./proc.js";
5
6
  import { memoryStore } from "./state.js";
6
7
  /** The most the service accepts for one result. */
7
8
  export const MAX_RESULT_BYTES = 100_000;
@@ -17,6 +18,19 @@ const ANSI = /\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_
17
18
  export const stripAnsi = (text) => text.replace(ANSI, "");
18
19
  /** Characters that show nothing or turn the text around them, so text that holds them can read as other text. */
19
20
  export const HIDDEN = /[\u00ad\u061c\u180e\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/;
21
+ /**
22
+ * What a worker's name and description may not hold: control, format and
23
+ * separator characters, and the default-ignorable code points Unicode files
24
+ * as letters or marks, which no property escape reaches, plus the blank
25
+ * braille pattern. Each shows nothing, breaks the line or turns the text
26
+ * around, so a name that holds one can read as another.
27
+ */
28
+ const UNSEEN = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}͏ᅟᅠ឴឵᠋-᠏⁥⠀ㅤ︀-️ᅠ￰-￸\u{e0000}-\u{e0fff}]/u;
29
+ /** The first character of `text` that UNSEEN refuses, as U+XXXX, or undefined when there is none. */
30
+ export function unseen(text) {
31
+ const found = UNSEEN.exec(text)?.[0];
32
+ return found === undefined ? undefined : `U+${found.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
33
+ }
20
34
  /** Text from strangers, made safe to print as one terminal line. */
21
35
  export const printable = (text) => stripAnsi(text)
22
36
  .replace(/[\t\n\r]+/g, " ")
@@ -96,19 +110,6 @@ function signalGroup(pid, signal) {
96
110
  throw error;
97
111
  }
98
112
  }
99
- /** One field of `ps` for a process, or undefined when it is gone or ps cannot tell. */
100
- function ps(pid, field) {
101
- const result = spawnSync("ps", ["-o", `${field}=`, "-p", String(pid)], { encoding: "utf8" });
102
- return result.status === 0 ? result.stdout.trim() : undefined;
103
- }
104
- /** When a process started, in ms since the epoch, or undefined when it is gone or ps cannot tell. */
105
- function startTime(pid) {
106
- const elapsed = /^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/.exec(ps(pid, "etime") ?? "");
107
- if (!elapsed)
108
- return undefined;
109
- const [days, hours, minutes, seconds] = elapsed.slice(1).map((part) => Number(part ?? 0));
110
- return Date.now() - (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
111
- }
112
113
  function exists(pid) {
113
114
  try {
114
115
  process.kill(pid, 0);
@@ -359,7 +360,8 @@ export function createWork(options) {
359
360
  };
360
361
  const needGas = async () => {
361
362
  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
+ const help = await options.gasHelp?.().catch(() => undefined);
364
+ throw new Error(`The wallet has no ETH to pay gas for the submit${help ? `. ${help}` : ""}. The task is tried again`);
363
365
  }
364
366
  };
365
367
  const inTime = async (task) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-worker",
3
- "version": "0.14.0",
3
+ "version": "0.16.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>",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "brainchain-verify": "^0.4.0",
32
- "viem": "^2.0.0"
32
+ "viem": "^2.57.12"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^22.10.2",