brainchain-worker 0.7.0 → 0.8.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:
@@ -151,7 +153,7 @@ Briefs are written by strangers, and some will try to get your agent to do somet
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/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
 
@@ -47,10 +57,11 @@ Usage:
47
57
  brainchain-worker proposal <id> Where a proposal stands: waiting, adopted and when it runs, or kept.
48
58
  brainchain-worker watch [--veto] [--once]
49
59
  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.
60
+ and re-verify every new decision, reporting what disagrees. With the
61
+ worker's key set, each round is reported to the service toward the
62
+ worker's watchtower mark. --veto vetoes a change that crosses a floor
63
+ through the guardian gate, when the worker's key is the gate's
64
+ watcher. --once reads one round and exits 1 on a finding.
54
65
  brainchain-worker leave Start unstaking (3-day cooldown).
55
66
  brainchain-worker withdraw Withdraw stake once the cooldown has passed.
56
67
  brainchain-worker --version Print the version.
@@ -372,8 +383,9 @@ export async function main(argv) {
372
383
  if (command === "watch") {
373
384
  const veto = switches.has("veto");
374
385
  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());
386
+ // Judging changes needs no key; vetoing and reporting do. A reader stands in for the signer otherwise.
387
+ const keyed = veto || Boolean(process.env.BRAINCHAIN_WORKER_KEY?.trim());
388
+ const account = keyed ? signer().account : privateKeyToAccount(generatePrivateKey());
377
389
  const client = createPublicClient({ chain: chainOf(network), transport: brainchainTransport(network) });
378
390
  const { taskBoard, executor, log } = network.contracts;
379
391
  const verify = async (id) => {
@@ -383,12 +395,22 @@ export async function main(argv) {
383
395
  set: { key: "active", release: "", fromBlock: Number(network.fromBlock), board: taskBoard, log, executor },
384
396
  briefs: new Map(),
385
397
  }, id);
386
- return { findings: report.checks.filter((c) => c.status === "fail" || c.status === "error").map((c) => `${c.name}: ${c.message}`) };
398
+ const findings = report.checks.filter((c) => c.status === "fail" || c.status === "error").map((c) => `${c.name}: ${c.message}`);
399
+ // A verified decision is reported with the record it was checked against, fetched again as the service serves it.
400
+ const record = findings.length === 0 && keyed && report.payloadURI ? await fetchRecord(report.payloadURI).catch(() => undefined) : undefined;
401
+ return { findings, ...(record ? { record } : {}) };
387
402
  };
388
- const watch = createWatch({ network, chain: createWatchChain(network, account), verify, veto, log: console.log });
403
+ const watch = createWatch({
404
+ network,
405
+ chain: createWatchChain(network, account),
406
+ verify,
407
+ ...(keyed ? { report: createReporter(network, account) } : {}),
408
+ veto,
409
+ log: console.log,
410
+ });
389
411
  if (veto && !network.contracts.gate)
390
412
  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" : ""}`);
413
+ console.log(`watching ${network.name} as ${account.address}${veto ? ", vetoing through the guardian gate" : ""}${keyed ? ", reporting rounds to the service" : ""}`);
392
414
  if (once) {
393
415
  const { findings } = await watch.round();
394
416
  if (findings > 0)
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.8.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>",