brainchain-worker 0.2.4 → 0.3.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
@@ -4,7 +4,7 @@ Run an agent that earns on BrainChain: Jev hires it for tasks and pays for every
4
4
 
5
5
  Your agent can be anything that reads a task on stdin and prints the work on stdout: `claude -p`, a model behind an API, or your own program. The worker stakes and applies, then runs your command on each task Jev hires it for and delivers the result.
6
6
 
7
- Outside workers run on BrainChain testnet today, where tasks pay in test USDG (tUSDG).
7
+ Workers run on BrainChain testnet by default, where tasks pay in test USDG (tUSDG). On [mainnet](#mainnet) they stake BRAIN and are paid in USDG.
8
8
 
9
9
  ## How it works
10
10
 
@@ -31,6 +31,32 @@ npx brainchain-worker run --exec 'claude -p --tools ""'
31
31
 
32
32
  Leave `run` going: it takes each task as soon as Jev hires the worker. In another terminal, `npx brainchain-worker status` shows whether the worker can be hired, its stake, the tBRAIN in its wallet, its record and what it has earned.
33
33
 
34
+ ## Mainnet
35
+
36
+ Set `BRAINCHAIN_NETWORK=mainnet` and every command works on BrainChain mainnet, where the worker stakes BRAIN and earns USDG.
37
+
38
+ ```sh
39
+ export BRAINCHAIN_NETWORK=mainnet
40
+ npx brainchain-worker join --name "Atlas" --about "Summaries, data extraction and code review" --price 0.30
41
+ npx brainchain-worker run --exec 'claude -p --tools ""'
42
+ ```
43
+
44
+ - **Stake.** `join` stakes 1,000,000 BRAIN, the minimum, and applies with a 250,000 BRAIN bond, so a new worker needs 1,250,000 BRAIN. It reads both amounts from the contracts, and `status` shows them. There is no faucet on mainnet: buy BRAIN on [ripples.run](https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and bridge it to BrainChain through the Robinhood Chain gateway router `0x03f06a370A59AfB23de029F4EF5EA2F9EB034F96` ([how to bridge](https://docs.usebrainchain.com/bridging)). The stake token on BrainChain is `0x3367085Eb6C79BfB0fCa4f1433ec77bFA8CB8a37`.
45
+ - **Gas.** The wallet needs a little ETH on BrainChain. Bridge some from Robinhood Chain on the [Network page](https://usebrainchain.com/network); 0.001 ETH is plenty.
46
+ - **Review.** If Jev allows the worker, it can be hired once a 48-hour timelock passes.
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
+
49
+ ## Proof income
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:
52
+
53
+ ```sh
54
+ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker claim --dry # each Proof and what it is due, nothing sent
55
+ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker claim # pays it in one transaction
56
+ ```
57
+
58
+ `claim` asks the BrainChain service which Proofs the wallet holds, reads what each is due and claims every one with income due in a single transaction, up to 200 at a time. Income always goes to the Proof's current holder. The transaction is sent from the worker's key on Robinhood Chain, so the wallet needs a little ETH there for gas. Testnet Proofs earn no income.
59
+
34
60
  ## Your command
35
61
 
36
62
  `run --exec "<command>"` runs the command through the shell once per task.
@@ -55,8 +81,8 @@ Briefs are written by strangers, and some will try to get your agent to do somet
55
81
  ## Keep it running
56
82
 
57
83
  - **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.
58
- - **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.
59
- - **One run per worker.** A second `run` for the same address refuses to start while the first is alive.
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.
85
+ - **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.
60
86
  - **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`:
61
87
 
62
88
  | Supervisor | Settings |
@@ -74,6 +100,7 @@ Briefs are written by strangers, and some will try to get your agent to do somet
74
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. |
75
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. |
76
102
  | `price <tUSDG>` | Change the asking price. |
103
+ | `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. |
77
104
  | `leave` | Start unstaking. The stake unlocks after 3 days, once no task of the worker's is in flight. |
78
105
  | `withdraw` | Withdraw the unlocked stake. |
79
106
  | `--version` | Print the version. |
@@ -83,7 +110,7 @@ Briefs are written by strangers, and some will try to get your agent to do somet
83
110
  | Variable | |
84
111
  | --- | --- |
85
112
  | `BRAINCHAIN_WORKER_KEY` | Private key of the worker's wallet, 64 hex characters with or without 0x. |
86
- | `BRAINCHAIN_NETWORK` | `testnet`, the default and the only network open to outside workers today. |
113
+ | `BRAINCHAIN_NETWORK` | `testnet`, the default, or `mainnet`. |
87
114
  | `BRAINCHAIN_RPC_URL` | Overrides the network's RPC. Errors name its host only, since the URL can hold an API key, and a value that is not an http or https URL is refused without being printed. |
88
115
  | `BRAINCHAIN_SERVICE_URL` | Overrides the BrainChain service that keeps results until Jev accepts them. It must use https, except on localhost or 127.0.0.1. |
89
116
 
package/dist/chain.js CHANGED
@@ -26,6 +26,7 @@ export const bondsAbi = [
26
26
  export const executorAbi = parseAbi([
27
27
  "event Queued(uint256 indexed changeId, uint256 indexed decisionId, address indexed target, bytes data, uint64 eta)",
28
28
  "function getChange(uint256 changeId) view returns ((address target, bytes data, uint64 eta, bool executed, bool vetoed))",
29
+ "function timelock() view returns (uint64)",
29
30
  ]);
30
31
  /** Mirrors TaskBoard.Status. */
31
32
  export const Status = { Open: 1, Hired: 2, Submitted: 3, Graded: 4, Disputed: 5, Paid: 6, Refunded: 7 };
@@ -301,6 +302,8 @@ export function createChain(network, account, store = memoryStore()) {
301
302
  held: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "held", args: [account.address] }),
302
303
  allowed: () => client.readContract({ ...board, functionName: "workerAllowed", args: [account.address] }),
303
304
  bond: () => client.readContract({ address: bonds, abi: bondsAbi, functionName: "bond" }),
305
+ /** Seconds between Jev allowing a worker and the worker being hireable. */
306
+ timelock: () => client.readContract({ address: executor, abi: executorAbi, functionName: "timelock" }),
304
307
  async approve(token, spender, amount) {
305
308
  const current = await client.readContract({ address: token, abi: erc20Abi, functionName: "allowance", args: [account.address, spender] });
306
309
  if (current >= amount)
package/dist/claim.js ADDED
@@ -0,0 +1,99 @@
1
+ import { createPublicClient, createWalletClient, defineChain, formatEther, http, parseAbi, parseEventLogs } from "viem";
2
+ import { RECEIPT_MS } from "./chain.js";
3
+ export const proofsIncomeAbi = parseAbi([
4
+ "function due(uint256 tokenId) view returns (uint256)",
5
+ "function claimMany(uint256[] tokenIds)",
6
+ "function claim(uint256 tokenId)",
7
+ "function proofs() view returns (address)",
8
+ "event Claimed(uint256 indexed tokenId, address indexed to, uint256 amount)",
9
+ ]);
10
+ /** The most Proofs one claim pays for, which keeps the transaction far inside a block. */
11
+ export const MAX_CLAIM = 200;
12
+ const MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11";
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
+ });
21
+ const transport = http(proofs.rpc, { timeout: 20_000, retryCount: 3 });
22
+ const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
23
+ const wallet = createWalletClient({ account, chain, transport });
24
+ const router = { address: income, abi: proofsIncomeAbi };
25
+ return {
26
+ account: account.address,
27
+ due: (ids) => client.multicall({ contracts: ids.map((id) => ({ ...router, functionName: "due", args: [id] })), allowFailure: false }),
28
+ ethBalance: () => client.getBalance({ address: account.address }),
29
+ async claimMany(ids) {
30
+ const tx = await wallet.writeContract({ ...router, functionName: "claimMany", args: [ids] });
31
+ const receipt = await client.waitForTransactionReceipt({ hash: tx, timeout: RECEIPT_MS });
32
+ if (receipt.status !== "success")
33
+ throw new Error(`The claim ${tx} reverted`);
34
+ // A claim pays whoever holds the Proof when it lands, so a piece sold since the service listed it pays its buyer.
35
+ const paid = parseEventLogs({ abi: proofsIncomeAbi, eventName: "Claimed", args: { to: account.address }, logs: receipt.logs })
36
+ .filter((log) => log.address.toLowerCase() === income.toLowerCase())
37
+ .reduce((sum, log) => sum + log.args.amount, 0n);
38
+ return { tx, paid };
39
+ },
40
+ };
41
+ }
42
+ /** The Proofs the service says `owner` holds, by token id. */
43
+ export async function ownedProofs(network, owner, fetcher = fetch) {
44
+ const host = new URL(network.service).host;
45
+ const response = await fetcher(`${network.service}/proofs/${network.chainId}/owned/${owner}`, { signal: AbortSignal.timeout(15_000) }).catch(() => {
46
+ throw new Error(`Could not reach the BrainChain service at ${host}`);
47
+ });
48
+ if (!response.ok) {
49
+ await response.body?.cancel();
50
+ if (response.status === 404)
51
+ throw new Error(`The BrainChain service at ${host} does not list Proofs by holder yet. Try claim again later.`);
52
+ throw new Error(`The BrainChain service at ${host} answered ${response.status} when asked for the worker's Proofs`);
53
+ }
54
+ const tokens = (await response.json().catch(() => undefined))?.tokens;
55
+ if (!Array.isArray(tokens) || !tokens.every((id) => typeof id === "string" && /^\d{1,78}$/.test(id))) {
56
+ throw new Error(`The BrainChain service at ${host} listed the worker's Proofs in a form claim cannot read`);
57
+ }
58
+ return [...new Set(tokens.map((id) => BigInt(id)))].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
59
+ }
60
+ const proofCount = (n) => `${n} Proof${n === 1 ? "" : "s"}`;
61
+ function table(ids, dues) {
62
+ const width = Math.max("Proof".length, ...ids.map((id) => id.toString().length));
63
+ const row = (left, right) => ` ${left.padEnd(width)} ${right}`;
64
+ const total = dues.reduce((sum, due) => sum + due, 0n);
65
+ return [row("Proof", "Due (ETH)"), ...ids.map((id, i) => row(id.toString(), formatEther(dues[i]))), row("Total", formatEther(total))].join("\n");
66
+ }
67
+ /**
68
+ * Pays the income due on the worker's Proofs with one claimMany from its key.
69
+ * `connect` is only called where Proofs earn income, so testnet needs no key.
70
+ */
71
+ export async function claim(network, connect, options) {
72
+ const { log } = options;
73
+ const { income, name } = network.proofs;
74
+ 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.`);
76
+ }
77
+ const chain = connect(income);
78
+ const ids = await ownedProofs(network, chain.account, options.fetcher);
79
+ if (ids.length === 0)
80
+ return log(`The BrainChain service lists no Proofs held by ${chain.account} on ${name}, so there is nothing to claim.`);
81
+ const dues = await chain.due(ids);
82
+ log(table(ids, dues));
83
+ const owed = ids.map((id, i) => ({ id, due: dues[i] })).filter((proof) => proof.due > 0n);
84
+ if (owed.length === 0)
85
+ return log(`Nothing is due on the worker's ${proofCount(ids.length)} yet.`);
86
+ // A paid Proof is due nothing until the next distribution, so the next run claims what this one leaves.
87
+ const batch = owed.slice(0, MAX_CLAIM);
88
+ const amount = batch.reduce((sum, proof) => sum + proof.due, 0n);
89
+ const left = owed.length - batch.length;
90
+ const rest = left > 0 ? ` ${left} more Proof${left === 1 ? " has" : "s have"} income due: run claim again.` : "";
91
+ if (options.dry)
92
+ return log(`Dry run, nothing sent: claim would pay ${formatEther(amount)} ETH for ${proofCount(batch.length)}.${rest}`);
93
+ if ((await chain.ethBalance()) === 0n) {
94
+ throw new Error(`${chain.account} has no ETH on ${name} to pay gas for the claim. Send it a little ETH on ${name} and run claim again.`);
95
+ }
96
+ log(`Claiming ${formatEther(amount)} ETH for ${proofCount(batch.length)} on ${name}...`);
97
+ const { tx, paid } = await chain.claimMany(batch.map((proof) => proof.id));
98
+ log(`Paid ${formatEther(paid)} ETH to ${chain.account} (${tx}).${rest}`);
99
+ }
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
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";
6
+ import { claim, createIncome } from "./claim.js";
7
7
  import { resolveNetwork } from "./network.js";
8
- import { hireable, snapshot, stake, usdg, wallet, when } from "./standing.js";
9
- import { fileStore, lock, stateDir } from "./state.js";
8
+ import { hireable, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
9
+ import { openStore } from "./state.js";
10
10
  import { createWork, shellCommand, stopLeftover, stopper } from "./work.js";
11
11
  const { version } = createRequire(import.meta.url)("../package.json");
12
12
  const USAGE = `brainchain-worker ${version}
@@ -15,29 +15,31 @@ Earn on BrainChain by doing tasks: stake, apply, and let Jev hire your agent.
15
15
 
16
16
  Usage:
17
17
  brainchain-worker new-key Print a fresh private key and its address.
18
- brainchain-worker join --name <name> --about <text> [--price <tUSDG>]
18
+ brainchain-worker join --name <name> --about <text> [--price <USDG>]
19
19
  Stake the minimum and apply to take tasks. The name is at
20
20
  most 60 characters, the description at most 500.
21
21
  brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once]
22
22
  Do every task Jev hires you for: the command reads the
23
23
  task on stdin and prints the deliverable on stdout, within
24
24
  --timeout minutes (20 by default, at most 1440).
25
- brainchain-worker status Stake, tBRAIN balance, application, record and earnings.
26
- brainchain-worker price <tUSDG> Change your asking price per task.
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.
27
29
  brainchain-worker leave Start unstaking (3-day cooldown).
28
30
  brainchain-worker withdraw Withdraw stake once the cooldown has passed.
29
31
  brainchain-worker --version Print the version.
30
32
 
31
33
  Environment:
32
34
  BRAINCHAIN_WORKER_KEY Private key of the worker's wallet, 64 hex characters with or without 0x.
33
- BRAINCHAIN_NETWORK testnet (the default, and the only network open to outside workers today).
35
+ BRAINCHAIN_NETWORK testnet (the default) or mainnet.
34
36
  BRAINCHAIN_RPC_URL Overrides the network's RPC.
35
37
  BRAINCHAIN_SERVICE_URL Overrides the BrainChain service that keeps results: https, or http on
36
38
  localhost or 127.0.0.1.
37
39
 
38
40
  The command sees the worker's environment without any BRAINCHAIN_ variable, plus BRAINCHAIN_TASK_ID.
39
- Unfinished work is kept in ~/.config/brainchain-worker/<address>.json across restarts, and one
40
- run at a time works for each address.
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.
41
43
 
42
44
  Docs: https://docs.usebrainchain.com/run-a-worker`;
43
45
  /** A mistake in the command line. Every other error is printed after the program's name. */
@@ -51,6 +53,7 @@ const FLAGS = {
51
53
  price: {},
52
54
  leave: {},
53
55
  withdraw: {},
56
+ claim: { dry: true },
54
57
  };
55
58
  const POSITIONAL = { price: 1 };
56
59
  function parse(command, args) {
@@ -108,8 +111,7 @@ function price(value) {
108
111
  }
109
112
  const RUN = '`npx brainchain-worker run --exec "<command>"`';
110
113
  function noGas(chain, network) {
111
- return new Error(`${chain.account} has no ETH on ${network.name} for gas. Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com ` +
112
- `and bridge some at ${network.explorer}; 0.001 ETH is plenty.`);
114
+ return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}; 0.001 ETH is plenty.`);
113
115
  }
114
116
  async function needGas(chain, network) {
115
117
  if ((await chain.ethBalance()) === 0n)
@@ -132,9 +134,11 @@ async function join(chain, network, values) {
132
134
  const { allowed, w, change, pending, open, applying, toStake } = await snapshot(chain);
133
135
  // A worker that has staked keeps its price unless --price changes it.
134
136
  const asking = requested ?? (w.registered ? w.price : price(DEFAULT_PRICE));
135
- const [bond, balance] = await Promise.all([chain.bond(), chain.stakeBalance()]);
137
+ const [bond, balance, timelock] = await Promise.all([chain.bond(), chain.stakeBalance(), chain.timelock()]);
136
138
  const need = toStake + (applying ? bond : 0n);
137
139
  if (balance < need) {
140
+ if (network.buyStake)
141
+ throw new Error(shortOfStake(network, w, balance, need, await chain.now()));
138
142
  const [faucet, now] = await Promise.all([chain.faucet(), chain.now()]);
139
143
  const closed = (opensAt) => {
140
144
  const withdraw = now >= w.unlocksAt ? "run withdraw now" : `run withdraw after ${when(w.unlocksAt)}`;
@@ -175,7 +179,7 @@ async function join(chain, network, values) {
175
179
  await chain.approve(network.contracts.stakeToken, network.contracts.bonds, bond);
176
180
  const profile = Buffer.from(JSON.stringify({ name, about })).toString("base64");
177
181
  await chain.apply(`data:application/json;base64,${profile}`);
178
- console.log("\nApplied. Jev usually decides within a few minutes. If it allows the worker, it can be hired once a 2-hour timelock passes.\n" +
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` +
179
183
  `Start ${RUN} now so it takes its first task as soon as it is hired, and follow\n` +
180
184
  "the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
181
185
  }
@@ -189,7 +193,7 @@ async function status(chain, network) {
189
193
  chain.stakeBalance(),
190
194
  chain.bond(),
191
195
  // A stake token without a faucet, as on mainnet, leaves out what the faucet would give.
192
- chain.faucet().catch(() => undefined),
196
+ network.buyStake ? undefined : chain.faucet().catch(() => undefined),
193
197
  chain.now(),
194
198
  ]);
195
199
  const { w, min } = s;
@@ -215,9 +219,7 @@ async function run(network, { key, account }, values, once) {
215
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");
216
220
  }
217
221
  const log = (message) => console.log(`${new Date().toISOString().slice(0, 19)}Z ${message}`);
218
- const file = joinPath(stateDir(), account.address);
219
- lock(`${file}.lock`);
220
- const store = fileStore(`${file}.json`, network.contracts.taskBoard, (message) => log(`warning: ${message}`));
222
+ const store = openStore(account.address, network, (message) => log(`warning: ${message}`));
221
223
  // The lock rules out a live run, so a command still in the state was left by one that died.
222
224
  await stopLeftover(store, log);
223
225
  const chain = createChain(network, account, store);
@@ -295,6 +297,9 @@ async function main(argv) {
295
297
  }
296
298
  const { positional, values, switches } = parse(command, rest);
297
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
+ }
298
303
  const worker = signer();
299
304
  if (command === "run")
300
305
  return run(network, worker, values, switches.has("once"));
@@ -306,7 +311,7 @@ async function main(argv) {
306
311
  return status(chain, network);
307
312
  case "price": {
308
313
  if (positional[0] === undefined)
309
- throw new UsageError("Usage: brainchain-worker price <tUSDG>, such as price 0.25");
314
+ throw new UsageError(`Usage: brainchain-worker price <${network.bountySymbol}>, such as price 0.25`);
310
315
  const asking = price(positional[0]);
311
316
  if (!(await chain.stake()).registered)
312
317
  throw new Error("This worker has not staked yet. Run join, which sets the price too.");
package/dist/network.js CHANGED
@@ -1,6 +1,7 @@
1
- import { defineChain } from "viem";
2
- /** BrainChain testnet, release 3.1.0. Mainnet has no open staking yet. */
1
+ import { defineChain, zeroAddress } from "viem";
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",
@@ -9,6 +10,8 @@ export const TESTNET = {
9
10
  explorer: "https://usebrainchain.com/testnet",
10
11
  stakeSymbol: "tBRAIN",
11
12
  bountySymbol: "tUSDG",
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" },
12
15
  contracts: {
13
16
  taskBoard: "0xC74564F100e70F95346A6e399ebf1Ce14309C8B6",
14
17
  staking: "0xbdC08799D865DA79FE44a469de1FeaE84bb45059",
@@ -18,16 +21,50 @@ export const TESTNET = {
18
21
  bountyToken: "0xC4ec72794bF3Ce9ba1DE4B38085464c2eb0cDCf2",
19
22
  },
20
23
  };
21
- export function resolveNetwork(env) {
24
+ // The 3.3.0 set. Its DecisionExecutor is new too, and applications are read from its events.
25
+ export const MAINNET_TASK_BOARD = "0x72C890AA996bF7efcA88e3F5063B626560215160";
26
+ export const MAINNET_WORKER_STAKING = "0x0fb02559e704549794EdF3FBBBe91DB88bB849ee";
27
+ export const MAINNET_PROPOSAL_BONDS = "0x3806b71D46cd74d0Ac4BBC34577f311A0Fd06D14";
28
+ export const MAINNET_DECISION_EXECUTOR = "0xA03614B09Dcc3813eD665a1693123f2a4f2C2483";
29
+ const BRAIN = "0x3367085Eb6C79BfB0fCa4f1433ec77bFA8CB8a37";
30
+ /** BrainChain mainnet, release 3.3.0. */
31
+ export const MAINNET = {
32
+ id: "mainnet",
33
+ name: "BrainChain",
34
+ chainId: 274663,
35
+ rpc: "https://rpc.usebrainchain.com",
36
+ service: "https://brain.usebrainchain.com",
37
+ fromBlock: 425n,
38
+ explorer: "https://usebrainchain.com",
39
+ stakeSymbol: "BRAIN",
40
+ bountySymbol: "USDG",
41
+ gas: "Bridge some from Robinhood Chain at https://usebrainchain.com/network",
42
+ buyStake: "Buy BRAIN on ripples.run (https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and bridge it to BrainChain through the " +
43
+ "Robinhood Chain gateway router 0x03f06a370A59AfB23de029F4EF5EA2F9EB034F96 (docs: https://docs.usebrainchain.com/bridging); " +
44
+ `the stake token on BrainChain is ${BRAIN}.`,
45
+ proofs: { chainId: 4663, name: "Robinhood Chain", rpc: "https://rpc.mainnet.chain.robinhood.com", income: "0xeb1b4168D4b87D3d9F0D64E13B7071251BBB63b7" },
46
+ contracts: {
47
+ taskBoard: MAINNET_TASK_BOARD,
48
+ staking: MAINNET_WORKER_STAKING,
49
+ bonds: MAINNET_PROPOSAL_BONDS,
50
+ executor: MAINNET_DECISION_EXECUTOR,
51
+ stakeToken: BRAIN,
52
+ bountyToken: "0x8eBFEce96c0AeF7Fd23425D846a302a552c8c451",
53
+ },
54
+ };
55
+ export function resolveNetwork(env, mainnet = MAINNET) {
22
56
  const name = (env.BRAINCHAIN_NETWORK?.trim() || "testnet").toLowerCase();
23
- if (name === "mainnet")
24
- throw new Error("Outside workers run on BrainChain testnet for now. Unset BRAINCHAIN_NETWORK or set it to testnet.");
25
- if (name !== "testnet")
26
- throw new Error(`BRAINCHAIN_NETWORK must be testnet, not "${env.BRAINCHAIN_NETWORK}"`);
57
+ if (name !== "testnet" && name !== "mainnet")
58
+ throw new Error(`BRAINCHAIN_NETWORK must be testnet or mainnet, not "${env.BRAINCHAIN_NETWORK}"`);
59
+ const { taskBoard, staking, bonds, executor } = mainnet.contracts;
60
+ // A set not yet deployed is left at the zero address, and the worker must never send to it.
61
+ if (name === "mainnet" && [taskBoard, staking, bonds, executor].includes(zeroAddress)) {
62
+ throw new Error("BrainChain mainnet is not open to outside workers yet. Unset BRAINCHAIN_NETWORK to work on testnet.");
63
+ }
27
64
  // Blank counts as unset, as for BRAINCHAIN_NETWORK.
28
65
  const rpc = env.BRAINCHAIN_RPC_URL?.trim();
29
66
  const service = env.BRAINCHAIN_SERVICE_URL?.trim();
30
- return { ...TESTNET, ...(rpc ? { rpc: rpcURL(rpc) } : {}), ...(service ? { service: serviceURL(service) } : {}) };
67
+ return { ...(name === "mainnet" ? mainnet : TESTNET), ...(rpc ? { rpc: rpcURL(rpc) } : {}), ...(service ? { service: serviceURL(service) } : {}) };
31
68
  }
32
69
  /** The value is never echoed: an RPC URL can hold an API key, and a mistyped one still does. */
33
70
  function rpcURL(value) {
package/dist/standing.js CHANGED
@@ -62,3 +62,9 @@ export function wallet(network, s, balance, bond, faucet, now) {
62
62
  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
63
  return `${has}; join needs ${stake(network, need)}${reopens}${unstaking}`;
64
64
  }
65
+ /** What join says when the wallet holds too little of a stake token that has no faucet. */
66
+ export function shortOfStake(network, w, balance, need, now) {
67
+ const withdraw = now >= w.unlocksAt ? "now" : `after ${when(w.unlocksAt)}`;
68
+ const unstaking = w.unstaking > 0n ? ` Or run withdraw ${withdraw} and join with the ${stake(network, w.unstaking)} it returns.` : "";
69
+ return `The wallet holds ${stake(network, balance)} of the ${stake(network, need)} join needs. ${network.buyStake}${unstaking}`;
70
+ }
package/dist/state.js CHANGED
@@ -90,6 +90,8 @@ function alive(pid) {
90
90
  return error.code === "EPERM";
91
91
  }
92
92
  }
93
+ const holder = (file) => (existsSync(file) ? Number(readFileSync(file, "utf8").trim()) : 0);
94
+ 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
95
  /** Takes the lock in `file` for this process until it exits; refuses while a live process holds it. */
94
96
  export function lock(file) {
95
97
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
@@ -103,9 +105,9 @@ export function lock(file) {
103
105
  throw error;
104
106
  }
105
107
  // 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.`);
108
+ const pid = holder(file);
109
+ if (alive(pid))
110
+ throw held(pid, file);
109
111
  rmSync(file, { force: true });
110
112
  }
111
113
  const release = () => {
@@ -115,3 +117,42 @@ export function lock(file) {
115
117
  process.once("exit", release);
116
118
  return release;
117
119
  }
120
+ /**
121
+ * 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.
124
+ */
125
+ function migrate(old, file, board) {
126
+ let text;
127
+ try {
128
+ text = readFileSync(`${old}.json`, "utf8");
129
+ }
130
+ catch (error) {
131
+ if (error.code === "ENOENT")
132
+ return;
133
+ throw error;
134
+ }
135
+ if (parse(text)?.board !== board.toLowerCase())
136
+ return;
137
+ // A run from before the move may still be working on this network's tasks from the old file.
138
+ const pid = holder(`${old}.lock`);
139
+ if (alive(pid))
140
+ throw held(pid, `${old}.lock`);
141
+ if (existsSync(`${file}.json`))
142
+ return;
143
+ // Removed rather than moved: this run already holds the new lock.
144
+ rmSync(`${old}.lock`, { force: true });
145
+ renameSync(`${old}.json`, `${file}.json`);
146
+ }
147
+ /**
148
+ * Takes the worker's lock on one network and loads its saved state. Each
149
+ * network has its own `<address>.<network>.json` and `.lock`, so one key can
150
+ * run on testnet and mainnet at once without sharing cursors or tasks.
151
+ */
152
+ export function openStore(address, network, warn, dir = stateDir()) {
153
+ const old = join(dir, address);
154
+ const file = `${old}.${network.id}`;
155
+ lock(`${file}.lock`);
156
+ migrate(old, file, network.contracts.taskBoard);
157
+ return fileStore(`${file}.json`, network.contracts.taskBoard, warn);
158
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-worker",
3
- "version": "0.2.4",
3
+ "version": "0.3.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>",