brainchain-worker 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
@@ -170,7 +172,7 @@ A command that can run a shell still runs as your user, and reaches whatever you
170
172
  - **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
173
  - **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
174
  - **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.
175
+ - **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
176
 
175
177
  Whichever you use, keep only the worker's stake and gas in this wallet.
176
178
 
@@ -193,10 +195,10 @@ Whichever you use, keep only the worker's stake and gas in this wallet.
193
195
 
194
196
  | Command | |
195
197
  | --- | --- |
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. |
198
+ | `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
199
  | `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. |
200
+ | `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`. |
201
+ | `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
202
  | `price <USDG>` | Change the asking price, in tUSDG on testnet and USDG on mainnet. |
201
203
  | `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
204
  | `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. |
@@ -206,7 +208,7 @@ Whichever you use, keep only the worker's stake and gas in this wallet.
206
208
  | `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
209
  | `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
210
  | `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. |
211
+ | `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
212
  | `leave` | Start unstaking. The stake unlocks after 3 days, once no task of the worker's is in flight. |
211
213
  | `withdraw` | Withdraw the unlocked stake. |
212
214
  | `--version` | Print the version. |
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,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { verifyDecision } from "brainchain-verify";
3
3
  import { createPublicClient, http } from "viem";
4
- import { parseUnits } from "viem";
4
+ import { formatEther, parseUnits } from "viem";
5
5
  import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
6
6
  import { brainchainTransport, createChain, faucetReopens, reason, requireChainId, unhealthy } from "./chain.js";
7
7
  import { assetOf, bridge, createBridgeChain } from "./bridge.js";
@@ -9,17 +9,21 @@ import { claim, createIncome } from "./claim.js";
9
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, flightLine, gasLine, hireable, joinLimits, shortOfGas, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
12
+ import { deliveries, deliveriesLeft, ethNeeded, flightLine, gasHelp, gasLine, hireable, joinLimits, shortOfGas, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
13
13
  import { openStore, readWatched, writeWatched } from "./state.js";
14
14
  import { clearKeyFromEnvironment, keyConfigured, keyText, writeKeyFile } from "./key.js";
15
15
  import { createCreditChain, credit } from "./credit.js";
16
16
  import { appeal, createDisputeChain, dispute } from "./dispute.js";
17
17
  import { createReporter, createWatch, createWatchChain, requireWatcher } from "./watch.js";
18
- import { createWork, firstProgram, HIDDEN, npxUnderSupervisor, programFound, shellCommand, stopLeftover, stopper, watchStarter } from "./work.js";
18
+ import { createWork, firstProgram, npxUnderSupervisor, programFound, shellCommand, stopLeftover, stopper, unseen, watchStarter } from "./work.js";
19
19
  /** How many of the latest decisions a first watch --once checks. */
20
20
  const FIRST_RUN_DECISIONS = 10n;
21
21
  /** How long an idle run goes without a line before it says it is still waiting. */
22
22
  const HEARTBEAT_MS = 3_600_000;
23
+ /** Below this many deliveries' worth of gas, run warns after each delivery. */
24
+ const LOW_DELIVERIES = 3n;
25
+ /** How often a run that cannot be hired yet reads again whether it can. */
26
+ const HIREABLE_CHECK_MS = 300_000;
23
27
  /** A published record's bytes as the service serves them, capped at a megabyte. */
24
28
  async function fetchRecord(uri) {
25
29
  const response = await fetch(uri, { signal: AbortSignal.timeout(20_000) });
@@ -34,19 +38,20 @@ const { version } = createRequire(import.meta.url)("../package.json");
34
38
  /** Each command's lines in the usage text, in the order the text lists them. */
35
39
  const COMMAND_USAGE = {
36
40
  "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.`,
41
+ to a new file only you can read and prints the file's path in
42
+ place of the key.`,
38
43
  join: ` brainchain-worker join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]
39
44
  Stake the minimum and apply to take tasks. The name is at
40
45
  most 60 characters, the description at most 500. --agent-id
41
46
  (mainnet) names the worker's ERC-8004 agent on Robinhood Chain,
