brainchain-worker 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -90,6 +90,8 @@ npx brainchain-worker watch --veto # also vetoes a change that crosses a flo
90
90
 
91
91
  `--veto` needs the worker's key to be the gate's watcher, which the owners' Safe sets. A veto shuts that function on that contract until the owners reopen it, and the reason is published with it.
92
92
 
93
+ With `BRAINCHAIN_WORKER_KEY` set, each round's re-verified decisions are reported to the service, signed by the worker, with a proof it fetched every record. A staked worker whose watchtower has re-verified 10 decisions and reported within the last 48 hours carries that on its line in Jev's hire questions: `runs a watchtower since <date> (<n> decisions re-verified)`. The service lists every watchtower at `/watchtowers`.
94
+
93
95
  ## Propose a change
94
96
 
95
97
  Any wallet can propose a change to BrainChain's settings: the task board's thresholds, windows, minimum bounty and fee, the model registry, or the executor's own limits and treasury. `propose` bonds the stake token (250,000 BRAIN on mainnet, 100 tBRAIN on testnet) and puts the exact contract call to Jev with your case for it:
@@ -144,14 +146,14 @@ Briefs are written by strangers, and some will try to get your agent to do somet
144
146
  | Command | |
145
147
  | --- | --- |
146
148
  | `new-key` | Print a fresh private key and its address. |
147
- | `join --name <name> --about <text> [--price <USDG>]` | Claim tBRAIN from the faucet if needed (on mainnet, the BRAIN comes from the wallet), stake the minimum and apply. Jev reads the name and description to decide, and refuses an empty one. The name is at most 60 characters and the description at most 500. A new worker's price defaults to 0.20; a worker that has staked keeps its price unless `--price` changes it. An allowed worker whose stake fell below the minimum, after `leave` or a slash, stakes back up to it. Stake that is unstaking does not count, so the tBRAIN comes from the wallet or the faucet until `withdraw` returns it. |
149
+ | `join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]` | Claim tBRAIN from the faucet if needed (on mainnet, the BRAIN comes from the wallet), stake the minimum and apply. Jev reads the name and description to decide, and refuses an empty one. The name is at most 60 characters and the description at most 500. `--agent-id` (mainnet) names the worker's ERC-8004 agent on Robinhood Chain. The wallet must own that agent or be its registered wallet; `join` checks this before it sends anything, and the id goes in the application. Every task the worker is hired for while its wallet holds that id is then written to the identity's reputation, tag `brainchain`, from `0xafB0C4Ef895D4895040cdC6435B96D3EE8350c6E`. A new worker's price defaults to 0.20; a worker that has staked keeps its price unless `--price` changes it. An allowed worker whose stake fell below the minimum, after `leave` or a slash, stakes back up to it. Stake that is unstaking does not count, so the tBRAIN comes from the wallet or the faucet until `withdraw` returns it. |
148
150
  | `run --exec "<command>" [--format prompt\|json] [--timeout <minutes>] [--once] [--no-mint]` | Do every task the worker is hired for, earliest deadline first, and mint the Proof of each one that is paid. `--timeout` sets how many minutes the command may spend on one task: 20 by default, at most 1440, fractions allowed. `--once` does one pass and exits, with code 1 if a task failed or was left too close to its deadline, for a cron job. `--no-mint` mints no Proofs. |
149
151
  | `status` | Whether the worker can be hired, its stake, its tBRAIN or BRAIN balance, price, record and earnings. Below the minimum stake, it also says where `join` will find the tBRAIN it needs, or on mainnet where to buy BRAIN. |
150
152
  | `price <USDG>` | Change the asking price, in tUSDG on testnet and USDG on mainnet. |
151
153
  | `mint [--count <n>] [--dry]` | Mint the Proof of each paid task waiting for one, up to 10 at a time or `--count`, at 0.001 WETH each on Robinhood Chain plus gas. `--dry` lists the tasks and what the wallet lacks without sending anything. |
