brainchain-worker 0.3.1 → 0.4.1

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,7 +94,7 @@ 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>.<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. 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.
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.
85
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
 
@@ -96,10 +109,11 @@ Briefs are written by strangers, and some will try to get your agent to do somet
96
109
  | Command | |
97
110
  | --- | --- |
98
111
  | `new-key` | Print a fresh private key and its address. |
99
- | `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. |
101
- | `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
- | `price <tUSDG>` | Change the asking price. |
112
+ | `join --name <name> --about <text> [--price <USDG>]` | Claim tBRAIN from the faucet if needed (on mainnet, the BRAIN comes from the wallet), stake the minimum and apply. Jev reads the name and description to decide, and refuses an empty one. The name is at most 60 characters and the description at most 500. 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. |
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. |
114
+ | `status` | Whether the worker can be hired, its stake, its tBRAIN or BRAIN balance, price, record and earnings. Below the minimum stake, it also says where `join` will find the tBRAIN it needs, or on mainnet where to buy BRAIN. |
115
+ | `price <USDG>` | Change the asking price, in tUSDG on testnet and USDG on mainnet. |
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. |
@@ -117,7 +131,7 @@ Briefs are written by strangers, and some will try to get your agent to do somet
117
131
  ## Price, pay and stake
118
132
 
119
133
  - **Price.** Jev sees each worker's asking price, record and description when it hires, so a lower price can win a task. The worker is paid the task's bounty either way.
120
- - **Pay.** Bounties are paid in tUSDG to the worker's wallet. House tasks pay 1 tUSDG.
134
+ - **Pay.** Bounties are paid to the worker's wallet in tUSDG, or in USDG on mainnet. House tasks pay 1 tUSDG.
121
135
  - **Stake.** The stake is held while the worker has a task in flight. A worker can dispute a grade by calling `dispute(taskId)` on the task board; if it loses a dispute it opened, 20% of its stake is burned.
122
136
 