42
47
  so its BrainChain record is written to that identity.`,
43
- run: ` brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--no-mint]
48
+ run: ` brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--mint]
44
49
  Do every task Jev hires the worker for: the command reads the
45
50
  task on stdin and prints the deliverable on stdout, within
46
51
  --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
52
+ each task settles. --mint also mints the Proof of each task it is
53
+ paid for.`,
54
+ status: ` brainchain-worker status Stake, wallet, application, record, earnings and gas, with where each
50
55
  task in flight stands.`,
51
56
  price: ` brainchain-worker price <USDG> Change your asking price per task.`,
52
57
  mint: ` brainchain-worker mint [--count <n>] [--dry]
@@ -84,8 +89,9 @@ const COMMAND_USAGE = {
84
89
  window. Jev rules on it; a confident ruling decides.`,
85
90
  appeal: ` brainchain-worker appeal <task> Appeal the ruling on a task, when it went against the worker, within the
86
91
  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.`,
92
+ bounty), claimed from the test USDG faucet when the wallet holds less.
93
+ Jev rules again; the bond comes back unless Jev confidently rules the
94
+ same way. Testnet only for now.`,
89
95
  leave: ` brainchain-worker leave Start unstaking (3-day cooldown).`,
90
96
  withdraw: ` brainchain-worker withdraw Withdraw stake once the cooldown has passed.`,
91
97
  };
@@ -119,7 +125,8 @@ export class UsageError extends Error {
119
125
  const FLAGS = {
120
126
  "new-key": { file: false },
121
127
  join: { name: false, about: false, price: false, "agent-id": false },
122
- run: { exec: false, format: false, timeout: false, once: true, "no-mint": true },
128
+ // --no-mint is still accepted from before minting became opt-in; it changes nothing.
129
+ run: { exec: false, format: false, timeout: false, once: true, mint: true, "no-mint": true },
123
130
  status: {},
124
131
  price: {},
125
132
  leave: {},
@@ -193,7 +200,7 @@ function signer() {
193
200
  const raw = keyText()?.trim();
194
201
  if (!raw)
195
202
  throw new Error("Set BRAINCHAIN_WORKER_KEY to the worker's private key, or BRAINCHAIN_WORKER_KEY_FILE to a file that holds it. `npx brainchain-worker new-key` makes one.");
196
- const hex = raw.startsWith("0x") ? raw.slice(2) : raw;
203
+ const hex = raw.replace(/^0x/i, "");
197
204
  if (!/^[0-9a-fA-F]{64}$/.test(hex))
198
205
  throw new Error("The worker's key must be 64 hex characters, with or without 0x");
199
206
  const key = `0x${hex.toLowerCase()}`;
@@ -214,12 +221,12 @@ function price(value) {
214
221
  }
215
222
  const RUN = '`npx brainchain-worker run --exec "<command>"`';
216
223
  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)}.`);