152
154
  | `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. |
153
155
  | `bridge --eth <ETH> \| --stake <amount> \| --bounty <amount> [--dry]` | Move ETH, the stake token or the bounty token from Robinhood Chain to the worker's address on BrainChain. |
154
- | `watch [--veto] [--once]` | Judge every queued change against the constitution and re-verify every new decision, reporting what disagrees. `--veto` vetoes a change that crosses a floor through the guardian gate, when the worker's key is the gate's watcher. `--once` reads one round and exits 1 on a finding. |
156
+ | `watch [--veto] [--once]` | Judge every queued change against the constitution and re-verify every new decision, reporting what disagrees. With the worker's key set, each round is reported to the service toward the worker's watchtower mark. `--veto` vetoes a change that crosses a floor through the guardian gate, when the worker's key is the gate's watcher. `--once` reads one round and exits 1 on a finding. |
155
157
  | `propose --target <contract> --call <function> --args <JSON> --title <text> --rationale <text> [--current <text>] [--proposed <text>] [--data <hex>] [--dry]` | Bond the stake token and propose a change for Jev to decide. See [Propose a change](#propose-a-change). |
156
158
  | `proposal <id>` | Where a proposal stands: waiting for Jev and until when, adopted and when its change runs, or kept with the bond burned. Needs no key. |
157
159
  | `leave` | Start unstaking. The stake unlocks after 3 days, once no task of the worker's is in flight. |
package/dist/chain.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { isIP } from "node:net";
2
2
  import { BaseError, ContractFunctionRevertedError, createPublicClient, createWalletClient, encodeFunctionData, http, HttpRequestError, keccak256, parseAbi, RpcRequestError, TimeoutError, toBytes, WaitForTransactionReceiptTimeoutError, } from "viem";
3
3
  import { erc20Errors, taskBoardAbi, workerStakingAbi } from "./abi.js";
4
- import { chainOf } from "./network.js";
4
+ import { chainOf, parentChainOf } from "./network.js";
5
5
  import { memoryStore } from "./state.js";
6
6
  export const erc20Abi = parseAbi([
7
7
  "function balanceOf(address) view returns (uint256)",
@@ -188,6 +188,10 @@ export async function unhealthy(service, fetcher = fetch) {
188
188
  }
189
189
  /** BrainChain's RPC, with answers that are not JSON-RPC refused before viem reads them. */
190
190
  export const brainchainTransport = (network) => http(network.rpc, { timeout: 20_000, retryCount: 3, onFetchResponse: (response) => checkReply(response, network.rpc) });
191
+ const identityAbi = parseAbi([
192
+ "function ownerOf(uint256 tokenId) view returns (address)",
193
+ "function getAgentWallet(uint256 agentId) view returns (address)",
194
+ ]);
191
195
  export function createChain(network, account, store = memoryStore()) {
192
196
  const chain = chainOf(network);
193
197
  const transport = brainchainTransport(network);
@@ -327,6 +331,23 @@ export function createChain(network, account, store = memoryStore()) {
327
331
  setPrice: (price) => send({ address: staking, abi: workerStakingAbi, functionName: "setPrice", args: [price] }),
328
332
  requestUnstake: (amount) => send({ address: staking, abi: workerStakingAbi, functionName: "requestUnstake", args: [amount] }),
329
333
  withdraw: () => send({ address: staking, abi: workerStakingAbi, functionName: "withdraw" }),
334
+ /**
335
+ * Whether this wallet owns the ERC-8004 agent `agentId` on the parent chain or is
336
+ * its registered wallet; undefined when the registry could not be read.
337
+ */
338
+ async holdsAgent(agentId) {
339
+ if (!network.identity || !network.bridge)
340
+ return undefined;
341
+ const parent = createPublicClient({ chain: parentChainOf(network.bridge), transport: http(network.bridge.rpc, { timeout: 20_000 }) });
342
+ const mine = (holder) => holder.toLowerCase() === account.address.toLowerCase();
343
+ const read = (functionName) => parent.readContract({ address: network.identity, abi: identityAbi, functionName, args: [agentId] });
344
+ try {
345
+ return mine(await read("ownerOf")) || mine(await read("getAgentWallet"));
346
+ }
347
+ catch (error) {
348
+ return error instanceof BaseError && error.walk((e) => e instanceof ContractFunctionRevertedError) ? false : undefined;
349
+ }
350
+ },
330
351
  apply: (profileURI) => send({ address: bonds, abi: bondsAbi, functionName: "propose", args: [taskBoard, applicationData(account.address), profileURI] }),
331
352
  /**
332
353
  * This worker's applications, newest last, with their state and the
package/dist/cli.js CHANGED
@@ -11,8 +11,18 @@ import { chainOf, resolveNetwork } from "./network.js";
11
11
  import { buildCall, caseURI, createProposals, proposal, propose } from "./propose.js";
12
12
  import { hireable, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
13
13
  import { openStore } from "./state.js";
14
- import { createWatch, createWatchChain } from "./watch.js";
14
+ import { createReporter, createWatch, createWatchChain } from "./watch.js";
15
15
  import { createWork, shellCommand, stopLeftover, stopper } from "./work.js";
16
+ /** A published record's bytes as the service serves them, capped at a megabyte. */
17
+ async function fetchRecord(uri) {
18
+ const response = await fetch(uri, { signal: AbortSignal.timeout(20_000) });
19
+ if (!response.ok)
20
+ throw new Error(`${response.status} from ${uri}`);
21
+ const bytes = new Uint8Array(await response.arrayBuffer());
22
+ if (bytes.byteLength > 1_000_000)
23
+ throw new Error(`the record at ${uri} is ${bytes.byteLength} bytes`);
24
+ return bytes;
25
+ }
16
26
  const { version } = createRequire(import.meta.url)("../package.json");
17
27
  const USAGE = `brainchain-worker ${version}
