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