224
+ async function noGas(chain, network) {
225
+ return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${gasHelp(network, chain.account, 0n, await chain.sent())}.`);
219
226
  }
220
227
  async function needGas(chain, network) {
221
228
  if ((await chain.ethBalance()) === 0n)
222
- throw noGas(chain, network);
229
+ throw await noGas(chain, network);
223
230
  }
224
231
  export async function join(chain, network, values) {
225
232
  const name = values.get("name")?.trim() ?? "";
@@ -229,15 +236,18 @@ export async function join(chain, network, values) {
229
236
  if (name.length > 60)
230
237
  throw new UsageError("--name is at most 60 characters");
231
238
  // 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");
239
+ const nameHides = unseen(name);
240
+ if (nameHides)
241
+ throw new UsageError(`--name must be one line with no invisible or direction-changing characters, and it holds ${nameHides}`);
234
242
  if (!about) {
235
243
  throw new UsageError("--about is required: say what work your agent does in up to 500 characters, since Jev reads it to decide and refuses an empty application");
236
244
  }
237
245
  if (about.length > 500)
238
246
  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");
247
+ // A description may run over several lines.
248
+ const aboutHides = unseen(about.replace(/[\t\n]/g, " "));
249
+ if (aboutHides)
250
+ throw new UsageError(`--about must have no invisible or direction-changing characters, and it holds ${aboutHides}`);
241
251
  const agentId = values.get("agent-id");
242
252
  if (agentId !== undefined && !/^(0|[1-9]\d{0,14})$/.test(agentId))
243
253
  throw new UsageError("--agent-id is the worker's ERC-8004 agent id, a whole number");
@@ -261,12 +271,19 @@ export async function join(chain, network, values) {
261
271
  }
262
272
  // A worker that has staked keeps its price unless --price changes it.
263
273
  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()]);
274
+ const [gas, gasPrice, bond, balance, timelock, sent] = await Promise.all([
275
+ chain.ethBalance(),
276
+ chain.gasPrice(),
277
+ chain.bond(),
278
+ chain.stakeBalance(),
279
+ chain.timelock(),
280
+ chain.sent(),
281
+ ]);
265
282
  const need = toStake + (applying ? bond : 0n);
266
283
  const claims = balance < need && !network.buyStake;
267
284
  // Checked before anything is sent: a wallet that runs out partway leaves the stake placed and the application unsent.
268
285
  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;
286
+ const lacksGas = gas < gasNeed ? `${chain.account} holds ${shortOfGas(network, chain.account, gas, sent, gasNeed, "join")}` : undefined;
270
287
  if (balance < need && network.buyStake) {
271
288
  const short = shortOfStake(network, w, balance, need, await chain.now());
272
289
  // Both needs at once, so a new worker does not bridge ETH only to learn it needs the stake token too.
@@ -320,9 +337,10 @@ export async function join(chain, network, values) {
320
337
  "the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
321
338
  }
322
339
  async function status(chain, network) {
323
- const [gas, gasPrice, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
340
+ const [gas, gasPrice, sent, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
324
341
  chain.ethBalance(),
325
342
  chain.gasPrice(),
343
+ chain.sent(),
326
344
  snapshot(chain),
327
345
  chain.held(),
328
346
  chain.record(),
@@ -342,7 +360,7 @@ async function status(chain, network) {
342
360
  Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
343
361
  Record ${record.paid} paid, ${record.refunded} refunded, ${record.inFlight.length} in flight${held > 0 ? " (stake held until they settle)" : ""}${record.inFlight.map((f) => `\n ${flightLine(f)}`).join("")}
344
362
  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))}`);
363
+ Gas ${gasLine(network, chain.account, s, gas, gasPrice, !network.buyStake && stakeBalance < s.toStake + (s.applying ? bond : 0n), sent)}`);
346
364
  }
347
365
  async function run(network, { key, account }, values, once, mints) {
348
366
  const exec = values.get("exec");
@@ -379,9 +397,23 @@ async function run(network, { key, account }, values, once, mints) {
379
397
  stopping: stop.stopping,
380
398
  // A cron job sees a failed task as a failed run.
381
399
  failed: once ? () => void (process.exitCode = 1) : undefined,
382
- delivered: settlements.track,
383
- gasHelp: network.gas(account.address),
400
+ delivered(task) {
401
+ settlements.track(task);
402
+ void lowGas();
403
+ },
404
+ gasHelp: async () => gasHelp(network, account.address, 0n, await chain.sent()),
384
405
  });
406
+ // The drip pays for only a few deliveries, so the log says when the wallet is down to its last ones.
407
+ const lowGas = async () => {
408
+ const [gas, price, sent] = await Promise.all([chain.ethBalance(), chain.gasPrice(), chain.sent()]).catch(() => []);
409
+ if (gas === undefined || price === undefined || sent === undefined)
410
+ return;
411
+ const left = deliveriesLeft(gas, price);
412
+ if (left >= LOW_DELIVERIES)
413
+ return;
414
+ const enough = left === 0n ? "not enough for another delivery" : `enough for about ${left} more ${left === 1n ? "delivery" : "deliveries"}`;
415
+ log(`warning: the wallet holds ${formatEther(gas)} ETH for gas, ${enough}. ${gasHelp(network, account.address, gas, sent)}.`);
416
+ };
385
417
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
386
418
  process.on(signal, stop.signal);
387
419
  if (process.env.npm_execpath !== undefined) {
@@ -406,12 +438,14 @@ async function run(network, { key, account }, values, once, mints) {
406
438
  log(`could not read the wallet's ETH balance: ${reason(error)}`);
