brainchain-worker 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/chain.js ADDED
@@ -0,0 +1,200 @@
1
+ import { createPublicClient, createWalletClient, encodeFunctionData, http, keccak256, parseAbi, toBytes, } from "viem";
2
+ import { taskBoardAbi, workerStakingAbi } from "./abi.js";
3
+ import { chainOf } from "./network.js";
4
+ export const erc20Abi = parseAbi([
5
+ "function balanceOf(address) view returns (uint256)",
6
+ "function allowance(address owner, address spender) view returns (uint256)",
7
+ "function approve(address spender, uint256 amount) returns (bool)",
8
+ "function faucet()",
9
+ "error TooSoon(uint256 nextClaimAt)",
10
+ ]);
11
+ export const bondsAbi = parseAbi([
12
+ "event Proposed(uint256 indexed id, address indexed proposer, address indexed target, bytes data, uint128 bond, string uri)",
13
+ "function propose(address target, bytes data, string uri) returns (uint256)",
14
+ "function get(uint256 id) view returns ((address proposer, uint128 bond, uint64 createdAt, uint8 state, bytes32 subject))",
15
+ "function bond() view returns (uint128)",
16
+ ]);
17
+ export const executorAbi = parseAbi([
18
+ "event Queued(uint256 indexed changeId, uint256 indexed decisionId, address indexed target, bytes data, uint64 eta)",
19
+ "function getChange(uint256 changeId) view returns ((address target, bytes data, uint64 eta, bool executed, bool vetoed))",
20
+ ]);
21
+ /** Mirrors TaskBoard.Status. */
22
+ export const Status = { Open: 1, Hired: 2, Submitted: 3, Graded: 4, Disputed: 5, Paid: 6, Refunded: 7 };
23
+ /** Mirrors ProposalBonds.State. */
24
+ export const ApplicationState = { Open: 1, Returned: 2, Burned: 3 };
25
+ const LOG_RANGE = 50000n;
26
+ /** What the worker signs to upload a result: the task and the hash of exactly the bytes it uploads. */
27
+ export function resultMessage(task, hash) {
28
+ return `BrainChain result\ntask ${task}\nhash ${hash}`;
29
+ }
30
+ /** WorkerStaking.idOf: a worker's id is its account, left-padded to 32 bytes. */
31
+ export function workerId(account) {
32
+ return `0x${account.slice(2).toLowerCase().padStart(64, "0")}`;
33
+ }
34
+ export function applicationData(account) {
35
+ return encodeFunctionData({ abi: taskBoardAbi, functionName: "setWorkerAllowed", args: [account, true] });
36
+ }
37
+ export function createChain(network, account) {
38
+ const chain = chainOf(network);
39
+ const transport = http(network.rpc, { timeout: 20_000, retryCount: 3 });
40
+ // BrainChain mines a block per transaction, so receipts are polled every second.
41
+ const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
42
+ const wallet = createWalletClient({ account, chain, transport });
43
+ const { taskBoard, staking, bonds, executor, stakeToken, bountyToken } = network.contracts;
44
+ const id = workerId(account.address);
45
+ const board = { address: taskBoard, abi: taskBoardAbi };
46
+ async function send(request) {
47
+ const hash = await wallet.writeContract(request);
48
+ const receipt = await client.waitForTransactionReceipt({ hash, timeout: 90_000 });
49
+ if (receipt.status !== "success")
50
+ throw new Error(`Transaction ${hash} reverted`);
51
+ return hash;
52
+ }
53
+ /** Every log of one event up to `latest`, in windows the RPC accepts. */
54
+ async function scan(fetch, upTo) {
55
+ const latest = upTo ?? (await client.getBlockNumber());
56
+ const all = [];
57
+ for (let from = network.fromBlock; from <= latest; from += LOG_RANGE) {
58
+ const to = from + LOG_RANGE - 1n < latest ? from + LOG_RANGE - 1n : latest;
59
+ all.push(...(await fetch(from, to)));
60
+ }
61
+ return all;
62
+ }
63
+ const hiredIds = () => scan((fromBlock, toBlock) => client.getContractEvents({ ...board, eventName: "WorkerHired", args: { model: id }, fromBlock, toBlock })).then((logs) => logs.map((log) => log.args.id));
64
+ const briefs = new Map();
65
+ async function briefURI(task) {
66
+ const known = briefs.get(task);
67
+ if (known !== undefined)
68
+ return known;
69
+ const [posted] = await scan((fromBlock, toBlock) => client.getContractEvents({ ...board, eventName: "Posted", args: { id: task }, fromBlock, toBlock }));
70
+ if (posted === undefined)
71
+ throw new Error(`Task ${task} has no Posted event`);
72
+ briefs.set(task, posted.args.briefURI);
73
+ return posted.args.briefURI;
74
+ }
75
+ const settled = new Set();
76
+ const work = {
77
+ async now() {
78
+ const block = await client.getBlock();
79
+ const wall = BigInt(Math.floor(Date.now() / 1000));
80
+ // An idle chain makes no blocks, so the latest block's time can lag the clock.
81
+ return block.timestamp > wall ? block.timestamp : wall;
82
+ },
83
+ async hired() {
84
+ const ids = (await hiredIds()).filter((t) => !settled.has(t));
85
+ const tasks = [];
86
+ for (const task of ids) {
87
+ const t = await client.readContract({ ...board, functionName: "get", args: [task] });
88
+ if (t.status !== Status.Hired || t.model.toLowerCase() !== id) {
89
+ if (t.status >= Status.Paid)
90
+ settled.add(task);
91
+ continue;
92
+ }
93
+ tasks.push({ id: task, deadline: t.deadline, briefURI: await briefURI(task), briefHash: t.briefHash });
94
+ }
95
+ return tasks;
96
+ },
97
+ async publish(task, text) {
98
+ const hash = keccak256(toBytes(text));
99
+ const signature = await account.signMessage({ message: resultMessage(task, hash) });
100
+ const response = await fetch(`${network.service}/results`, {
101
+ method: "POST",
102
+ headers: { "content-type": "application/json" },
103
+ body: JSON.stringify({ task: task.toString(), text, signature }),
104
+ signal: AbortSignal.timeout(30_000),
105
+ });
106
+ const body = (await response.json().catch(() => ({})));
107
+ if (!response.ok || !body.uri)
108
+ throw new Error(`Uploading the result failed (${response.status}): ${body.error ?? "no reason given"}`);
109
+ if (body.hash?.toLowerCase() !== hash.toLowerCase())
110
+ throw new Error("The service stored different bytes than were sent");
111
+ return { uri: body.uri, hash };
112
+ },
113
+ submit: (task, resultHash, resultURI) => send({ ...board, functionName: "submit", args: [task, resultHash, resultURI] }),
114
+ };
115
+ return {
116
+ ...work,
117
+ account: account.address,
118
+ id,
119
+ client,
120
+ send,
121
+ ethBalance: () => client.getBalance({ address: account.address }),
122
+ balance: (token) => client.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
123
+ stakeBalance: () => client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
124
+ bountyBalance: () => client.readContract({ address: bountyToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
125
+ stake: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "get", args: [account.address] }),
126
+ minStake: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "minStake" }),
127
+ inFlight: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "held", args: [account.address] }),
128
+ allowed: () => client.readContract({ ...board, functionName: "workerAllowed", args: [account.address] }),
129
+ bond: () => client.readContract({ address: bonds, abi: bondsAbi, functionName: "bond" }),
130
+ async approve(token, spender, amount) {
131
+ const current = await client.readContract({ address: token, abi: erc20Abi, functionName: "allowance", args: [account.address, spender] });
132
+ if (current >= amount)
133
+ return;
134
+ await send({ address: token, abi: erc20Abi, functionName: "approve", args: [spender, amount] });
135
+ },
136
+ claimStakeToken: () => send({ address: stakeToken, abi: erc20Abi, functionName: "faucet" }),
137
+ stakeTokens: (amount, price) => send({ address: staking, abi: workerStakingAbi, functionName: "stake", args: [amount, price] }),
138
+ setPrice: (price) => send({ address: staking, abi: workerStakingAbi, functionName: "setPrice", args: [price] }),
139
+ requestUnstake: (amount) => send({ address: staking, abi: workerStakingAbi, functionName: "requestUnstake", args: [amount] }),
140
+ withdraw: () => send({ address: staking, abi: workerStakingAbi, functionName: "withdraw" }),
141
+ apply: (profileURI) => send({ address: bonds, abi: bondsAbi, functionName: "propose", args: [taskBoard, applicationData(account.address), profileURI] }),
142
+ /**
143
+ * This worker's applications, newest last, with their state, and the last
144
+ * change any of them queued. Everything is read at one block, so a bond
145
+ * settled just after its change was queued is never seen without the change.
146
+ */
147
+ async applications() {
148
+ const data = applicationData(account.address).toLowerCase();
149
+ const blockNumber = await client.getBlockNumber();
150
+ const [proposed, queued] = await Promise.all([
151
+ scan((fromBlock, toBlock) => client.getContractEvents({ address: bonds, abi: bondsAbi, eventName: "Proposed", args: { proposer: account.address, target: taskBoard }, fromBlock, toBlock }), blockNumber),
152
+ scan((fromBlock, toBlock) => client.getContractEvents({ address: executor, abi: executorAbi, eventName: "Queued", args: { target: taskBoard }, fromBlock, toBlock }), blockNumber),
153
+ ]);
154
+ const mine = queued.filter((log) => log.args.data.toLowerCase() === data).at(-1);
155
+ const change = mine ? await client.readContract({ address: executor, abi: executorAbi, functionName: "getChange", args: [mine.args.changeId], blockNumber }) : undefined;
156
+ const applications = await Promise.all(proposed
157
+ .filter((log) => log.args.data.toLowerCase() === data)
158
+ .map(async (log) => ({ id: log.args.id, state: (await client.readContract({ address: bonds, abi: bondsAbi, functionName: "get", args: [log.args.id], blockNumber })).state })));
159
+ return { applications, change };
160
+ },
161
+ /** Tasks this worker was hired for, by outcome, and what it was paid after fees. */
162
+ async record() {
163
+ const ids = [...new Set(await hiredIds())];
164
+ const tasks = await Promise.all(ids.map((task) => client.readContract({ ...board, functionName: "get", args: [task] })));
165
+ const paid = await scan((fromBlock, toBlock) => client.getContractEvents({ ...board, eventName: "BountyPaid", args: { account: account.address }, fromBlock, toBlock }));
166
+ const count = (statuses) => tasks.filter((t) => statuses.includes(t.status)).length;
167
+ return {
168
+ accepted: count([Status.Paid]),
169
+ refunded: tasks.filter((t) => t.status === Status.Refunded && t.model.toLowerCase() === id).length,
170
+ inFlight: count([Status.Hired, Status.Submitted, Status.Graded, Status.Disputed]),
171
+ earned: paid.reduce((sum, log) => sum + log.args.bounty - log.args.fee, 0n),
172
+ };
173
+ },
174
+ };
175
+ }
176
+ /** Reads a brief and checks it against the hash on chain. Briefs are base64 data URIs or https URLs. */
177
+ export async function fetchBrief(uri, expected, fetcher = fetch) {
178
+ const MAX = 256 * 1024;
179
+ let bytes;
180
+ if (uri.startsWith("data:")) {
181
+ const comma = uri.indexOf(",");
182
+ if (comma < 0 || !uri.slice(0, comma).endsWith(";base64"))
183
+ throw new Error("The brief's data URI is not base64");
184
+ bytes = Uint8Array.from(Buffer.from(uri.slice(comma + 1), "base64"));
185
+ }
186
+ else if (uri.startsWith("https://")) {
187
+ const response = await fetcher(uri, { redirect: "error", signal: AbortSignal.timeout(15_000) });
188
+ if (!response.ok)
189
+ throw new Error(`The brief answered ${response.status}`);
190
+ bytes = new Uint8Array(await response.arrayBuffer());
191
+ }
192
+ else {
193
+ throw new Error("The brief is neither a data URI nor an https URL");
194
+ }
195
+ if (bytes.byteLength > MAX)
196
+ throw new Error("The brief is too large");
197
+ if (keccak256(bytes).toLowerCase() !== expected.toLowerCase())
198
+ throw new Error("The brief does not match its hash on chain");
199
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
200
+ }
package/dist/index.js ADDED
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { BaseError, formatEther, formatUnits, HttpRequestError, parseUnits } from "viem";
4
+ import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
5
+ import { ApplicationState, createChain } from "./chain.js";
6
+ import { resolveNetwork } from "./network.js";
7
+ import { createWork, shellCommand } from "./work.js";
8
+ const { version } = createRequire(import.meta.url)("../package.json");
9
+ const USAGE = `brainchain-worker ${version}
10
+
11
+ Earn on BrainChain by doing tasks: stake, apply, and let Jev hire your agent.
12
+
13
+ Usage:
14
+ brainchain-worker new-key Print a fresh private key and its address.
15
+ brainchain-worker join --name <name> --about <text> [--price <tUSDG>]
16
+ Stake the minimum and apply to take tasks.
17
+ brainchain-worker run --exec "<command>" [--format prompt|json] [--once]
18
+ Do every task Jev hires you for: the command reads the
19
+ task on stdin and prints the deliverable on stdout.
20
+ brainchain-worker status Stake, application, record and earnings.
21
+ brainchain-worker price <tUSDG> Change your asking price per task.
22
+ brainchain-worker leave Start unstaking (3-day cooldown).
23
+ brainchain-worker withdraw Withdraw stake once the cooldown has passed.
24
+
25
+ Environment:
26
+ BRAINCHAIN_WORKER_KEY Private key of the worker's wallet, 64 hex characters with or without 0x.
27
+ BRAINCHAIN_NETWORK testnet (the default, and the only network open to outside workers today).
28
+ BRAINCHAIN_RPC_URL Overrides the network's RPC.
29
+
30
+ Docs: https://docs.usebrainchain.com/run-a-worker`;
31
+ class UsageError extends Error {
32
+ }
33
+ /** The flags each command takes; `true` marks a switch, which takes no value. */
34
+ const FLAGS = {
35
+ join: { name: false, about: false, price: false },
36
+ run: { exec: false, format: false, once: true },
37
+ status: {},
38
+ price: {},
39
+ leave: {},
40
+ withdraw: {},
41
+ };
42
+ const POSITIONAL = { price: 1 };
43
+ function parse(command, args) {
44
+ const known = FLAGS[command];
45
+ if (known === undefined)
46
+ throw new UsageError(`Unknown command "${command}". Run brainchain-worker help.`);
47
+ const positional = [];
48
+ const values = new Map();
49
+ const switches = new Set();
50
+ for (let i = 0; i < args.length; i++) {
51
+ const arg = args[i];
52
+ if (!arg.startsWith("--")) {
53
+ positional.push(arg);
54
+ continue;
55
+ }
56
+ const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
57
+ const isSwitch = known[name];
58
+ if (isSwitch === undefined)
59
+ throw new UsageError(`${command} has no --${name} flag. Run brainchain-worker help.`);
60
+ if (isSwitch) {
61
+ switches.add(name);
62
+ continue;
63
+ }
64
+ const value = inline ?? args[++i];
65
+ if (value === undefined || (inline === undefined && value.startsWith("--")))
66
+ throw new UsageError(`--${name} needs a value`);
67
+ values.set(name, value);
68
+ }
69
+ if (positional.length > (POSITIONAL[command] ?? 0))
70
+ throw new UsageError(`Unexpected "${positional.at(-1)}". Run brainchain-worker help.`);
71
+ return { positional, values, switches };
72
+ }
73
+ function account() {
74
+ const raw = process.env.BRAINCHAIN_WORKER_KEY?.trim();
75
+ if (!raw)
76
+ throw new UsageError("Set BRAINCHAIN_WORKER_KEY to the worker's private key. `npx brainchain-worker new-key` makes one.");
77
+ const hex = raw.startsWith("0x") ? raw.slice(2) : raw;
78
+ if (!/^[0-9a-fA-F]{64}$/.test(hex))
79
+ throw new UsageError("BRAINCHAIN_WORKER_KEY must be 64 hex characters, with or without 0x");
80
+ try {
81
+ return privateKeyToAccount(`0x${hex}`);
82
+ }
83
+ catch {
84
+ throw new UsageError("BRAINCHAIN_WORKER_KEY is not a valid private key. `npx brainchain-worker new-key` makes one.");
85
+ }
86
+ }
87
+ /** A price in the bounty token, which has 6 decimals. */
88
+ function price(value) {
89
+ if (!/^\d{1,6}(\.\d{1,6})?$/.test(value)) {
90
+ throw new UsageError(`The price must be a number from 0 to 999999 with at most 6 decimals, such as 0.20, not "${value}"`);
91
+ }
92
+ return parseUnits(value, 6);
93
+ }
94
+ const stake = (network, amount) => `${formatUnits(amount, 18)} ${network.stakeSymbol}`;
95
+ const usdg = (network, amount) => `${formatUnits(amount, 6)} ${network.bountySymbol}`;
96
+ const when = (seconds) => new Date(Number(seconds) * 1000).toISOString().replace(".000Z", "Z");
97
+ async function needGas(chain, network) {
98
+ if ((await chain.ethBalance()) > 0n)
99
+ return;
100
+ throw new UsageError(`${chain.account} has no ETH on ${network.name} for gas. Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com ` +
101
+ `and bridge some at ${network.explorer}; 0.001 ETH is plenty.`);
102
+ }
103
+ /** Why the worker can or cannot be hired right now, in one line. */
104
+ async function hireable(chain, network) {
105
+ const [allowed, w, min, { applications, change }] = await Promise.all([chain.allowed(), chain.stake(), chain.minStake(), chain.applications()]);
106
+ if (allowed)
107
+ return w.staked >= min ? "yes" : `no: your stake is below the minimum of ${stake(network, min)}. Run join to stake again.`;
108
+ // An allowed application waiting out its timelock outranks anything filed after it.
109
+ if (change && !change.executed && !change.vetoed)
110
+ return `no: Jev allowed this worker; it can be hired after ${when(change.eta)}`;
111
+ const last = applications.at(-1);
112
+ if (last === undefined)
113
+ return "no: run `npx brainchain-worker join` to apply";
114
+ if (last.state === ApplicationState.Open)
115
+ return `no: application ${last.id} is waiting for Jev`;
116
+ if (last.state === ApplicationState.Burned)
117
+ return `no: Jev did not allow application ${last.id}, and its bond was burned. Run join to apply again.`;
118
+ if (change?.vetoed)
119
+ return `no: the guardian vetoed application ${last.id}`;
120
+ return `no: application ${last.id} closed without a decision; its bond was returned. Run join to apply again.`;
121
+ }
122
+ async function join(chain, network, values) {
123
+ const name = values.get("name")?.trim();
124
+ const about = values.get("about")?.trim() ?? "";
125
+ if (!name || name.length > 60)
126
+ throw new UsageError("--name is required, up to 60 characters");
127
+ if (!about || about.length > 500) {
128
+ throw new UsageError("--about is required, up to 500 characters: say what work your agent does. Jev reads it to decide, and refuses an empty application.");
129
+ }
130
+ const asking = price(values.get("price") ?? "0.20");
131
+ await needGas(chain, network);
132
+ if (await chain.allowed()) {
133
+ console.log("This worker can already be hired. Start it with `npx brainchain-worker run`.");
134
+ return;
135
+ }
136
+ const { applications, change } = await chain.applications();
137
+ if (change && !change.executed && !change.vetoed) {
138
+ console.log(`Jev already allowed this worker. It can be hired after ${when(change.eta)}; keep \`npx brainchain-worker run\` going until then.`);
139
+ return;
140
+ }
141
+ const open = applications.find((a) => a.state === ApplicationState.Open);
142
+ const [w, min, bond, balance] = await Promise.all([chain.stake(), chain.minStake(), chain.bond(), chain.stakeBalance()]);
143
+ const toStake = w.staked >= min ? 0n : min - w.staked;
144
+ const needed = toStake + (open ? 0n : bond);
145
+ if (balance < needed) {
146
+ console.log(`Claiming ${network.stakeSymbol} from the testnet faucet...`);
147
+ await chain.claimStakeToken();
148
+ }
149
+ if (toStake > 0n) {
150
+ console.log(`Staking ${stake(network, toStake)} at ${usdg(network, asking)} per task...`);
151
+ await chain.approve(network.contracts.stakeToken, network.contracts.staking, toStake);
152
+ await chain.stakeTokens(toStake, asking);
153
+ }
154
+ else if (w.price !== asking) {
155
+ console.log(`Setting your price to ${usdg(network, asking)} per task...`);
156
+ await chain.setPrice(asking);
157
+ }
158
+ if (open) {
159
+ console.log(`Application ${open.id} is already waiting for Jev.`);
160
+ return;
161
+ }
162
+ console.log(`Applying with a ${stake(network, bond)} bond, returned if Jev allows the worker...`);
163
+ await chain.approve(network.contracts.stakeToken, network.contracts.bonds, bond);
164
+ const profile = Buffer.from(JSON.stringify({ name, about })).toString("base64");
165
+ await chain.apply(`data:application/json;base64,${profile}`);
166
+ 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" +
167
+ "Start `npx brainchain-worker run` now so it takes its first task as soon as it is hired, and follow the application\n" +
168
+ "with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
169
+ }
170
+ async function status(chain, network) {
171
+ const [gas, w, min, held, record, balance, line] = await Promise.all([
172
+ chain.ethBalance(),
173
+ chain.stake(),
174
+ chain.minStake(),
175
+ chain.inFlight(),
176
+ chain.record(),
177
+ chain.bountyBalance(),
178
+ hireable(chain, network),
179
+ ]);
180
+ const unstaking = w.unstaking > 0n ? `, ${stake(network, w.unstaking)} unstaking until ${when(w.unlocksAt)}` : "";
181
+ console.log(`Worker ${chain.account} on ${network.name}
182
+ Hireable ${line}
183
+ Stake ${stake(network, w.staked)} (minimum ${stake(network, min)})${unstaking}
184
+ Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
185
+ Record ${record.accepted} accepted, ${record.refunded} refunded, ${record.inFlight} in flight${held > 0 ? " (stake held until they settle)" : ""}
186
+ Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, balance)}
187
+ Gas ${formatEther(gas)} ETH`);
188
+ }
189
+ async function run(chain, network, values, once) {
190
+ const exec = values.get("exec");
191
+ if (!exec)
192
+ throw new UsageError(`--exec is required: the command that does the work, such as --exec 'claude -p --tools ""'`);
193
+ const format = values.get("format") ?? "prompt";
194
+ if (format !== "prompt" && format !== "json")
195
+ throw new UsageError("--format is prompt or json");
196
+ const log = (message) => console.log(`${new Date().toISOString().slice(11, 19)} ${message}`);
197
+ const work = createWork({ chain, command: shellCommand(exec), format, log });
198
+ log(`worker ${chain.account} on ${network.name}; hireable: ${await hireable(chain, network)}`);
199
+ if (once) {
200
+ const result = await work.round();
201
+ if (result.hired === 0)
202
+ log("no task is hired to this worker right now");
203
+ // A cron job sees a failed task as a failed run.
204
+ if (result.failed > 0)
205
+ process.exitCode = 1;
206
+ return;
207
+ }
208
+ let stopping = false;
209
+ process.once("SIGINT", () => {
210
+ stopping = true;
211
+ log("stopping after the current task");
212
+ });
213
+ log("waiting for tasks (Ctrl-C to stop)");
214
+ while (!stopping) {
215
+ await work.round().catch((error) => log(`round failed: ${describe(error)}`));
216
+ for (let i = 0; i < 15 && !stopping; i++)
217
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
218
+ }
219
+ }
220
+ async function main(argv) {
221
+ const [command, ...rest] = argv;
222
+ if (command === undefined || command === "help" || argv.includes("--help") || argv.includes("-h")) {
223
+ console.log(USAGE);
224
+ return;
225
+ }
226
+ if (command === "--version" || command === "-v") {
227
+ console.log(version);
228
+ return;
229
+ }
230
+ if (command === "new-key") {
231
+ const fresh = generatePrivateKey();
232
+ console.log(`BRAINCHAIN_WORKER_KEY=${fresh}\naddress ${privateKeyToAccount(fresh).address}\nKeep the key secret; it controls the stake and the earnings.`);
233
+ return;
234
+ }
235
+ const { positional, values, switches } = parse(command, rest);
236
+ const network = resolveNetwork(process.env);
237
+ const chain = createChain(network, account());
238
+ switch (command) {
239
+ case "join":
240
+ return join(chain, network, values);
241
+ case "status":
242
+ return status(chain, network);
243
+ case "run":
244
+ return run(chain, network, values, switches.has("once"));
245
+ case "price": {
246
+ if (positional[0] === undefined)
247
+ throw new UsageError("Usage: brainchain-worker price <tUSDG>, such as price 0.25");
248
+ const asking = price(positional[0]);
249
+ if (!(await chain.stake()).registered)
250
+ throw new UsageError("This worker has not staked yet. Run join, which sets the price too.");
251
+ await needGas(chain, network);
252
+ await chain.setPrice(asking);
253
+ console.log(`Price set to ${usdg(network, asking)} per task.`);
254
+ return;
255
+ }
256
+ case "leave": {
257
+ const w = await chain.stake();
258
+ if (w.staked === 0n) {
259
+ throw new UsageError(w.unstaking > 0n ? `Nothing left to unstake: ${stake(network, w.unstaking)} is already unstaking until ${when(w.unlocksAt)}.` : "Nothing is staked.");
260
+ }
261
+ await needGas(chain, network);
262
+ await chain.requestUnstake(w.staked);
263
+ const after = await chain.stake();
264
+ 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.`);
265
+ return;
266
+ }
267
+ case "withdraw": {
268
+ const [w, held, now] = await Promise.all([chain.stake(), chain.inFlight(), chain.now()]);
269
+ if (w.unstaking === 0n)
270
+ throw new UsageError("Nothing is unstaking. Run leave first.");
271
+ if (now < w.unlocksAt)
272
+ throw new UsageError(`${stake(network, w.unstaking)} unlocks at ${when(w.unlocksAt)}. Withdraw after that.`);
273
+ if (held > 0)
274
+ throw new UsageError(`The stake is held until the worker's ${held} task${held === 1 ? "" : "s"} in flight settle.`);
275
+ await needGas(chain, network);
276
+ await chain.withdraw();
277
+ console.log(`Withdrew ${stake(network, w.unstaking)}.`);
278
+ return;
279
+ }
280
+ }
281
+ }
282
+ /** One readable line for an error, with the cause viem keeps apart from its summary. */
283
+ function describe(error) {
284
+ if (error instanceof HttpRequestError)
285
+ return `Could not reach ${error.url}${error.details ? ` (${error.details})` : ""}`;
286
+ if (error instanceof BaseError)
287
+ return error.shortMessage;
288
+ return error instanceof Error ? error.message : String(error);
289
+ }
290
+ main(process.argv.slice(2)).catch((error) => {
291
+ console.error(error instanceof UsageError ? error.message : `brainchain-worker: ${describe(error)}`);
292
+ process.exit(1);
293
+ });
@@ -0,0 +1,40 @@
1
+ import { defineChain } from "viem";
2
+ /** BrainChain testnet, release 3.1.0. Mainnet has no open staking yet. */
3
+ export const TESTNET = {
4
+ name: "BrainChain testnet",
5
+ chainId: 274630,
6
+ rpc: "https://rpc.testnet.usebrainchain.com",
7
+ service: "https://brain.testnet.usebrainchain.com",
8
+ fromBlock: 898n,
9
+ explorer: "https://usebrainchain.com/testnet",
10
+ stakeSymbol: "tBRAIN",
11
+ bountySymbol: "tUSDG",
12
+ contracts: {
13
+ taskBoard: "0xC74564F100e70F95346A6e399ebf1Ce14309C8B6",
14
+ staking: "0xbdC08799D865DA79FE44a469de1FeaE84bb45059",
15
+ bonds: "0x574060D1A7CA8f3786d2D14fe1A782cc4638CB1e",
16
+ executor: "0x420484B139001ED5B1DA330e689Cc2f4814001Fb",
17
+ stakeToken: "0xc717546497343911750768335b649b86d0a6eCcf",
18
+ bountyToken: "0xC4ec72794bF3Ce9ba1DE4B38085464c2eb0cDCf2",
19
+ },
20
+ };
21
+ export function resolveNetwork(env) {
22
+ 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}"`);
27
+ return {
28
+ ...TESTNET,
29
+ ...(env.BRAINCHAIN_RPC_URL ? { rpc: env.BRAINCHAIN_RPC_URL } : {}),
30
+ ...(env.BRAINCHAIN_SERVICE_URL ? { service: env.BRAINCHAIN_SERVICE_URL.replace(/\/$/, "") } : {}),
31
+ };
32
+ }
33
+ export function chainOf(network) {
34
+ return defineChain({
35
+ id: network.chainId,
36
+ name: network.name,
37
+ nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
38
+ rpcUrls: { default: { http: [network.rpc] } },
39
+ });
40
+ }