123
137
  ## Links
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 });
@@ -72,7 +66,7 @@ export async function claim(network, connect, options) {
72
66
  const { log } = options;
73
67
  const { income, name } = network.proofs;
74
68
  if (income === undefined) {
75
- return log(`Proofs on ${network.name} earn no income, so there is nothing to claim. Mainnet Proofs are claimed with BRAINCHAIN_NETWORK=mainnet.`);
69
+ return log(`Proofs on ${name} earn no income, so there is nothing to claim. Mainnet Proofs are claimed with BRAINCHAIN_NETWORK=mainnet.`);
76
70
  }
77
71
  const chain = connect(income);
78
72
  const ids = await ownedProofs(network, chain.account, options.fetcher);
package/dist/cli.js ADDED
@@ -0,0 +1,380 @@
1
+ import { createRequire } from "node:module";
2
+ import { formatEther, parseUnits } from "viem";
3
+ import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
4
+ import { createChain, faucetReopens, reason, unhealthy } from "./chain.js";
5
+ import { claim, createIncome } from "./claim.js";
6
+ import { createProofMinter, createProofsChain, MAX_MINT, mint } from "./mint.js";
7
+ import { resolveNetwork } from "./network.js";
8
+ import { hireable, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
9
+ import { openStore } from "./state.js";
10
+ import { createWork, shellCommand, stopLeftover, stopper } from "./work.js";
11
+ const { version } = createRequire(import.meta.url)("../package.json");
12
+ const USAGE = `brainchain-worker ${version}
13
+
14
+ Earn on BrainChain by doing tasks: stake, apply, and let Jev hire your agent.
15
+
16
+ Usage:
17
+ brainchain-worker new-key Print a fresh private key and its address.
18
+ brainchain-worker join --name <name> --about <text> [--price <USDG>]
19
+ Stake the minimum and apply to take tasks. The name is at
20
+ most 60 characters, the description at most 500.
21
+ brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--no-mint]
22
+ Do every task Jev hires the worker for: the command reads the
23
+ task on stdin and prints the deliverable on stdout, within
24
+ --timeout minutes (20 by default, at most 1440). The worker
25
+ mints the Proof of each task it is paid for, unless --no-mint.
26
+ brainchain-worker status Stake, wallet, application, record and earnings.
27
+ brainchain-worker price <USDG> Change your asking price per task.
28
+ brainchain-worker mint [--count <n>] [--dry]
29
+ Mint a Proof for each paid task waiting for one, up to 10 or
30
+ --count, at 0.001 WETH each on Robinhood Chain. --dry sends nothing.
31
+ brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
32
+ --dry lists what is due and sends nothing.
33
+ brainchain-worker leave Start unstaking (3-day cooldown).
34
+ brainchain-worker withdraw Withdraw stake once the cooldown has passed.
35
+ brainchain-worker --version Print the version.
36
+
37
+ Environment:
38
+ BRAINCHAIN_WORKER_KEY Private key of the worker's wallet, 64 hex characters with or without 0x.
39
+ BRAINCHAIN_NETWORK testnet (the default) or mainnet.
40
+ BRAINCHAIN_RPC_URL Overrides the network's RPC.
41
+ BRAINCHAIN_SERVICE_URL Overrides the BrainChain service that keeps results: https, or http on
42
+ localhost or 127.0.0.1.
43
+
44
+ The command sees the worker's environment without any BRAINCHAIN_ variable, plus BRAINCHAIN_TASK_ID.
45
+ Unfinished work is kept in ~/.config/brainchain-worker/<address>.<network>.json across restarts,
46
+ and one run at a time works for each address on each network.
47
+
48
+ Docs: https://docs.usebrainchain.com/run-a-worker`;
49
+ /** A mistake in the command line. Every other error is printed after the program's name. */
50
+ export class UsageError extends Error {
51
+ }
52
+ /** The flags each command takes; `true` marks a switch, which takes no value. */
53
+ const FLAGS = {
54
+ join: { name: false, about: false, price: false },
55
+ run: { exec: false, format: false, timeout: false, once: true, "no-mint": true },
56
+ status: {},
57
+ price: {},
58
+ leave: {},
59
+ withdraw: {},
60
+ claim: { dry: true },
61
+ mint: { count: false, dry: true },
62
+ };
63
+ const POSITIONAL = { price: 1 };
64
+ export function parse(command, args) {
65
+ const known = FLAGS[command];
66
+ if (known === undefined)
67
+ throw new UsageError(`Unknown command "${command}" (see brainchain-worker --help)`);
68
+ const positional = [];
69
+ const values = new Map();
70
+ const switches = new Set();
71
+ for (let i = 0; i < args.length; i++) {
72
+ const arg = args[i];
73
+ if (!arg.startsWith("--")) {
74
+ positional.push(arg);
75
+ continue;
76
+ }
77
+ const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
78
+ const isSwitch = known[name];
79
+ if (isSwitch === undefined)
80
+ throw new UsageError(`The ${command} command has no --${name} flag (see brainchain-worker --help)`);
81
+ if (isSwitch) {
82
+ if (inline !== undefined)
83
+ throw new UsageError(`--${name} takes no value`);
84
+ switches.add(name);
85
+ continue;
86
+ }
87
+ const value = inline ?? args[++i];
88
+ if (value === undefined || (inline === undefined && value.startsWith("--")))
89
+ throw new UsageError(`--${name} needs a value`);
90
+ values.set(name, value);
91
+ }
92
+ if (positional.length > (POSITIONAL[command] ?? 0))
93
+ throw new UsageError(`Unexpected "${positional.at(-1)}" (see brainchain-worker --help)`);
94
+ return { positional, values, switches };
95
+ }
96
+ function signer() {
97
+ const raw = process.env.BRAINCHAIN_WORKER_KEY?.trim();
98
+ if (!raw)
99
+ throw new Error("Set BRAINCHAIN_WORKER_KEY to the worker's private key. `npx brainchain-worker new-key` makes one.");
100
+ const hex = raw.startsWith("0x") ? raw.slice(2) : raw;
101
+ if (!/^[0-9a-fA-F]{64}$/.test(hex))
102
+ throw new Error("BRAINCHAIN_WORKER_KEY must be 64 hex characters, with or without 0x");
103
+ const key = `0x${hex.toLowerCase()}`;
104
+ try {
105
+ return { key, account: privateKeyToAccount(key) };
106
+ }
107
+ catch {
108
+ throw new Error("BRAINCHAIN_WORKER_KEY is not a valid private key. `npx brainchain-worker new-key` makes one.");
109
+ }
110
+ }
111
+ const DEFAULT_PRICE = "0.20";
112
+ /** A price in the bounty token, which has 6 decimals. */
113
+ function price(value) {
114
+ if (!/^\d{1,6}(\.\d{1,6})?$/.test(value)) {
115
+ throw new UsageError(`The price must be a number from 0 to 999999 with at most 6 decimals, such as 0.20, not "${value}"`);
116
+ }
117
+ return parseUnits(value, 6);
118
+ }
119
+ const RUN = '`npx brainchain-worker run --exec "<command>"`';
120
+ function noGas(chain, network) {
121
+ return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}; 0.001 ETH is plenty.`);
122
+ }
123
+ async function needGas(chain, network) {
124
+ if ((await chain.ethBalance()) === 0n)
125
+ throw noGas(chain, network);
126
+ }
127
+ export async function join(chain, network, values) {
128
+ const name = values.get("name")?.trim() ?? "";
129
+ const about = values.get("about")?.trim() ?? "";
130
+ if (!name)
131
+ throw new UsageError("--name is required, up to 60 characters");
132
+ if (name.length > 60)
133
+ throw new UsageError("--name is at most 60 characters");
134
+ if (!about) {
135
+ 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");
136
+ }
137
+ if (about.length > 500)
138
+ throw new UsageError("--about is at most 500 characters");
139
+ const requested = values.has("price") ? price(values.get("price")) : undefined;
140
+ const gas = await chain.ethBalance();
141
+ // The faucet covers the stake where there is one, so gas is all a new worker can lack there.
142
+ if (gas === 0n && !network.buyStake)
143
+ throw noGas(chain, network);
144
+ const { allowed, w, change, pending, open, applying, toStake } = await snapshot(chain);
145
+ // A worker that has staked keeps its price unless --price changes it.
146
+ const asking = requested ?? (w.registered ? w.price : price(DEFAULT_PRICE));
147
+ const [bond, balance, timelock] = await Promise.all([chain.bond(), chain.stakeBalance(), chain.timelock()]);
148
+ const need = toStake + (applying ? bond : 0n);
149
+ if (balance < need && network.buyStake) {
150
+ const short = shortOfStake(network, w, balance, need, await chain.now());
151
+ // Both needs at once, so a new worker does not bridge ETH only to learn it needs the stake token too.
152
+ throw new Error(gas === 0n ? `${noGas(chain, network).message}\n${short}` : short);
153
+ }
154
+ if (gas === 0n)
155
+ throw noGas(chain, network);
156
+ if (balance < need) {
157
+ const [faucet, now] = await Promise.all([chain.faucet(), chain.now()]);
158
+ const closed = (opensAt) => {
159
+ const withdraw = now >= w.unlocksAt ? "run withdraw now" : `run withdraw after ${when(w.unlocksAt)}`;
160
+ const unstaking = w.unstaking > 0n ? `, or ${withdraw} and join with the ${stake(network, w.unstaking)} it returns` : "";
161
+ return new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, need)} join needs, and the faucet allows one claim a day: ` +
162
+ `this wallet can claim again at ${when(opensAt)}. Run join again then${unstaking}.`);
163
+ };
164
+ if (now < faucet.opensAt)
165
+ throw closed(faucet.opensAt);
166
+ console.log(`Claiming ${network.stakeSymbol} from the testnet faucet...`);
167
+ await chain.claimStakeToken().catch((error) => {
168
+ const opensAt = faucetReopens(error);
169
+ throw opensAt === undefined ? error : closed(opensAt);
170
+ });
171
+ }
172
+ if (toStake > 0n) {
173
+ console.log(`Staking ${stake(network, toStake)}${w.staked > 0n ? " more, up to the minimum," : ""} at ${usdg(network, asking)} per task...`);
174
+ await chain.approve(network.contracts.stakeToken, network.contracts.staking, toStake);
175
+ await chain.stakeTokens(toStake, asking);
176
+ }
177
+ else if (w.price !== asking) {
178
+ console.log(`Setting your price to ${usdg(network, asking)} per task...`);
179
+ await chain.setPrice(asking);
180
+ }
181
+ if (allowed) {
182
+ console.log(`${toStake > 0n ? "Staked back to the minimum, so this worker can be hired again." : "This worker can already be hired."} Start it with ${RUN}.`);
183
+ return;
184
+ }
185
+ if (change && pending) {
186
+ console.log(`Jev already allowed this worker. It can be hired after ${when(change.eta)}; keep ${RUN} going until then.`);
187
+ return;
188
+ }
189
+ if (open) {
190
+ console.log(`Application ${open.id} is already waiting for Jev.`);
191
+ return;
192
+ }
193
+ console.log(`Applying with a ${stake(network, bond)} bond, returned if Jev allows the worker...`);
194
+ await chain.approve(network.contracts.stakeToken, network.contracts.bonds, bond);
195
+ const profile = Buffer.from(JSON.stringify({ name, about })).toString("base64");
196
+ await chain.apply(`data:application/json;base64,${profile}`);
197
+ console.log(`\nApplied. Jev usually decides within a few minutes. If it allows the worker, it can be hired once a ${Number(timelock) / 3600}-hour timelock passes.\n` +
198
+ `Start ${RUN} now so it takes its first task as soon as it is hired, and follow\n` +
199
+ "the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
200
+ }
201
+ async function status(chain, network) {
202
+ const [gas, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
203
+ chain.ethBalance(),
204
+ snapshot(chain),
205
+ chain.held(),
206
+ chain.record(),
207
+ chain.bountyBalance(),
208
+ chain.stakeBalance(),
209
+ chain.bond(),
210
+ // A stake token without a faucet, as on mainnet, leaves out what the faucet would give.
211
+ network.buyStake ? undefined : chain.faucet().catch(() => undefined),
212
+ chain.now(),
213
+ ]);
214
+ const { w, min } = s;
215
+ const unstaking = w.unstaking > 0n ? `, ${stake(network, w.unstaking)} unstaking until ${when(w.unlocksAt)}` : "";
216
+ console.log(`Worker ${chain.account} on ${network.name}
217
+ Hireable ${hireable(s, network, "status")}
218
+ Stake ${stake(network, w.staked)} (minimum ${stake(network, min)})${unstaking}
219
+ Wallet ${wallet(network, s, stakeBalance, bond, faucet, now)}
220
+ Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
221
+ Record ${record.accepted} accepted, ${record.refunded} refunded, ${record.inFlight} in flight${held > 0 ? " (stake held until they settle)" : ""}
222
+ Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, bountyBalance)}
223
+ Gas ${formatEther(gas)} ETH`);
224
+ }
225
+ async function run(network, { key, account }, values, once, mints) {
226
+ const exec = values.get("exec");
227
+ if (!exec)
228
+ throw new UsageError(`--exec is required: the command that does the work, such as --exec 'claude -p --tools ""'`);
229
+ const format = values.get("format") ?? "prompt";
230
+ if (format !== "prompt" && format !== "json")
231
+ throw new UsageError("--format is prompt or json");
232
+ const minutes = values.get("timeout") ?? "20";
233
+ if (!/^\d{1,4}(\.\d+)?$/.test(minutes) || Number(minutes) <= 0 || Number(minutes) > 1440) {
234
+ 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");
235
+ }
236
+ const log = (message) => console.log(`${new Date().toISOString().slice(0, 19)}Z ${message}`);
237
+ const store = openStore(account.address, network, log);
238
+ // The lock rules out a live run, so a command still in the state was left by one that died.
239
+ await stopLeftover(store, log);
240
+ const chain = createChain(network, account, store);
241
+ const proofs = createProofMinter({ network, chain: createProofsChain(network.proofs, account), settled: chain.settled, store, log, enabled: mints });
242
+ const command = shellCommand(exec);
243
+ // An idle stop keeps the exit code of a task that failed earlier in a --once pass.
244
+ const stop = stopper({ busy: () => work.busy(), kill: command.kill, log, exit: (code) => process.exit(code || process.exitCode) });
245
+ const work = createWork({
246
+ chain,
247
+ command,
248
+ format,
249
+ log,
250
+ timeoutMs: Number(minutes) * 60_000,
251
+ secrets: [key, key.slice(2)],
252
+ store,
253
+ stopping: stop.stopping,
254
+ // A cron job sees a failed task as a failed run.
255
+ failed: once ? () => void (process.exitCode = 1) : undefined,
256
+ delivered: proofs.track,
257
+ });
258
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
259
+ process.on(signal, stop.signal);
260
+ let gas;
261
+ try {
262
+ gas = await chain.ethBalance();
263
+ }
264
+ catch (error) {
265
+ // The rest of a single pass reads the same RPC, so it would only fail the same way twice more.
266
+ if (once) {
267
+ log(`could not read the chain: ${reason(error)}`);
268
+ process.exitCode = 1;
269
+ return;
270
+ }
271
+ log(`could not read the wallet's ETH balance: ${reason(error)}`);
272
+ }
273
+ if (gas === 0n)
274
+ throw noGas(chain, network);
275
+ const [line, sick] = await Promise.all([
276
+ snapshot(chain).then((s) => hireable(s, network, "run"), (error) => `could not check (${reason(error)})`),
277
+ unhealthy(network.service),
278
+ ]);
279
+ log(`worker ${chain.account} on ${network.name}; hireable: ${line}`);
280
+ if (sick)
281
+ log(`warning: ${sick}`);
282
+ if (once) {
283
+ const result = await work.round().catch((error) => {
284
+ log(`round failed: ${reason(error)}`);
285
+ process.exitCode = 1;
286
+ return undefined;
287
+ });
288
+ if (result?.hired === 0 && result.delivered === 0)
289
+ log("no task is hired to this worker right now");
290
+ await proofs.round().catch((error) => log(`proofs round failed: ${reason(error)}`));
291
+ return;
292
+ }
293
+ log("waiting for tasks (Ctrl-C to stop)");
294
+ // Minting waits on receipts from another chain, so it runs beside the work, one round at a time.
295
+ let minting;
296
+ while (!stop.stopping()) {
297
+ await work.round().catch((error) => log(`round failed: ${reason(error)}`));
298
+ minting ??= proofs
299
+ .round()
300
+ .catch((error) => log(`proofs round failed: ${reason(error)}`))
301
+ .finally(() => (minting = undefined));
302
+ for (let i = 0; i < 15 && !stop.stopping(); i++)
303
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
304
+ }
305
+ await minting;
306
+ log("stopped");
307
+ }
308
+ export async function main(argv) {
309
+ const [command, ...rest] = argv;
310
+ if (command === undefined || command === "help" || argv.includes("--help") || argv.includes("-h")) {
311
+ console.log(USAGE);
312
+ return;
313
+ }
314
+ if (command === "--version" || command === "-v") {
315
+ console.log(version);
316
+ return;
317
+ }
318
+ if (command === "new-key") {
319
+ const fresh = generatePrivateKey();
320
+ console.log(`BRAINCHAIN_WORKER_KEY=${fresh}\naddress ${privateKeyToAccount(fresh).address}\nKeep the key secret; it controls the stake and the earnings.`);
321
+ return;
322
+ }
323
+ const { positional, values, switches } = parse(command, rest);
324
+ const network = resolveNetwork(process.env);
325
+ if (command === "claim") {
326
+ return claim(network, (income) => createIncome(network.proofs, income, signer().account), { dry: switches.has("dry"), log: console.log });
327
+ }
328
+ if (command === "mint") {
329
+ const count = values.get("count");
330
+ if (count !== undefined && !(/^\d{1,2}$/.test(count) && Number(count) >= 1 && Number(count) <= MAX_MINT)) {
331
+ throw new UsageError(`--count is how many Proofs to mint: 1 to ${MAX_MINT}`);
332
+ }
333
+ return mint(network, createProofsChain(network.proofs, signer().account), { count: count === undefined ? undefined : Number(count), dry: switches.has("dry"), log: console.log });
334
+ }
335
+ const worker = signer();
336
+ if (command === "run")
337
+ return run(network, worker, values, switches.has("once"), !switches.has("no-mint"));
338
+ const chain = createChain(network, worker.account);
339
+ switch (command) {
340
+ case "join":
341
+ return join(chain, network, values);
342
+ case "status":
343
+ return status(chain, network);
344
+ case "price": {
345
+ if (positional[0] === undefined)
346
+ throw new UsageError("Usage: brainchain-worker price <USDG>, such as price 0.25");
347
+ const asking = price(positional[0]);
348
+ if (!(await chain.stake()).registered)
349
+ throw new Error("This worker has not staked yet. Run join, which sets the price too.");
350
+ await needGas(chain, network);
351
+ await chain.setPrice(asking);
352
+ console.log(`Price set to ${usdg(network, asking)} per task.`);
353
+ return;
354
+ }
355
+ case "leave": {
356
+ const w = await chain.stake();
357
+ if (w.staked === 0n) {
358
+ throw new Error(w.unstaking > 0n ? `Nothing left to unstake: ${stake(network, w.unstaking)} is already unstaking until ${when(w.unlocksAt)}.` : "Nothing is staked.");
359
+ }
360
+ await needGas(chain, network);
361
+ await chain.requestUnstake(w.staked);
362
+ const after = await chain.stake();
363
+ console.log(`Unstaking ${stake(network, w.staked)}. Withdraw with \`npx brainchain-worker withdraw\` after ${when(after.unlocksAt)}, once no task of the worker's is in flight.`);
364
+ return;
365
+ }
366
+ case "withdraw": {
367
+ const [w, held, now] = await Promise.all([chain.stake(), chain.held(), chain.now()]);
368
+ if (w.unstaking === 0n)
369
+ throw new Error("Nothing is unstaking. Run leave first.");
370
+ if (now < w.unlocksAt)
371
+ throw new Error(`${stake(network, w.unstaking)} unlocks at ${when(w.unlocksAt)}. Withdraw after that.`);
372
+ if (held > 0)
373
+ throw new Error(`The stake is held until the worker's ${held} task${held === 1 ? "" : "s"} in flight settle.`);
374
+ await needGas(chain, network);
375
+ await chain.withdraw();
376
+ console.log(`Withdrew ${stake(network, w.unstaking)}.`);
377
+ return;
378
+ }
379
+ }
380
+ }
package/dist/index.js CHANGED
@@ -1,351 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { createRequire } from "node:module";
3
- import { formatEther, parseUnits } from "viem";
4
- import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
5
- import { createChain, faucetReopens, reason, unhealthy } from "./chain.js";
6
- import { claim, createIncome } from "./claim.js";
7
- import { resolveNetwork } from "./network.js";
8
- import { hireable, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
9
- import { openStore } from "./state.js";
10
- import { createWork, shellCommand, stopLeftover, stopper } from "./work.js";
11
- const { version } = createRequire(import.meta.url)("../package.json");
12
- const USAGE = `brainchain-worker ${version}
13
-
14
- Earn on BrainChain by doing tasks: stake, apply, and let Jev hire your agent.
15
-
16
- Usage:
17
- brainchain-worker new-key Print a fresh private key and its address.
18
- brainchain-worker join --name <name> --about <text> [--price <USDG>]
19
- Stake the minimum and apply to take tasks. The name is at
20
- most 60 characters, the description at most 500.
21
- brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once]
22
- Do every task Jev hires you for: the command reads the
23
- task on stdin and prints the deliverable on stdout, within
24
- --timeout minutes (20 by default, at most 1440).
25
- brainchain-worker status Stake, wallet, application, record and earnings.
26
- brainchain-worker price <USDG> Change your asking price per task.
27
- brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
28
- --dry lists what is due and sends nothing.
29
- brainchain-worker leave Start unstaking (3-day cooldown).
30
- brainchain-worker withdraw Withdraw stake once the cooldown has passed.
31
- brainchain-worker --version Print the version.
32
-
33
- Environment:
34
- BRAINCHAIN_WORKER_KEY Private key of the worker's wallet, 64 hex characters with or without 0x.
35
- BRAINCHAIN_NETWORK testnet (the default) or mainnet.
36
- BRAINCHAIN_RPC_URL Overrides the network's RPC.
37
- BRAINCHAIN_SERVICE_URL Overrides the BrainChain service that keeps results: https, or http on
38
- localhost or 127.0.0.1.
39
-
40
- The command sees the worker's environment without any BRAINCHAIN_ variable, plus BRAINCHAIN_TASK_ID.
41
- Unfinished work is kept in ~/.config/brainchain-worker/<address>.<network>.json across restarts,
42
- and one run at a time works for each address on each network.
43
-
44
- Docs: https://docs.usebrainchain.com/run-a-worker`;
45
- /** A mistake in the command line. Every other error is printed after the program's name. */
46
- class UsageError extends Error {
47
- }
48
- /** The flags each command takes; `true` marks a switch, which takes no value. */
49
- const FLAGS = {
50
- join: { name: false, about: false, price: false },
51
- run: { exec: false, format: false, timeout: false, once: true },
52
- status: {},
53
- price: {},
54
- leave: {},
55
- withdraw: {},
56
- claim: { dry: true },
57
- };
58
- const POSITIONAL = { price: 1 };
59
- function parse(command, args) {
60
- const known = FLAGS[command];
61
- if (known === undefined)
62
- throw new UsageError(`Unknown command "${command}" (see brainchain-worker --help)`);
63
- const positional = [];
64
- const values = new Map();
65
- const switches = new Set();
66
- for (let i = 0; i < args.length; i++) {
67
- const arg = args[i];
68
- if (!arg.startsWith("--")) {
69
- positional.push(arg);
70
- continue;
71
- }
72
- const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
73
- const isSwitch = known[name];
74
- if (isSwitch === undefined)
75
- throw new UsageError(`The ${command} command has no --${name} flag (see brainchain-worker --help)`);
76
- if (isSwitch) {
77
- switches.add(name);
78
- continue;
79
- }
80
- const value = inline ?? args[++i];
81
- if (value === undefined || (inline === undefined && value.startsWith("--")))
82
- throw new UsageError(`--${name} needs a value`);
83
- values.set(name, value);
84
- }
85
- if (positional.length > (POSITIONAL[command] ?? 0))
86
- throw new UsageError(`Unexpected "${positional.at(-1)}" (see brainchain-worker --help)`);
87
- return { positional, values, switches };
88
- }
89
- function signer() {
90
- const raw = process.env.BRAINCHAIN_WORKER_KEY?.trim();
91
- if (!raw)
92
- throw new Error("Set BRAINCHAIN_WORKER_KEY to the worker's private key. `npx brainchain-worker new-key` makes one.");
93
- const hex = raw.startsWith("0x") ? raw.slice(2) : raw;
94
- if (!/^[0-9a-fA-F]{64}$/.test(hex))
95
- throw new Error("BRAINCHAIN_WORKER_KEY must be 64 hex characters, with or without 0x");
96
- const key = `0x${hex.toLowerCase()}`;
97
- try {
98
- return { key, account: privateKeyToAccount(key) };
99
- }
100
- catch {
101
- throw new Error("BRAINCHAIN_WORKER_KEY is not a valid private key. `npx brainchain-worker new-key` makes one.");
102
- }
103
- }
104
- const DEFAULT_PRICE = "0.20";
105
- /** A price in the bounty token, which has 6 decimals. */
106
- function price(value) {
107
- if (!/^\d{1,6}(\.\d{1,6})?$/.test(value)) {
108
- throw new UsageError(`The price must be a number from 0 to 999999 with at most 6 decimals, such as 0.20, not "${value}"`);
109
- }
110
- return parseUnits(value, 6);
111
- }
112
- const RUN = '`npx brainchain-worker run --exec "<command>"`';
113
- function noGas(chain, network) {
114
- return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}; 0.001 ETH is plenty.`);
115
- }
116
- async function needGas(chain, network) {
117
- if ((await chain.ethBalance()) === 0n)
118
- throw noGas(chain, network);
119
- }
120
- async function join(chain, network, values) {
121
- const name = values.get("name")?.trim() ?? "";
122
- const about = values.get("about")?.trim() ?? "";
123
- if (!name)
124
- throw new UsageError("--name is required, up to 60 characters");
125
- if (name.length > 60)
126
- throw new UsageError("--name is at most 60 characters");
127
- if (!about) {
128
- 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");
129
- }
130
- if (about.length > 500)
131
- throw new UsageError("--about is at most 500 characters");
132
- const requested = values.has("price") ? price(values.get("price")) : undefined;
133
- await needGas(chain, network);
134
- const { allowed, w, change, pending, open, applying, toStake } = await snapshot(chain);
135
- // A worker that has staked keeps its price unless --price changes it.
136
- const asking = requested ?? (w.registered ? w.price : price(DEFAULT_PRICE));
137
- const [bond, balance, timelock] = await Promise.all([chain.bond(), chain.stakeBalance(), chain.timelock()]);
138
- const need = toStake + (applying ? bond : 0n);
139
- if (balance < need) {
140
- if (network.buyStake)
141
- throw new Error(shortOfStake(network, w, balance, need, await chain.now()));
142
- const [faucet, now] = await Promise.all([chain.faucet(), chain.now()]);
143
- const closed = (opensAt) => {
144
- const withdraw = now >= w.unlocksAt ? "run withdraw now" : `run withdraw after ${when(w.unlocksAt)}`;
145
- const unstaking = w.unstaking > 0n ? `, or ${withdraw} and join with the ${stake(network, w.unstaking)} it returns` : "";
146
- return new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, need)} join needs, and the faucet allows one claim a day: ` +
147
- `this wallet can claim again at ${when(opensAt)}. Run join again then${unstaking}.`);
148
- };
149
- if (now < faucet.opensAt)
150
- throw closed(faucet.opensAt);
151
- console.log(`Claiming ${network.stakeSymbol} from the testnet faucet...`);
152
- await chain.claimStakeToken().catch((error) => {
153
- const opensAt = faucetReopens(error);
154
- throw opensAt === undefined ? error : closed(opensAt);
155
- });
156
- }
157
- if (toStake > 0n) {
158
- console.log(`Staking ${stake(network, toStake)}${w.staked > 0n ? " more, up to the minimum," : ""} at ${usdg(network, asking)} per task...`);
159
- await chain.approve(network.contracts.stakeToken, network.contracts.staking, toStake);
160
- await chain.stakeTokens(toStake, asking);
161
- }
162
- else if (w.price !== asking) {
163
- console.log(`Setting your price to ${usdg(network, asking)} per task...`);
164
- await chain.setPrice(asking);
165
- }
166
- if (allowed) {
167
- console.log(`${toStake > 0n ? "Staked back to the minimum, so this worker can be hired again." : "This worker can already be hired."} Start it with ${RUN}.`);
168
- return;
169
- }
170
- if (change && pending) {
171
- console.log(`Jev already allowed this worker. It can be hired after ${when(change.eta)}; keep ${RUN} going until then.`);
172
- return;
173
- }
174
- if (open) {
175
- console.log(`Application ${open.id} is already waiting for Jev.`);
176
- return;
177
- }
178
- console.log(`Applying with a ${stake(network, bond)} bond, returned if Jev allows the worker...`);
179
- await chain.approve(network.contracts.stakeToken, network.contracts.bonds, bond);
180
- const profile = Buffer.from(JSON.stringify({ name, about })).toString("base64");
181
- await chain.apply(`data:application/json;base64,${profile}`);
182
- console.log(`\nApplied. Jev usually decides within a few minutes. If it allows the worker, it can be hired once a ${Number(timelock) / 3600}-hour timelock passes.\n` +
183
- `Start ${RUN} now so it takes its first task as soon as it is hired, and follow\n` +
184
- "the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
185
- }
186
- async function status(chain, network) {
187
- const [gas, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
188
- chain.ethBalance(),
189
- snapshot(chain),
190
- chain.held(),
191
- chain.record(),
192
- chain.bountyBalance(),
193
- chain.stakeBalance(),
194
- chain.bond(),
195
- // A stake token without a faucet, as on mainnet, leaves out what the faucet would give.
196
- network.buyStake ? undefined : chain.faucet().catch(() => undefined),
197
- chain.now(),
198
- ]);
199
- const { w, min } = s;
200
- const unstaking = w.unstaking > 0n ? `, ${stake(network, w.unstaking)} unstaking until ${when(w.unlocksAt)}` : "";
201
- console.log(`Worker ${chain.account} on ${network.name}
202
- Hireable ${hireable(s, network, "status")}
203
- Stake ${stake(network, w.staked)} (minimum ${stake(network, min)})${unstaking}
204
- Wallet ${wallet(network, s, stakeBalance, bond, faucet, now)}
205
- Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
206
- Record ${record.accepted} accepted, ${record.refunded} refunded, ${record.inFlight} in flight${held > 0 ? " (stake held until they settle)" : ""}
207
- Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, bountyBalance)}
208
- Gas ${formatEther(gas)} ETH`);
209
- }
210
- async function run(network, { key, account }, values, once) {
211
- const exec = values.get("exec");
212
- if (!exec)
213
- throw new UsageError(`--exec is required: the command that does the work, such as --exec 'claude -p --tools ""'`);
214
- const format = values.get("format") ?? "prompt";
215
- if (format !== "prompt" && format !== "json")
216
- throw new UsageError("--format is prompt or json");
217
- const minutes = values.get("timeout") ?? "20";
218
- if (!/^\d{1,4}(\.\d+)?$/.test(minutes) || Number(minutes) <= 0 || Number(minutes) > 1440) {
219
- 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");
220
- }
221
- const log = (message) => console.log(`${new Date().toISOString().slice(0, 19)}Z ${message}`);
222
- const store = openStore(account.address, network, (message) => log(`warning: ${message}`));
223
- // The lock rules out a live run, so a command still in the state was left by one that died.
224
- await stopLeftover(store, log);
225
- const chain = createChain(network, account, store);
226
- const command = shellCommand(exec);
227
- // An idle stop keeps the exit code of a task that failed earlier in a --once pass.
228
- const stop = stopper({ busy: () => work.busy(), kill: command.kill, log, exit: (code) => process.exit(code || process.exitCode) });
229
- const work = createWork({
230
- chain,
231
- command,
232
- format,
233
- log,
234
- timeoutMs: Number(minutes) * 60_000,
235
- secrets: [key, key.slice(2)],
236
- store,
237
- stopping: stop.stopping,
238
- // A cron job sees a failed task as a failed run.
239
- failed: once ? () => void (process.exitCode = 1) : undefined,
240
- });
241
- for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
242
- process.on(signal, stop.signal);
243
- let gas;
244
- try {
245
- gas = await chain.ethBalance();
246
- }
247
- catch (error) {
248
- // The rest of a single pass reads the same RPC, so it would only fail the same way twice more.
249
- if (once) {
250
- log(`could not read the chain: ${reason(error)}`);
251
- process.exitCode = 1;
252
- return;
253
- }
254
- log(`could not read the wallet's ETH balance: ${reason(error)}`);
255
- }
256
- if (gas === 0n)
257
- throw noGas(chain, network);
258
- const [line, sick] = await Promise.all([
259
- snapshot(chain).then((s) => hireable(s, network, "run"), (error) => `could not check (${reason(error)})`),
260
- unhealthy(network.service),
261
- ]);
262
- log(`worker ${chain.account} on ${network.name}; hireable: ${line}`);
263
- if (sick)
264
- log(`warning: ${sick}`);
265
- if (once) {
266
- const result = await work.round().catch((error) => {
267
- log(`round failed: ${reason(error)}`);
268
- process.exitCode = 1;
269
- return undefined;
270
- });
271
- if (result?.hired === 0 && result.delivered === 0)
272
- log("no task is hired to this worker right now");
273
- return;
274
- }
275
- log("waiting for tasks (Ctrl-C to stop)");
276
- while (!stop.stopping()) {
277
- await work.round().catch((error) => log(`round failed: ${reason(error)}`));
278
- for (let i = 0; i < 15 && !stop.stopping(); i++)
279
- await new Promise((resolve) => setTimeout(resolve, 1_000));
280
- }
281
- log("stopped");
282
- }
283
- async function main(argv) {
284
- const [command, ...rest] = argv;
285
- if (command === undefined || command === "help" || argv.includes("--help") || argv.includes("-h")) {
286
- console.log(USAGE);
287
- return;
288
- }
289
- if (command === "--version" || command === "-v") {
290
- console.log(version);
291
- return;
292
- }
293
- if (command === "new-key") {
294
- const fresh = generatePrivateKey();
295
- console.log(`BRAINCHAIN_WORKER_KEY=${fresh}\naddress ${privateKeyToAccount(fresh).address}\nKeep the key secret; it controls the stake and the earnings.`);
296
- return;
297
- }
298
- const { positional, values, switches } = parse(command, rest);
299
- const network = resolveNetwork(process.env);
300
- if (command === "claim") {
301
- return claim(network, (income) => createIncome(network.proofs, income, signer().account), { dry: switches.has("dry"), log: console.log });
302
- }
303
- const worker = signer();
304
- if (command === "run")
305
- return run(network, worker, values, switches.has("once"));
306
- const chain = createChain(network, worker.account);
307
- switch (command) {
308
- case "join":
309
- return join(chain, network, values);
310
- case "status":
311
- return status(chain, network);
312
- case "price": {
313
- if (positional[0] === undefined)
314
- throw new UsageError(`Usage: brainchain-worker price <${network.bountySymbol}>, such as price 0.25`);
315
- const asking = price(positional[0]);
316
- if (!(await chain.stake()).registered)
317
- throw new Error("This worker has not staked yet. Run join, which sets the price too.");
318
- await needGas(chain, network);
319
- await chain.setPrice(asking);
320
- console.log(`Price set to ${usdg(network, asking)} per task.`);
321
- return;
322
- }
323
- case "leave": {
324
- const w = await chain.stake();
325
- if (w.staked === 0n) {
326
- throw new Error(w.unstaking > 0n ? `Nothing left to unstake: ${stake(network, w.unstaking)} is already unstaking until ${when(w.unlocksAt)}.` : "Nothing is staked.");
327
- }
328
- await needGas(chain, network);
329
- await chain.requestUnstake(w.staked);
330
- const after = await chain.stake();
331
- console.log(`Unstaking ${stake(network, w.staked)}. Withdraw with \`npx brainchain-worker withdraw\` after ${when(after.unlocksAt)}, once no task of the worker's is in flight.`);
332
- return;
333
- }
334
- case "withdraw": {
335
- const [w, held, now] = await Promise.all([chain.stake(), chain.held(), chain.now()]);
336
- if (w.unstaking === 0n)
337
- throw new Error("Nothing is unstaking. Run leave first.");
338
- if (now < w.unlocksAt)
339
- throw new Error(`${stake(network, w.unstaking)} unlocks at ${when(w.unlocksAt)}. Withdraw after that.`);
340
- if (held > 0)
341
- throw new Error(`The stake is held until the worker's ${held} task${held === 1 ? "" : "s"} in flight settle.`);
342
- await needGas(chain, network);
343
- await chain.withdraw();
344
- console.log(`Withdrew ${stake(network, w.unstaking)}.`);
345
- return;
346
- }
347
- }
348
- }
2
+ import { reason } from "./chain.js";
3
+ import { main, UsageError } from "./cli.js";
349
4
  main(process.argv.slice(2)).catch((error) => {
350
5
  console.error(error instanceof UsageError ? error.message : `brainchain-worker: ${reason(error)}`);
351
6
  process.exit(1);
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
@@ -11,7 +11,15 @@ export const TESTNET = {
11
11
  stakeSymbol: "tBRAIN",
12
12
  bountySymbol: "tUSDG",
13
13
  gas: "Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com and bridge some at https://usebrainchain.com/testnet",
14
- 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
+ },
15
23
  contracts: {
16
24
  taskBoard: "0xC74564F100e70F95346A6e399ebf1Ce14309C8B6",
17
25
  staking: "0xbdC08799D865DA79FE44a469de1FeaE84bb45059",
@@ -42,7 +50,15 @@ export const MAINNET = {
42
50
  buyStake: "Buy BRAIN on ripples.run (https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and bridge it to BrainChain through the " +
43
51
  "Robinhood Chain gateway router 0x03f06a370A59AfB23de029F4EF5EA2F9EB034F96 (docs: https://docs.usebrainchain.com/bridging); " +
44
52
  `the stake token on BrainChain is ${BRAIN}.`,
45
- 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
+ },
46
62
  contracts: {
47
63
  taskBoard: MAINNET_TASK_BOARD,
48
64
  staking: MAINNET_WORKER_STAKING,
@@ -90,3 +106,13 @@ export function chainOf(network) {
90
106
  rpcUrls: { default: { http: [network.rpc] } },
91
107
  });
92
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/standing.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import { formatUnits } from "viem";
2
2
  import { ApplicationState } from "./chain.js";
3
- export const stake = (network, amount) => `${formatUnits(amount, 18)} ${network.stakeSymbol}`;
3
+ /** A token amount with thousands separators, such as 1,250,000; a fraction is kept as it is. */
4
+ function grouped(amount, decimals) {
5
+ const [whole, fraction] = formatUnits(amount, decimals).split(".");
6
+ return `${whole.replace(/\B(?=(\d{3})+$)/g, ",")}${fraction === undefined ? "" : `.${fraction}`}`;
7
+ }
8
+ export const stake = (network, amount) => `${grouped(amount, 18)} ${network.stakeSymbol}`;
4
9
  export const usdg = (network, amount) => `${formatUnits(amount, 6)} ${network.bountySymbol}`;
5
10
  export const when = (seconds) => new Date(Number(seconds) * 1000).toISOString().replace(".000Z", "Z");
6
11
  /** The worker's standing with the board and the staking contract, and what join would do about it. */
@@ -47,7 +52,8 @@ export function hireable({ allowed, w, min, applications, change, pending }, net
47
52
  }
48
53
  /**
49
54
  * The wallet's stake token and, below the minimum stake, where join would find
50
- * what it needs. A stake token without a faucet has no `faucet`.
55
+ * what it needs. A stake token without a faucet has no `faucet`; one that is
56
+ * bought instead says where.
51
57
  */
52
58
  export function wallet(network, s, balance, bond, faucet, now) {
53
59
  const has = stake(network, balance);
@@ -60,7 +66,8 @@ export function wallet(network, s, balance, bond, faucet, now) {
60
66
  return `${has}; join needs ${stake(network, need)} and claims the rest from the faucet, which gives ${stake(network, faucet.amount)} a day`;
61
67
  const reopens = faucet ? `. The faucet opens again for this wallet at ${when(faucet.opensAt)}` : "";
62
68
  const unstaking = s.w.unstaking > 0n ? `, and the ${stake(network, s.w.unstaking)} unstaking can be withdrawn ${now >= s.w.unlocksAt ? "now" : `after ${when(s.w.unlocksAt)}`}` : "";
63
- return `${has}; join needs ${stake(network, need)}${reopens}${unstaking}`;
69
+ const buy = network.buyStake ? `. ${network.buyStake}` : "";
70
+ return `${has}; join needs ${stake(network, need)}${reopens}${unstaking}${buy}`;
64
71
  }
65
72
  /** What join says when the wallet holds too little of a stake token that has no faucet. */
66
73
  export function shortOfStake(network, w, balance, need, now) {
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
  }
@@ -119,8 +126,8 @@ export function lock(file) {
119
126
  }
120
127
  /**
121
128
  * Moves the state 0.3.0 and earlier kept per address alone to `file`, once,
122
- * when it was kept for `board`. State for the other network stays where it is
123
- * until a run there moves it.
129
+ * when it was kept for `board`, and says whether it did. State for the other
130
+ * network stays where it is until a run there moves it.
124
131
  */
125
132
  function migrate(old, file, board) {
126
133
  let text;
@@ -129,30 +136,32 @@ function migrate(old, file, board) {
129
136
  }
130
137
  catch (error) {
131
138
  if (error.code === "ENOENT")
132
- return;
139
+ return false;
133
140
  throw error;
134
141
  }
135
142
  if (parse(text)?.board !== board.toLowerCase())
136
- return;
143
+ return false;
137
144
  // A run from before the move may still be working on this network's tasks from the old file.
138
145
  const pid = holder(`${old}.lock`);
139
146
  if (alive(pid))
140
147
  throw held(pid, `${old}.lock`);
141
148
  if (existsSync(`${file}.json`))
142
- return;
149
+ return false;
143
150
  // Removed rather than moved: this run already holds the new lock.
144
151
  rmSync(`${old}.lock`, { force: true });
145
152
  renameSync(`${old}.json`, `${file}.json`);
153
+ return true;
146
154
  }
147
155
  /**
148
156
  * Takes the worker's lock on one network and loads its saved state. Each
149
157
  * network has its own `<address>.<network>.json` and `.lock`, so one key can
150
158
  * run on testnet and mainnet at once without sharing cursors or tasks.
151
159
  */
152
- export function openStore(address, network, warn, dir = stateDir()) {
160
+ export function openStore(address, network, log = console.log, dir = stateDir()) {
153
161
  const old = join(dir, address);
154
162
  const file = `${old}.${network.id}`;
155
163
  lock(`${file}.lock`);
156
- migrate(old, file, network.contracts.taskBoard);
157
- return fileStore(`${file}.json`, network.contracts.taskBoard, warn);
164
+ if (migrate(old, file, network.contracts.taskBoard))
165
+ log(`moved ${old}.json to ${file}.json`);
166
+ return fileStore(`${file}.json`, network.contracts.taskBoard, (message) => log(`warning: ${message}`));
158
167
  }
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.1",
3
+ "version": "0.4.1",
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>",