407
439
  }
408
440
  if (gas === 0n)
409
- throw noGas(chain, network);
410
- const [line, sick] = await Promise.all([
441
+ throw await noGas(chain, network);
442
+ const held = gas;
443
+ const [line, sick, left] = await Promise.all([
411
444
  snapshot(chain).then((s) => hireable(s, network, "run"), (error) => `could not check (${reason(error)})`),
412
445
  unhealthy(network.service),
446
+ held === undefined ? undefined : chain.gasPrice().then((price) => deliveriesLeft(held, price), () => undefined),
413
447
  ]);
414
- log(`worker ${chain.account} on ${network.name}; hireable: ${line}`);
448
+ log(`worker ${chain.account} on ${network.name}; hireable: ${line}${left === undefined ? "" : `; gas for ${deliveries(left)}`}`);
415
449
  if (sick)
416
450
  log(`warning: ${sick}`);
417
451
  if (once) {
@@ -428,12 +462,16 @@ async function run(network, { key, account }, values, once, mints) {
428
462
  log("waiting for tasks (Ctrl-C to stop)");
429
463
  // Minting waits on receipts from another chain, so it runs beside the work, one round at a time.
430
464
  let minting;
465
+ const hireChange = hireWatch(line, () => snapshot(chain).then((s) => hireable(s, network, "run"), () => undefined));
431
466
  while (!stop.stopping()) {
432
467
  await work.round().catch((error) => log(`round failed: ${reason(error)}`));
433
468
  minting ??= settlements
434
469
  .round()
435
470
  .catch((error) => log(`settlement round failed: ${reason(error)}`))
436
471
  .finally(() => (minting = undefined));
472
+ const changed = stop.stopping() ? undefined : await hireChange();
473
+ if (changed !== undefined)
474
+ log(`hireable: ${changed}`);
437
475
  if (quietFor() >= HEARTBEAT_MS && !work.busy() && !stop.stopping()) {
438
476
  log(`still waiting for tasks; hireable: ${await snapshot(chain).then((s) => hireable(s, network, "run"), (error) => `could not check (${reason(error)})`)}`);
439
477
  }
@@ -443,6 +481,26 @@ async function run(network, { key, account }, values, once, mints) {
443
481
  await minting;
444
482
  log("stopped");
445
483
  }
484
+ /**
485
+ * Reads again, every few minutes until the worker can be hired, whether it can,
486
+ * and returns the new answer when it changed. A run started while Jev decides
487
+ * on the application then says when Jev allows the worker, and when its
488
+ * timelock has passed.
489
+ */
490
+ export function hireWatch(first, check, everyMs = HIREABLE_CHECK_MS, now = Date.now) {
491
+ let last = first;
492
+ let checkedAt = now();
493
+ return async () => {
494
+ if (last === "yes" || now() - checkedAt < everyMs)
495
+ return undefined;
496
+ checkedAt = now();
497
+ const line = await check();
498
+ if (line === undefined || line === last)
499
+ return undefined;
500
+ last = line;
501
+ return line;
502
+ };
503
+ }
446
504
  /** Run's log, with each line's time, and how long it has been quiet, so an idle run can say it is still there. */
447
505
  export function timedLog(write, now = Date.now) {
448
506
  let last = now();
@@ -602,7 +660,7 @@ export async function main(argv) {
602
660
  return propose(network, createProposals(network, worker.account), call, uri, { dry: switches.has("dry"), log: console.log });
603
661
  }
604
662
  if (command === "run")
605
- return run(network, worker, values, switches.has("once"), !switches.has("no-mint"));
663
+ return run(network, worker, values, switches.has("once"), switches.has("mint"));
606
664
  const chain = createChain(network, worker.account);
607
665
  switch (command) {
608
666
  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: {
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.15.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",