brainchain-worker 0.3.0 → 0.4.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
@@ -46,9 +46,22 @@ npx brainchain-worker run --exec 'claude -p --tools ""'
46
46
  - **Review.** If Jev allows the worker, it can be hired once a 48-hour timelock passes.
47
47
  - **Pay.** Accepted work is paid in USDG, less the 3% protocol fee, once the 6-hour dispute window closes. The USDG stays on BrainChain until it is [bridged back](https://docs.usebrainchain.com/bridging) to Robinhood Chain.
48
48
 
49
- ## Proof income
49
+ ## Proofs
50
50
 
51
- BrainChain Proofs are NFTs on Robinhood Chain, and each one 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:
51
+ 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.
52
+
53
+ **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.
54
+
55
+ `mint` catches up on paid tasks still waiting for their Proof, such as tasks paid while `run` was stopped:
56
+
57
+ ```sh
58
+ npx brainchain-worker mint --dry # the tasks waiting, what they cost and what the wallet lacks; nothing sent
59
+ npx brainchain-worker mint # mints up to 10, one for each task
60
+ ```
61
+
62
+ 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.
63
+
64
+ **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:
52
65
 
53
66
  ```sh
54
67
  BRAINCHAIN_NETWORK=mainnet npx brainchain-worker claim --dry # each Proof and what it is due, nothing sent
@@ -81,8 +94,8 @@ Briefs are written by strangers, and some will try to get your agent to do somet
81
94
  ## Keep it running
82
95
 
83
96
  - **Stopping.** A `run` with no task in hand stops at once on Ctrl-C or SIGTERM. During a task, the first one stops `run` from taking new tasks and lets the current command finish and deliver; a second one stops the command at once. SIGHUP, which a closed terminal or a dropped SSH session sends, stops the command and `run` at once.
84
- - **Restarts.** `run` saves unfinished work to `~/.config/brainchain-worker/<address>.json`, readable only by you: a result waiting to be uploaded or submitted, a submit already sent, when each failed task is due again, and how far it has read the chain. A restart, or the next `--once` run, picks up from there without running the command again for a result it already has, and waits for a sent submit before sending another. A command left running by a `run` that was killed is stopped when the next one starts. If the file is damaged, `run` moves it aside and starts without saved work.
85
- - **One run per worker.** A second `run` for the same address refuses to start while the first is alive.
97
+ - **Restarts.** `run` saves unfinished work to `~/.config/brainchain-worker/<address>.<network>.json`, readable only by you: a result waiting to be uploaded or submitted, a submit already sent, when each failed task is due again, and how far it has read the chain, and the delivered tasks waiting for their Proof. A restart, or the next `--once` run, picks up from there without running the command again for a result it already has, and waits for a sent submit before sending another. A command left running by a `run` that was killed is stopped when the next one starts. If the file is damaged, `run` moves it aside and starts without saved work.
98
+ - **One run per worker and network.** State is kept per network, where `<network>` is `testnet` or `mainnet`, so one key can run on both at the same time, and a second `run` for the same address on the same network refuses to start while the first is alive.
86
99
  - **Under a supervisor.** systemd sends SIGTERM to every process of a service by default, the command included, and every supervisor kills what is left after a timeout: 90 seconds for systemd, 10 for Docker and 30 for Kubernetes. For a stop that lets the current task deliver, signal the worker alone and allow the task timeout plus two minutes, 22 minutes with the default `--timeout`:
87
100
 
88
101
  | Supervisor | Settings |
@@ -97,9 +110,10 @@ Briefs are written by strangers, and some will try to get your agent to do somet
97
110
  | --- | --- |
98
111
  | `new-key` | Print a fresh private key and its address. |
99
112
  | `join --name <name> --about <text> [--price <tUSDG>]` | Claim tBRAIN from the faucet if needed, stake the minimum and apply. Jev reads the name and description to decide, and refuses an empty one. The name is at most 60 characters and the description at most 500. 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. |
100
- | `run --exec "<command>" [--format prompt\|json] [--timeout <minutes>] [--once]` | Do every task the worker is hired for, earliest deadline first. `--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. |
113
+ | `run --exec "<command>" [--format prompt\|json] [--timeout <minutes>] [--once] [--no-mint]` | Do every task the worker is hired for, earliest deadline first, and mint the Proof of each one that is paid. `--timeout` sets how many minutes the command may spend on one task: 20 by default, at most 1440, fractions allowed. `--once` does one pass and exits, with code 1 if a task failed or was left too close to its deadline, for a cron job. `--no-mint` mints no Proofs. |
101
114
  | `status` | Whether the worker can be hired, its stake, its tBRAIN balance, price, record and earnings. Below the minimum stake, it also says where `join` will find the tBRAIN it needs. |
102
115
  | `price <tUSDG>` | Change the asking price. |
116
+ | `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. |
103
117
  | `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. |
104
118
  | `leave` | Start unstaking. The stake unlocks after 3 days, once no task of the worker's is in flight. |
105
119
  | `withdraw` | Withdraw the unlocked stake. |
package/dist/chain.js CHANGED
@@ -355,6 +355,11 @@ export function createChain(network, account, store = memoryStore()) {
355
355
  })));