18
28
 
@@ -20,9 +30,11 @@ Earn on BrainChain by doing tasks: stake, apply, and let Jev hire your agent.
20
30
 
21
31
  Usage:
22
32
  brainchain-worker new-key Print a fresh private key and its address.
23
- brainchain-worker join --name <name> --about <text> [--price <USDG>]
33
+ brainchain-worker join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]
24
34
  Stake the minimum and apply to take tasks. The name is at
25
- most 60 characters, the description at most 500.
35
+ most 60 characters, the description at most 500. --agent-id
36
+ (mainnet) names the worker's ERC-8004 agent on Robinhood Chain,
37
+ so its BrainChain record is written to that identity.
26
38
  brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--no-mint]
27
39
  Do every task Jev hires the worker for: the command reads the
28
40
  task on stdin and prints the deliverable on stdout, within
@@ -47,10 +59,11 @@ Usage:
47
59
  brainchain-worker proposal <id> Where a proposal stands: waiting, adopted and when it runs, or kept.
48
60
  brainchain-worker watch [--veto] [--once]
49
61
  Watch the chain: judge every queued change against the constitution
50
- and re-verify every new decision, reporting what disagrees. --veto
51
- vetoes a change that crosses a floor through the guardian gate, when
52
- the worker's key is the gate's watcher. --once reads one round and
53
- exits 1 on a finding.
62
+ and re-verify every new decision, reporting what disagrees. With the
63
+ worker's key set, each round is reported to the service toward the
64
+ worker's watchtower mark. --veto vetoes a change that crosses a floor
65
+ through the guardian gate, when the worker's key is the gate's
66
+ watcher. --once reads one round and exits 1 on a finding.
54
67
  brainchain-worker leave Start unstaking (3-day cooldown).
55
68
  brainchain-worker withdraw Withdraw stake once the cooldown has passed.
56
69
  brainchain-worker --version Print the version.
@@ -72,7 +85,7 @@ export class UsageError extends Error {
72
85
  }
73
86
  /** The flags each command takes; `true` marks a switch, which takes no value. */
74
87
  const FLAGS = {
75
- join: { name: false, about: false, price: false },
88
+ join: { name: false, about: false, price: false, "agent-id": false },
76
89
  run: { exec: false, format: false, timeout: false, once: true, "no-mint": true },
77
90
  status: {},
78
91
  price: {},
@@ -161,12 +174,30 @@ export async function join(chain, network, values) {
161
174
  }
162
175
  if (about.length > 500)
163
176
  throw new UsageError("--about is at most 500 characters");
177
+ const agentId = values.get("agent-id");
178
+ if (agentId !== undefined && !/^(0|[1-9]\d{0,14})$/.test(agentId))
179
+ throw new UsageError("--agent-id is the worker's ERC-8004 agent id, a whole number");
180
+ if (agentId !== undefined && !network.identity) {
181
+ throw new UsageError("--agent-id works on mainnet only, where BrainChain writes each worker's record to its ERC-8004 identity");
182
+ }
164
183
  const requested = values.has("price") ? price(values.get("price")) : undefined;
165
184
  const gas = await chain.ethBalance();
166
185
  // The faucet covers the stake where there is one, so gas is all a new worker can lack there.
167
186
  if (gas === 0n && !network.buyStake)
168
187
  throw noGas(chain, network);
169
188
  const { allowed, w, change, pending, open, applying, toStake } = await snapshot(chain);
189
+ // The agent id travels in the application, and Jev keeps the profile it decided on.
190
+ if (agentId !== undefined) {
191
+ if (allowed || (change && pending) || open) {
192
+ throw new UsageError("--agent-id goes in the application, and this worker is already allowed or has one waiting. Run join without it.");
193
+ }
194
+ const holds = await chain.holdsAgent(BigInt(agentId));
195
+ if (holds === undefined)
196
+ throw new Error("The ERC-8004 identity registry on Robinhood Chain could not be read, so --agent-id was not checked. Try again.");
197
+ if (!holds) {
198
+ throw new UsageError(`${chain.account} neither owns ERC-8004 agent ${agentId} nor is its registered wallet, so BrainChain would write no record to it`);
199
+ }
200
+ }
170
201
  // A worker that has staked keeps its price unless --price changes it.
171
202
  const asking = requested ?? (w.registered ? w.price : price(DEFAULT_PRICE));
172
203
  const [bond, balance, timelock] = await Promise.all([chain.bond(), chain.stakeBalance(), chain.timelock()]);
@@ -217,7 +248,7 @@ export async function join(chain, network, values) {
217
248
  }
218
249
  console.log(`Applying with a ${stake(network, bond)} bond, returned if Jev allows the worker...`);
219
250
  await chain.approve(network.contracts.stakeToken, network.contracts.bonds, bond);
220
- const profile = Buffer.from(JSON.stringify({ name, about })).toString("base64");
251
+ const profile = Buffer.from(JSON.stringify({ name, about, ...(agentId !== undefined ? { agentId: Number(agentId) } : {}) })).toString("base64");
221
252
  await chain.apply(`data:application/json;base64,${profile}`);
222
253
  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` +
223
254
  `Start ${RUN} now so it takes its first task as soon as it is hired, and follow\n` +
@@ -372,8 +403,9 @@ export async function main(argv) {
372
403
  if (command === "watch") {
373
404
  const veto = switches.has("veto");
374
405
  const once = switches.has("once");
375
- // Judging changes needs no key; vetoing does. A reader stands in for the signer otherwise.
376
- const account = veto || process.env.BRAINCHAIN_WORKER_KEY?.trim() ? signer().account : privateKeyToAccount(generatePrivateKey());
406
+ // Judging changes needs no key; vetoing and reporting do. A reader stands in for the signer otherwise.
407
+ const keyed = veto || Boolean(process.env.BRAINCHAIN_WORKER_KEY?.trim());
408
+ const account = keyed ? signer().account : privateKeyToAccount(generatePrivateKey());
377
409
  const client = createPublicClient({ chain: chainOf(network), transport: brainchainTransport(network) });
378
410
  const { taskBoard, executor, log } = network.contracts;
379
411
  const verify = async (id) => {
@@ -383,12 +415,22 @@ export async function main(argv) {
383
415
  set: { key: "active", release: "", fromBlock: Number(network.fromBlock), board: taskBoard, log, executor },
384
416
  briefs: new Map(),
385
417
  }, id);
386
- return { findings: report.checks.filter((c) => c.status === "fail" || c.status === "error").map((c) => `${c.name}: ${c.message}`) };
418
+ const findings = report.checks.filter((c) => c.status === "fail" || c.status === "error").map((c) => `${c.name}: ${c.message}`);
419
+ // A verified decision is reported with the record it was checked against, fetched again as the service serves it.
420
+ const record = findings.length === 0 && keyed && report.payloadURI ? await fetchRecord(report.payloadURI).catch(() => undefined) : undefined;
421
+ return { findings, ...(record ? { record } : {}) };
387
422
  };
388
- const watch = createWatch({ network, chain: createWatchChain(network, account), verify, veto, log: console.log });
423
+ const watch = createWatch({
424
+ network,
425
+ chain: createWatchChain(network, account),
426
+ verify,
427
+ ...(keyed ? { report: createReporter(network, account) } : {}),
428
+ veto,
429
+ log: console.log,
430
+ });
389
431
  if (veto && !network.contracts.gate)
390
432
  throw new UsageError(`${network.name} has no guardian gate yet, so there is nothing to veto through`);
391
- console.log(`watching ${network.name} as ${account.address}${veto ? ", vetoing through the guardian gate" : ""}`);
433
+ console.log(`watching ${network.name} as ${account.address}${veto ? ", vetoing through the guardian gate" : ""}${keyed ? ", reporting rounds to the service" : ""}`);
392
434
  if (once) {
393
435
  const { findings } = await watch.round();
394
436
  if (findings > 0)
package/dist/network.js CHANGED
@@ -59,9 +59,8 @@ export const MAINNET = {
59
59
  stakeSymbol: "BRAIN",
60
60
  bountySymbol: "USDG",
61
61
  gas: "Bridge some from Robinhood Chain at https://usebrainchain.com/network",
62
- buyStake: "Buy BRAIN on ripples.run (https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and bridge it to BrainChain through the " +
63
- "Robinhood Chain gateway router 0x03f06a370A59AfB23de029F4EF5EA2F9EB034F96 (docs: https://docs.usebrainchain.com/bridging); " +
64
- `the stake token on BrainChain is ${BRAIN}.`,
62
+ buyStake: "Buy BRAIN on Robinhood Chain at ripples.run (https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and move it to BrainChain with " +
63
+ `brainchain-worker bridge --stake <amount> (docs: https://docs.usebrainchain.com/bridging); the stake token on BrainChain is ${BRAIN}.`,
65
64
  proofs: {
66
65
  chainId: 4663,
67
66
  name: "Robinhood Chain",
@@ -72,6 +71,7 @@ export const MAINNET = {
72
71
  gas: "Send it a little ETH there for gas",
73
72
  income: "0xeb1b4168D4b87D3d9F0D64E13B7071251BBB63b7",
74
73
  },
74
+ identity: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432",
75
75
  bridge: {
76
76
  chainId: 4663,
77
77
  name: "Robinhood Chain",
package/dist/watch.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createPublicClient, createWalletClient, parseAbi } from "viem";
1
+ import { concat, createPublicClient, createWalletClient, keccak256, parseAbi, toBytes } from "viem";
2
2
  import { brainchainTransport, executorAbi, reason } from "./chain.js";
3
3
  import { chainOf } from "./network.js";
4
4
  /**
@@ -93,6 +93,43 @@ export function createWatchChain(network, account) {
93
93
  : {}),
94
94
  };
95
95
  }
96
+ /** The service's prefix on a report; the worker signs the prefix and the report's JSON text. */
97
+ export const REPORT_PREFIX = "BrainChain watchtower report\n";
98
+ /** A report lists at most this many decisions checked. */
99
+ export const MAX_CHECKED = 200;
100
+ /** The proof a record was fetched: the hash of its bytes followed by the worker's address, which nothing on chain gives away. */
101
+ export const fetchProof = (bytes, worker) => keccak256(concat([bytes, toBytes(worker)]));
102
+ /**
103
+ * Sends a round's re-verified decisions to the service, signed by the worker,
104
+ * so a staked worker's watchtower counts toward its mark in hire questions.
105
+ * Resolves to the line to log.
106
+ */
107
+ export function createReporter(network, account, fetcher = fetch) {
108
+ return async (checked, findings) => {
109
+ const report = {
110
+ worker: account.address,
111
+ chainId: network.chainId,
112
+ log: network.contracts.log,
113
+ at: Math.floor(Date.now() / 1000),
114
+ checked: checked.slice(0, MAX_CHECKED).map((c) => ({ id: Number(c.id), proof: c.proof })),
115
+ findings: findings.slice(0, 50).map((f) => ({ id: Number(f.id), checks: f.checks.slice(0, 20).map((c) => c.slice(0, 400)) })),
116
+ };
117
+ const text = JSON.stringify(report);
118
+ const signature = await account.signMessage({ message: `${REPORT_PREFIX}${text}` });
119
+ const response = await fetcher(`${network.service}/watchtowers/reports`, {
120
+ method: "POST",
121
+ headers: { "content-type": "application/json" },
122
+ body: JSON.stringify({ report: text, signature }),
123
+ signal: AbortSignal.timeout(20_000),
124
+ });
125
+ const body = (await response.json().catch(() => ({})));
126
+ if (!response.ok)
127
+ throw new Error(body.error ?? `${response.status} from ${network.service}`);
128
+ return body.mark
129
+ ? `reported to ${network.name}: ${body.checked} decisions re-verified in all, watchtower mark since ${body.mark.since}`
130
+ : `reported to ${network.name}: ${body.checked} of the ${body.markAfter} re-verified decisions that earn the watchtower mark`;
131
+ };
132
+ }
96
133
  /**
97
134
  * Watches the chain for the worker: every queued change is judged against the
98
135
  * constitution and, with `veto` on, one that crosses a floor is vetoed through
@@ -157,13 +194,19 @@ export function createWatch(options) {
157
194
  log(`${count} decisions recorded so far; new ones are checked from here`);
158
195
  return;
159
196
  }
197
+ const checked = [];
198
+ const found = [];
160
199
  for (let id = seenDecision + 1n; id <= count; id++) {
161
200
  try {
162
- const { findings: wrong } = await verify(id);
163
- if (wrong.length === 0)
201
+ const { findings: wrong, record } = await verify(id);
202
+ if (wrong.length === 0) {
164
203
  log(`decision ${id} verified`);
204
+ if (record)
205
+ checked.push({ id, proof: fetchProof(record, chain.account) });
206
+ }
165
207
  else {
166
208
  findings += 1;
209
+ found.push({ id, checks: wrong });
167
210
  log(`decision ${id} does not verify: ${wrong.join("; ")}`);
168
211
  }
169
212
  }
@@ -172,6 +215,14 @@ export function createWatch(options) {
172
215
  }
173
216
  }
174
217
  seenDecision = count;
218
+ if (!options.report || (checked.length === 0 && found.length === 0))
219
+ return;
220
+ try {
221
+ log(await options.report(checked, found));
222
+ }
223
+ catch (error) {
224
+ log(`report not taken: ${reason(error)}`);
225
+ }
175
226
  }
176
227
  return {
177
228
  async round() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-worker",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Earn on BrainChain and propose changes to it: stake, apply, do the tasks Jev hires your agent for, and bond BRAIN to put a change to Jev.",
5
5
  "license": "MIT",
6
6
  "author": "BrainChain <hello@usebrainchain.com>",