356
356
  return { applications, change };
357
357
  },
358
+ /** How a task settled, or undefined while it has not. */
359
+ async settled(task) {
360
+ const { status } = await client.readContract({ ...board, functionName: "get", args: [task] });
361
+ return status === Status.Paid ? "paid" : status === Status.Refunded ? "refunded" : undefined;
362
+ },
358
363
  /** Tasks this worker was hired for, by outcome, and what it was paid after fees. */
359
364
  async record() {
360
365
  const latest = await client.getBlockNumber();
package/dist/claim.js CHANGED
@@ -1,5 +1,6 @@
1
- import { createPublicClient, createWalletClient, defineChain, formatEther, http, parseAbi, parseEventLogs } from "viem";
1
+ import { createPublicClient, createWalletClient, formatEther, http, parseAbi, parseEventLogs } from "viem";
2
2
  import { RECEIPT_MS } from "./chain.js";
3
+ import { proofsChainOf } from "./network.js";
3
4
  export const proofsIncomeAbi = parseAbi([
4
5
  "function due(uint256 tokenId) view returns (uint256)",
5
6
  "function claimMany(uint256[] tokenIds)",
@@ -9,15 +10,8 @@ export const proofsIncomeAbi = parseAbi([
9
10
  ]);
10
11
  /** The most Proofs one claim pays for, which keeps the transaction far inside a block. */
11
12
  export const MAX_CLAIM = 200;
12
- const MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11";
13
13
  export function createIncome(proofs, income, account) {
14
- const chain = defineChain({
15
- id: proofs.chainId,
16
- name: proofs.name,
17
- nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
18
- rpcUrls: { default: { http: [proofs.rpc] } },
19
- contracts: { multicall3: { address: MULTICALL3 } },
20
- });
14
+ const chain = proofsChainOf(proofs);
21
15
  const transport = http(proofs.rpc, { timeout: 20_000, retryCount: 3 });
22
16
  const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
23
17
  const wallet = createWalletClient({ account, chain, transport });
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import { join as joinPath } from "node:path";
4
3
  import { formatEther, parseUnits } from "viem";
5
4
  import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
6
5
  import { createChain, faucetReopens, reason, unhealthy } from "./chain.js";
7
6
  import { claim, createIncome } from "./claim.js";
7
+ import { createProofMinter, createProofsChain, MAX_MINT, mint } from "./mint.js";
8
8
  import { resolveNetwork } from "./network.js";
9
9
  import { hireable, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
10
- import { fileStore, lock, stateDir } from "./state.js";
10
+ import { openStore } from "./state.js";
11
11
  import { createWork, shellCommand, stopLeftover, stopper } from "./work.js";
12
12
  const { version } = createRequire(import.meta.url)("../package.json");
13
13
  const USAGE = `brainchain-worker ${version}
@@ -19,12 +19,16 @@ Usage:
19
19
  brainchain-worker join --name <name> --about <text> [--price <USDG>]
20
20
  Stake the minimum and apply to take tasks. The name is at
21
21
  most 60 characters, the description at most 500.
22
- brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once]
22
+ brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--no-mint]
23
23
  Do every task Jev hires you for: the command reads the
24
24
  task on stdin and prints the deliverable on stdout, within
25
- --timeout minutes (20 by default, at most 1440).
25
+ --timeout minutes (20 by default, at most 1440). The worker
26
+ mints the Proof of each task it is paid for, unless --no-mint.
26
27
  brainchain-worker status Stake, wallet, application, record and earnings.
27
28
  brainchain-worker price <USDG> Change your asking price per task.
29
+ brainchain-worker mint [--count <n>] [--dry]
30
+ Mint a Proof for each paid task waiting for one, up to 10 or
31
+ --count, at 0.001 WETH each on Robinhood Chain. --dry sends nothing.
28
32
  brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
29
33
  --dry lists what is due and sends nothing.
30
34
  brainchain-worker leave Start unstaking (3-day cooldown).
@@ -39,8 +43,8 @@ Environment:
39
43
  localhost or 127.0.0.1.
40
44
 
41
45
  The command sees the worker's environment without any BRAINCHAIN_ variable, plus BRAINCHAIN_TASK_ID.
42
- Unfinished work is kept in ~/.config/brainchain-worker/<address>.json across restarts, and one
43
- run at a time works for each address.
46
+ Unfinished work is kept in ~/.config/brainchain-worker/<address>.<network>.json across restarts,
47
+ and one run at a time works for each address on each network.
44
48
 
45
49
  Docs: https://docs.usebrainchain.com/run-a-worker`;
46
50
  /** A mistake in the command line. Every other error is printed after the program's name. */
@@ -49,12 +53,13 @@ class UsageError extends Error {
49
53
  /** The flags each command takes; `true` marks a switch, which takes no value. */
50
54
  const FLAGS = {
51
55
  join: { name: false, about: false, price: false },
52
- run: { exec: false, format: false, timeout: false, once: true },
56
+ run: { exec: false, format: false, timeout: false, once: true, "no-mint": true },
53
57
  status: {},
54
58
  price: {},
55
59
  leave: {},
56
60
  withdraw: {},
57
61
  claim: { dry: true },
62
+ mint: { count: false, dry: true },
58
63
  };
59
64
  const POSITIONAL = { price: 1 };
60
65
  function parse(command, args) {
@@ -208,7 +213,7 @@ async function status(chain, network) {
208
213
  Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, bountyBalance)}
209
214
  Gas ${formatEther(gas)} ETH`);
210
215
  }
211
- async function run(network, { key, account }, values, once) {
216
+ async function run(network, { key, account }, values, once, mints) {
212
217
  const exec = values.get("exec");
213
218
  if (!exec)
214
219
  throw new UsageError(`--exec is required: the command that does the work, such as --exec 'claude -p --tools ""'`);
@@ -220,12 +225,11 @@ async function run(network, { key, account }, values, once) {
220
225
  throw new UsageError("--timeout is the most minutes the command may spend on one task: more than 0, at most 1440, and 20 by default");
221
226
  }
222
227
  const log = (message) => console.log(`${new Date().toISOString().slice(0, 19)}Z ${message}`);
223
- const file = joinPath(stateDir(), account.address);
224
- lock(`${file}.lock`);
225
- const store = fileStore(`${file}.json`, network.contracts.taskBoard, (message) => log(`warning: ${message}`));
228
+ const store = openStore(account.address, network, (message) => log(`warning: ${message}`));
226
229
  // The lock rules out a live run, so a command still in the state was left by one that died.
227
230
  await stopLeftover(store, log);
228
231
  const chain = createChain(network, account, store);
232
+ const proofs = createProofMinter({ network, chain: createProofsChain(network.proofs, account), settled: chain.settled, store, log, enabled: mints });
229
233
  const command = shellCommand(exec);
230
234
  // An idle stop keeps the exit code of a task that failed earlier in a --once pass.
231
235
  const stop = stopper({ busy: () => work.busy(), kill: command.kill, log, exit: (code) => process.exit(code || process.exitCode) });
@@ -240,6 +244,7 @@ async function run(network, { key, account }, values, once) {
240
244
  stopping: stop.stopping,
241
245
  // A cron job sees a failed task as a failed run.
242
246
  failed: once ? () => void (process.exitCode = 1) : undefined,
247
+ delivered: proofs.track,
243
248
  });
244
249
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
245
250
  process.on(signal, stop.signal);
@@ -273,14 +278,22 @@ async function run(network, { key, account }, values, once) {
273
278
  });
274
279
  if (result?.hired === 0 && result.delivered === 0)
275
280
  log("no task is hired to this worker right now");
281
+ await proofs.round().catch((error) => log(`proofs round failed: ${reason(error)}`));
276
282
  return;
277
283
  }
278
284
  log("waiting for tasks (Ctrl-C to stop)");
285
+ // Minting waits on receipts from another chain, so it runs beside the work, one round at a time.
286
+ let minting;
279
287
  while (!stop.stopping()) {
280
288
  await work.round().catch((error) => log(`round failed: ${reason(error)}`));
289
+ minting ??= proofs
290
+ .round()
291
+ .catch((error) => log(`proofs round failed: ${reason(error)}`))
292
+ .finally(() => (minting = undefined));
281
293
  for (let i = 0; i < 15 && !stop.stopping(); i++)
282
294
  await new Promise((resolve) => setTimeout(resolve, 1_000));
283
295
  }
296
+ await minting;
284
297
  log("stopped");
285
298
  }
286
299
  async function main(argv) {
@@ -303,9 +316,16 @@ async function main(argv) {
303
316
  if (command === "claim") {
304
317
  return claim(network, (income) => createIncome(network.proofs, income, signer().account), { dry: switches.has("dry"), log: console.log });
305
318
  }
319
+ if (command === "mint") {
320
+ const count = values.get("count");
321
+ if (count !== undefined && !(/^\d{1,2}$/.test(count) && Number(count) >= 1 && Number(count) <= MAX_MINT)) {
322
+ throw new UsageError(`--count is how many Proofs to mint: 1 to ${MAX_MINT}`);
323
+ }
324
+ return mint(network, createProofsChain(network.proofs, signer().account), { count: count === undefined ? undefined : Number(count), dry: switches.has("dry"), log: console.log });
325
+ }
306
326
  const worker = signer();
307
327
  if (command === "run")
308
- return run(network, worker, values, switches.has("once"));
328
+ return run(network, worker, values, switches.has("once"), !switches.has("no-mint"));
309
329
  const chain = createChain(network, worker.account);
310
330
  switch (command) {
311
331
  case "join":
package/dist/mint.js ADDED
@@ -0,0 +1,278 @@
1
+ import { createPublicClient, createWalletClient, formatEther, http, parseAbi, parseEventLogs, zeroAddress, } from "viem";
2
+ import { reason, RECEIPT_MS } from "./chain.js";
3
+ import { proofsChainOf } from "./network.js";
4
+ import { when } from "./standing.js";
5
+ export const collectionAbi = parseAbi([
6
+ "function PRICE_QUOTE() view returns (uint256)",
7
+ "function mint(uint256 quantity, uint256 minRoutedTotal) payable",
8
+ "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)",
9
+ "error SupplyExceeded()",
10
+ "error WalletCapExceeded()",
11
+ "error Ended()",
12
+ "error NotStarted()",
13
+ ]);
14
+ export const wethAbi = parseAbi([
15
+ "function balanceOf(address) view returns (uint256)",
16
+ "function allowance(address owner, address spender) view returns (uint256)",
17
+ "function approve(address spender, uint256 amount) returns (bool)",
18
+ "function faucet() returns (uint256)",
19
+ "function nextFaucetAt(address) view returns (uint256)",
20
+ "error FaucetCoolingDown(uint256 nextAt)",
21
+ ]);
22
+ /** The most pieces one mint run pays for. */
23
+ export const MAX_MINT = 10;
24
+ /** How often a delivered task's status is read until it settles. */
25
+ const STATUS_SEC = 300;
26
+ /** Grading, a dispute and its ruling take up to 6 hours each on mainnet, so a task unsettled after a day is left. */
27
+ const WATCH_SEC = 86_400;
28
+ /** A day after payment the task joins the public pool, so the worker's own attempts stop an hour short of it. */
29
+ const OWN_SEC = 23 * 3_600;
30
+ /** The least time between two mint attempts for one task. */
31
+ const RETRY_SEC = 3_600;
32
+ /** The service lists a payment a round or two after it lands; a task still unlisted this long after has its piece. */
33
+ const LISTING_SEC = 900;
34
+ /** The piece a mint's receipt shows the collection minting to `to`. */
35
+ export function mintedToken(logs, collection, to) {
36
+ // WETH's Transfer shares the event's signature but not its indexed token id, so only the collection's own logs decode.
37
+ return parseEventLogs({ abi: collectionAbi, eventName: "Transfer", args: { from: zeroAddress, to }, logs }).find((log) => log.address.toLowerCase() === collection.toLowerCase())?.args.tokenId;
38
+ }
39
+ export function createProofsChain(proofs, account) {
40
+ const chain = proofsChainOf(proofs);
41
+ const transport = http(proofs.rpc, { timeout: 20_000, retryCount: 3 });
42
+ const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
43
+ const wallet = createWalletClient({ account, chain, transport });
44
+ const weth = { address: proofs.weth, abi: wethAbi };
45
+ const collection = { address: proofs.collection, abi: collectionAbi };
46
+ async function send(request) {
47
+ const hash = await wallet.writeContract(request);
48
+ const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
49
+ if (receipt.status !== "success")
50
+ throw new Error(`Transaction ${hash} reverted`);
51
+ return { hash, receipt };
52
+ }
53
+ return {
54
+ account: account.address,
55
+ price: () => client.readContract({ ...collection, functionName: "PRICE_QUOTE" }),
56
+ weth: () => client.readContract({ ...weth, functionName: "balanceOf", args: [account.address] }),
57
+ ethBalance: () => client.getBalance({ address: account.address }),
58
+ faucetOpensAt: () => client.readContract({ ...weth, functionName: "nextFaucetAt", args: [account.address] }),
59
+ async claimWeth() {
60
+ await send({ ...weth, functionName: "faucet" });
61
+ },
62
+ async approve(amount) {
63
+ const current = await client.readContract({ ...weth, functionName: "allowance", args: [account.address, proofs.collection] });
64
+ if (current >= amount)
65
+ return;
66
+ await send({ ...weth, functionName: "approve", args: [proofs.collection, amount] });
67
+ },
68
+ async mint() {
69
+ const { hash, receipt } = await send({ ...collection, functionName: "mint", args: [1n, 0n] });
70
+ const tokenId = mintedToken(receipt.logs, proofs.collection, account.address);
71
+ if (tokenId === undefined)
72
+ throw new Error(`The mint ${hash} minted no piece to ${account.address}`);
73
+ return { tokenId, tx: hash };
74
+ },
75
+ };
76
+ }
77
+ /**
78
+ * The worker's paid tasks the service has bound no piece to, in the order it
79
+ * binds the worker's own mints to them: oldest payment first.
80
+ */
81
+ export async function pendingTasks(network, worker, fetcher = fetch) {
82
+ const host = new URL(network.service).host;
83
+ const response = await fetcher(`${network.service}/proofs/${network.chainId}/pending/${worker}`, { signal: AbortSignal.timeout(15_000) }).catch(() => {
84
+ throw new Error(`Could not reach the BrainChain service at ${host}`);
85
+ });
86
+ if (!response.ok) {
87
+ await response.body?.cancel();
88
+ if (response.status === 404)
89
+ throw new Error(`The BrainChain service at ${host} does not list the tasks waiting for a Proof yet`);
90
+ throw new Error(`The BrainChain service at ${host} answered ${response.status} when asked for the tasks waiting for a Proof`);
91
+ }
92
+ const tasks = (await response.json().catch(() => undefined))?.tasks;
93
+ if (!Array.isArray(tasks) || !tasks.every((id) => typeof id === "string" && /^\d{1,78}$/.test(id))) {
94
+ throw new Error(`The BrainChain service at ${host} listed the tasks waiting for a Proof in a form the worker cannot read`);
95
+ }
96
+ return [...new Set(tasks)].map((id) => BigInt(id));
97
+ }
98
+ const proofCount = (n) => `${n} Proof${n === 1 ? "" : "s"}`;
99
+ /** "task 12", "tasks 12 and 15", "tasks 12, 15 and 18". */
100
+ function taskList(tasks) {
101
+ const ids = tasks.map(String);
102
+ if (ids.length === 1)
103
+ return `task ${ids[0]}`;
104
+ return `tasks ${ids.slice(0, -1).join(", ")} and ${ids.at(-1)}`;
105
+ }
106
+ const wall = () => Math.floor(Date.now() / 1000);
107
+ /**
108
+ * What the wallet lacks to pay for `count` pieces, if anything. Short of test
109
+ * WETH while the faucet is open, it claims from the faucet first, calling
110
+ * `claiming` as it starts, unless `dry`, which only sets `claim`.
111
+ */
112
+ async function afford(proofs, chain, count, options) {
113
+ const [price, eth] = await Promise.all([chain.price(), chain.ethBalance()]);
114
+ const cost = price * BigInt(count);
115
+ let weth = await chain.weth();
116
+ let opensAt = 0n;
117
+ if (weth < cost && proofs.faucet) {
118
+ opensAt = await chain.faucetOpensAt();
119
+ // The claim pays gas too.
120
+ if (opensAt <= BigInt(options.now()) && eth > 0n) {
121
+ if (options.dry)
122
+ return { price, claim: true };
123
+ options.claiming();
124
+ await chain.claimWeth();
125
+ [weth, opensAt] = await Promise.all([chain.weth(), chain.faucetOpensAt()]);
126
+ }
127
+ }
128
+ const needWeth = weth < cost;
129
+ const needEth = eth === 0n;
130
+ if (!needWeth && !needEth)
131
+ return { price };
132
+ const fixes = [];
133
+ if (needWeth && !proofs.faucet)
134
+ fixes.push(`Send it ${formatEther(cost - weth)} WETH (${proofs.weth}) on ${proofs.name}.`);
135
+ if (needEth)
136
+ fixes.push(`${proofs.gas}.`);
137
+ if (needWeth && proofs.faucet) {
138
+ fixes.push(opensAt <= BigInt(options.now())
139
+ ? "The worker claims test WETH from the faucet once it has ETH."
140
+ : `The test WETH faucet allows one claim a day, and this wallet can claim again at ${when(opensAt)}.`);
141
+ }
142
+ const holds = `${chain.account} holds ${formatEther(weth)} WETH${needEth ? " and no ETH" : ""} on ${proofs.name}`;
143
+ return { price, missing: `${holds}, and ${proofCount(count)} cost${count === 1 ? "s" : ""} ${formatEther(cost)} WETH plus gas. ${fixes.join(" ")}` };
144
+ }
145
+ /**
146
+ * Mints a piece for each of the worker's paid tasks the service lists without
147
+ * one, up to `count` and at most MAX_MINT, from the worker's key. The service
148
+ * binds each piece the worker mints to its oldest paid task waiting for one.
149
+ */
150
+ export async function mint(network, chain, options) {
151
+ const { log } = options;
152
+ const { proofs } = network;
153
+ const listed = await pendingTasks(network, chain.account, options.fetcher);
154
+ if (listed.length === 0)
155
+ return log(`The BrainChain service lists no paid task of ${chain.account} waiting for a Proof, so there is nothing to mint.`);
156
+ const batch = listed.slice(0, Math.min(options.count ?? MAX_MINT, MAX_MINT));
157
+ const left = listed.length - batch.length;
158
+ const rest = left > 0 ? ` ${left} more paid task${left === 1 ? " waits" : "s wait"} for a Proof: run mint again.` : "";
159
+ const funds = await afford(proofs, chain, batch.length, { dry: options.dry, claiming: () => log("Claiming test WETH from the faucet..."), now: options.now ?? wall });
160
+ const each = `${formatEther(funds.price)} WETH each plus gas`;
161
+ if (options.dry) {
162
+ log(`Dry run, nothing sent: mint would mint ${proofCount(batch.length)} on ${proofs.name} for ${taskList(batch)}, at ${each}.${rest}`);
163
+ if (funds.claim)
164
+ log("The wallet is short of WETH, so mint would claim test WETH from the faucet first.");
165
+ if (funds.missing)
166
+ log(funds.missing);
167
+ return;
168
+ }
169
+ if (funds.missing)
170
+ throw new Error(`${funds.missing} Then run mint again.`);
171
+ log(`Minting ${proofCount(batch.length)} on ${proofs.name} at ${each}...`);
172
+ for (const task of batch) {
173
+ await chain.approve(funds.price);
174
+ const { tokenId, tx } = await chain.mint();
175
+ log(`Minted Proof ${tokenId} for task ${task} (${tx}).`);
176
+ }
177
+ if (rest)
178
+ log(rest.trim());
179
+ }
180
+ /**
181
+ * What `run` does for Proofs: it watches each task the worker delivers until
182
+ * it settles and mints a piece for each paid one, at most once an hour per
183
+ * task, and only while the service lists the task as waiting for a piece. The
184
+ * watched tasks live in the state, so a restart neither forgets nor repeats a
185
+ * mint. With `enabled` false, as under --no-mint, it does nothing.
186
+ */
187
+ export function createProofMinter(options) {
188
+ const { network, chain, store, log } = options;
189
+ const clock = options.now ?? wall;
190
+ const watched = () => (store.state.proofs ??= {});
191
+ const drop = (key, why) => {
192
+ delete watched()[key];
193
+ store.save();
194
+ if (why)
195
+ log(`task ${key}: ${why}`);
196
+ };
197
+ const later = (entry, seconds) => {
198
+ entry.nextAt = clock() + seconds;
199
+ store.save();
200
+ };
201
+ /** Reads whether a delivered task settled; true once it is paid. */
202
+ async function settle(key, entry) {
203
+ let outcome;
204
+ try {
205
+ outcome = await options.settled(BigInt(key));
206
+ }
207
+ catch (error) {
208
+ later(entry, STATUS_SEC);
209
+ log(`task ${key}: could not read whether it was paid (${reason(error)})`);
210
+ return false;
211
+ }
212
+ if (outcome === "refunded") {
213
+ drop(key, "refunded to its poster, so it gets no proof");
214
+ return false;
215
+ }
216
+ if (outcome === undefined) {
217
+ if (clock() - entry.deliveredAt >= WATCH_SEC)
218
+ drop(key, "not settled a day after delivery, so the worker mints no proof for it");
219
+ else
220
+ later(entry, STATUS_SEC);
221
+ return false;
222
+ }
223
+ entry.paidAt = clock();
224
+ store.save();
225
+ return true;
226
+ }
227
+ async function mintFor(key, entry, listed) {
228
+ const paidAt = entry.paidAt;
229
+ if (clock() - paidAt >= OWN_SEC)
230
+ return drop(key, "no proof minted within a day of payment, so the task joins the public pool");
231
+ if (Array.isArray(listed) && !listed.includes(BigInt(key))) {
232
+ if (clock() - paidAt < LISTING_SEC)
233
+ return later(entry, 60);
234
+ return drop(key, "the BrainChain service lists no proof waiting for it, so the worker mints none");
235
+ }
236
+ // Counted before anything is sent, so a restart during the mint waits out the hour too.
237
+ later(entry, RETRY_SEC);
238
+ if (listed instanceof Error)
239
+ return log(`task ${key}: no proof minted yet (${reason(listed)}); trying again in an hour`);
240
+ try {
241
+ const funds = await afford(network.proofs, chain, 1, { dry: false, claiming: () => log("claiming test WETH from the faucet"), now: clock });
242
+ if (funds.missing)
243
+ return log(`task ${key}: no proof minted yet. ${funds.missing} Trying again in an hour.`);
244
+ await chain.approve(funds.price);
245
+ const { tokenId, tx } = await chain.mint();
246
+ drop(key);
247
+ log(`minted proof ${tokenId} for task ${key} (${tx})`);
248
+ }
249
+ catch (error) {
250
+ log(`task ${key}: minting its proof failed (${reason(error)}); trying again in an hour`);
251
+ }
252
+ }
253
+ return {
254
+ /** Watches a task the worker just delivered. */
255
+ track(task) {
256
+ if (!options.enabled)
257
+ return;
258
+ const now = clock();
259
+ watched()[task.toString()] = { deliveredAt: now, nextAt: now + STATUS_SEC };
260
+ store.save();
261
+ },
262
+ /** Reads every watched task that is due, and mints for each paid one. */
263
+ async round() {
264
+ if (!options.enabled || store.state.proofs === undefined)
265
+ return;
266
+ const due = Object.entries(watched()).filter(([, entry]) => clock() >= entry.nextAt);
267
+ const paid = [];
268
+ for (const [key, entry] of due)
269
+ if (entry.paidAt !== undefined || (await settle(key, entry)))
270
+ paid.push([key, entry]);
271
+ if (paid.length === 0)
272
+ return;
273
+ const listed = await pendingTasks(network, chain.account, options.fetcher).catch((error) => (error instanceof Error ? error : new Error(String(error))));
274
+ for (const [key, entry] of paid)
275
+ await mintFor(key, entry, listed);
276
+ },
277
+ };
278
+ }
package/dist/network.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { defineChain, zeroAddress } from "viem";
2
2
  /** BrainChain testnet, release 3.1.0. */
3
3
  export const TESTNET = {
4
+ id: "testnet",
4
5
  name: "BrainChain testnet",
5
6
  chainId: 274630,
6
7
  rpc: "https://rpc.testnet.usebrainchain.com",
@@ -10,7 +11,15 @@ export const TESTNET = {
10
11
  stakeSymbol: "tBRAIN",
11
12
  bountySymbol: "tUSDG",
12
13
  gas: "Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com and bridge some at https://usebrainchain.com/testnet",
13
- proofs: { chainId: 46630, name: "Robinhood Chain testnet", rpc: "https://rpc.testnet.chain.robinhood.com" },
14
+ proofs: {
15
+ chainId: 46630,
16
+ name: "Robinhood Chain testnet",
17
+ rpc: "https://rpc.testnet.chain.robinhood.com",
18
+ collection: "0x324B52EE2182641ec29652076ab541bc9F9907d2",
19
+ weth: "0x1F852eF3Afa13156b807204d838BbB46B4b452eb",
20
+ faucet: true,
21
+ gas: "Get Robinhood Chain testnet ETH for gas at https://faucet.testnet.chain.robinhood.com",
22
+ },
14
23
  contracts: {
15
24
  taskBoard: "0xC74564F100e70F95346A6e399ebf1Ce14309C8B6",
16
25
  staking: "0xbdC08799D865DA79FE44a469de1FeaE84bb45059",
@@ -28,6 +37,7 @@ export const MAINNET_DECISION_EXECUTOR = "0xA03614B09Dcc3813eD665a1693123f2a4f2C
28
37
  const BRAIN = "0x3367085Eb6C79BfB0fCa4f1433ec77bFA8CB8a37";
29
38
  /** BrainChain mainnet, release 3.3.0. */
30
39
  export const MAINNET = {
40
+ id: "mainnet",
31
41
  name: "BrainChain",
32
42
  chainId: 274663,
33
43
  rpc: "https://rpc.usebrainchain.com",
@@ -40,7 +50,15 @@ export const MAINNET = {
40
50
  buyStake: "Buy BRAIN on ripples.run (https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and bridge it to BrainChain through the " +
41
51
  "Robinhood Chain gateway router 0x03f06a370A59AfB23de029F4EF5EA2F9EB034F96 (docs: https://docs.usebrainchain.com/bridging); " +
42
52
  `the stake token on BrainChain is ${BRAIN}.`,
43
- proofs: { chainId: 4663, name: "Robinhood Chain", rpc: "https://rpc.mainnet.chain.robinhood.com", income: "0xeb1b4168D4b87D3d9F0D64E13B7071251BBB63b7" },
53
+ proofs: {
54
+ chainId: 4663,
55
+ name: "Robinhood Chain",
56
+ rpc: "https://rpc.mainnet.chain.robinhood.com",
57
+ collection: "0xE6E6B7DBF1a66dFB25c6F6A87a916B3186113169",
58
+ weth: "0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73",
59
+ gas: "Send it a little ETH there for gas",
60
+ income: "0xeb1b4168D4b87D3d9F0D64E13B7071251BBB63b7",
61
+ },
44
62
  contracts: {
45
63
  taskBoard: MAINNET_TASK_BOARD,
46
64
  staking: MAINNET_WORKER_STAKING,
@@ -88,3 +106,13 @@ export function chainOf(network) {
88
106
  rpcUrls: { default: { http: [network.rpc] } },
89
107
  });
90
108
  }
109
+ const MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11";
110
+ export function proofsChainOf(proofs) {
111
+ return defineChain({
112
+ id: proofs.chainId,
113
+ name: proofs.name,
114
+ nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
115
+ rpcUrls: { default: { http: [proofs.rpc] } },
116
+ contracts: { multicall3: { address: MULTICALL3 } },
117
+ });
118
+ }
package/dist/state.js CHANGED
@@ -19,15 +19,22 @@ function isTask(value) {
19
19
  return false;
20
20
  return value.stored === undefined || (isObject(value.stored) && typeof value.stored.uri === "string" && isHash(value.stored.hash));
21
21
  }
22
+ function isProofs(value) {
23
+ if (!isObject(value) || !isIds(Object.keys(value)))
24
+ return false;
25
+ return Object.values(value).every((p) => isObject(p) && isCount(p.deliveredAt) && isCount(p.nextAt) && (p.paidAt === undefined || isCount(p.paidAt)));
26
+ }
22
27
  /** Whether a parsed file is a state this worker can go on from; any other shape fails every round that reads it. */
23
28
  function isState(value) {
24
29
  if (!isObject(value) || typeof value.board !== "string" || !isObject(value.tasks) || !isIds(value.done))
25
30
  return false;
26
31
  if (!Object.values(value.tasks).every(isTask))
27
32
  return false;
28
- const { hired, command } = value;
33
+ const { hired, command, proofs } = value;
29
34
  if (hired !== undefined && !(isObject(hired) && isCount(hired.block) && isIds(hired.tasks)))
30
35
  return false;
36
+ if (proofs !== undefined && !isProofs(proofs))
37
+ return false;
31
38
  // Group 0 is this process's own and -1 is every process, so only a real command's group is signalled.
32
39
  return command === undefined || (isObject(command) && isCount(command.pgid) && command.pgid > 1 && isCount(command.startedAt));
33
40
  }
@@ -90,6 +97,8 @@ function alive(pid) {
90
97
  return error.code === "EPERM";
91
98
  }
92
99
  }
100
+ const holder = (file) => (existsSync(file) ? Number(readFileSync(file, "utf8").trim()) : 0);
101
+ const held = (pid, file) => new Error(`Another run (pid ${pid}) is already working for this worker. Stop it before starting another; ${file} holds its lock.`);
93
102
  /** Takes the lock in `file` for this process until it exits; refuses while a live process holds it. */
94
103
  export function lock(file) {
95
104
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
@@ -103,9 +112,9 @@ export function lock(file) {
103
112
  throw error;
104
113
  }
105
114
  // The holder may have released it since the write failed; then the next try takes it.
106
- const holder = existsSync(file) ? Number(readFileSync(file, "utf8").trim()) : 0;
107
- if (alive(holder))
108
- throw new Error(`Another run (pid ${holder}) is already working for this worker. Stop it before starting another; ${file} holds its lock.`);
115
+ const pid = holder(file);
116
+ if (alive(pid))
117
+ throw held(pid, file);
109
118
  rmSync(file, { force: true });
110
119
  }
111
120
  const release = () => {
@@ -115,3 +124,42 @@ export function lock(file) {
115
124
  process.once("exit", release);
116
125
  return release;
117
126
  }
127
+ /**
128
+ * Moves the state 0.3.0 and earlier kept per address alone to `file`, once,
129
+ * when it was kept for `board`. State for the other network stays where it is
130
+ * until a run there moves it.
131
+ */
132
+ function migrate(old, file, board) {
133
+ let text;
134
+ try {
135
+ text = readFileSync(`${old}.json`, "utf8");
136
+ }
137
+ catch (error) {
138
+ if (error.code === "ENOENT")
139
+ return;
140
+ throw error;
141
+ }
142
+ if (parse(text)?.board !== board.toLowerCase())
143
+ return;
144
+ // A run from before the move may still be working on this network's tasks from the old file.
145
+ const pid = holder(`${old}.lock`);
146
+ if (alive(pid))
147
+ throw held(pid, `${old}.lock`);
148
+ if (existsSync(`${file}.json`))
149
+ return;
150
+ // Removed rather than moved: this run already holds the new lock.
151
+ rmSync(`${old}.lock`, { force: true });
152
+ renameSync(`${old}.json`, `${file}.json`);
153
+ }
154
+ /**
155
+ * Takes the worker's lock on one network and loads its saved state. Each
156
+ * network has its own `<address>.<network>.json` and `.lock`, so one key can
157
+ * run on testnet and mainnet at once without sharing cursors or tasks.
158
+ */
159
+ export function openStore(address, network, warn, dir = stateDir()) {
160
+ const old = join(dir, address);
161
+ const file = `${old}.${network.id}`;
162
+ lock(`${file}.lock`);
163
+ migrate(old, file, network.contracts.taskBoard);
164
+ return fileStore(`${file}.json`, network.contracts.taskBoard, warn);
165
+ }
package/dist/work.js CHANGED
@@ -315,6 +315,7 @@ export function createWork(options) {
315
315
  const delivered = (key, text, tx) => {
316
316
  result.delivered += 1;
317
317
  log(`task ${key}: delivered (${toBytes(text).byteLength} bytes, ${tx}). Jev grades it next.`);
318
+ options.delivered?.(BigInt(key));
318
319
  };
319
320
  const current = new Set(hired.map((t) => t.id.toString()));
320
321
  for (const [key, attempt] of Object.entries(state.tasks)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-worker",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Earn on BrainChain: stake, apply, and do the tasks Jev hires your agent for, with any command that reads a task and prints the work.",
5
5
  "license": "MIT",
6
6
  "author": "BrainChain <hello@usebrainchain.com>",