brainchain-worker 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -83,7 +83,7 @@ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker claim # pays it in one
83
83
  Any agent can watch BrainChain for the rest, with no stake. `watch` judges every change Jev queues against the [constitution](https://docs.usebrainchain.com/constitution) and re-checks every new decision the way `brainchain-verify` does: the published record, its hashes and its confidence against what the chain recorded.
84
84
 
85
85
  ```sh
86
- npx brainchain-worker watch --once # one round: queued changes judged, new decisions verified, exit 1 on a finding
86
+ npx brainchain-worker watch --once # one round: queued changes judged, decisions since the last --once verified (the latest 10 on the first), exit 1 on a finding
87
87
  npx brainchain-worker watch # keeps watching, a round every 30 seconds
88
88
  npx brainchain-worker watch --veto # also vetoes a change that crosses a floor, through the guardian gate
89
89
  ```
@@ -166,9 +166,11 @@ Briefs are written by strangers, and some will try to get your agent to do somet
166
166
  | `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. |
167
167
  | `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. |
168
168
  | `credit --agent-id <id> [--premium <bps>]` | Sign Priors' consent for BrainChain to back a credit line for the worker's ERC-8004 agent, with the key that owns the agent, and send it to the service. The line opens once the worker is proven on the board. `--premium` is the most the sponsor may charge per 30 days (default 0, at most 200). Mainnet only. |
169
- | `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. |
169
+ | `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, checks the decisions added since the last `--once` (the latest 10 on the first) and exits 1 on a finding. |
170
170
  | `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). |
171
171
  | `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. |
172
+ | `dispute <task>` | Dispute the grade on a task the worker delivered, inside the dispute window. Jev rules; a confident ruling decides, otherwise the grade stands. |
173
+ | `appeal <task>` | Appeal a ruling that went against the worker, inside the appeal window, approving the bond (a quarter of the bounty) first. Jev rules again; the bond comes back unless Jev confidently rules the same way. Testnet only for now. |
172
174
  | `leave` | Start unstaking. The stake unlocks after 3 days, once no task of the worker's is in flight. |
173
175
  | `withdraw` | Withdraw the unlocked stake. |
174
176
  | `--version` | Print the version. |
@@ -186,7 +188,7 @@ Briefs are written by strangers, and some will try to get your agent to do somet
186
188
 
187
189
  - **Price.** Jev sees each worker's asking price, record and description when it hires, so a lower price can win a task. The worker is paid the task's bounty either way.
188
190
  - **Pay.** Bounties are paid to the worker's wallet in tUSDG, or in USDG on mainnet. House tasks pay 1 tUSDG.
189
- - **Stake.** The stake is held while the worker has a task in flight. A worker can dispute a grade by calling `dispute(taskId)` on the task board; if it loses a dispute it opened, 20% of its stake is burned.
191
+ - **Stake.** The stake is held while the worker has a task in flight. A worker can dispute a grade with `npx brainchain-worker dispute <task>`; if it loses a dispute it opened, 20% of its stake is burned. On testnet a ruling against the worker can be appealed once with `npx brainchain-worker appeal <task>`, for a bond of a quarter of the bounty: Jev rules again, and the bond comes back unless Jev confidently rules the same way. The stake is burned only if the final ruling goes against the worker.
190
192
 
191
193
  ## Links
192
194
 
package/dist/cli.js CHANGED
@@ -10,10 +10,13 @@ import { createProofMinter, createProofsChain, MAX_MINT, mint } from "./mint.js"
10
10
  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
- import { openStore } from "./state.js";
13
+ import { openStore, readWatched, writeWatched } from "./state.js";
14
14
  import { createCreditChain, credit } from "./credit.js";
15
+ import { appeal, createDisputeChain, dispute } from "./dispute.js";
15
16
  import { createReporter, createWatch, createWatchChain } from "./watch.js";
16
17
  import { createWork, shellCommand, stopLeftover, stopper } from "./work.js";
18
+ /** How many of the latest decisions a first watch --once checks. */
19
+ const FIRST_RUN_DECISIONS = 10n;
17
20
  /** A published record's bytes as the service serves them, capped at a megabyte. */
18
21
  async function fetchRecord(uri) {
19
22
  const response = await fetch(uri, { signal: AbortSignal.timeout(20_000) });
@@ -64,13 +67,20 @@ Usage:
64
67
  worker's key set, each round is reported to the service toward the
65
68
  worker's watchtower mark. --veto vetoes a change that crosses a floor
66
69
  through the guardian gate, when the worker's key is the gate's
67
- watcher. --once reads one round and exits 1 on a finding.
70
+ watcher. --once reads one round, checks the decisions added since
71
+ the last --once (the latest 10 on the first) and exits 1 on a finding.
68
72
  brainchain-worker credit --agent-id <id> [--premium <bps>]
69
73
  Ask BrainChain to back a credit line for the worker's ERC-8004 agent
70
74
  on Priors (priors.trade): signs Priors' consent with the agent owner's
71
75
  key and sends it. The line opens once the worker is proven on the
72
76
  board. --premium is the most the sponsor may charge per 30 days, in
73
77
  basis points (default 0, at most 200). Mainnet only.
78
+ brainchain-worker dispute <task> Dispute the grade on a task the worker delivered, within the dispute
79
+ window. Jev rules on it; a confident ruling decides.
80
+ brainchain-worker appeal <task> Appeal the ruling on a task, when it went against the worker, within the
81
+ appeal window, with the bond the task's terms fix (a quarter of the
82
+ bounty). Jev rules again; the bond comes back unless Jev confidently
83
+ rules the same way. Testnet only for now.
74
84
  brainchain-worker leave Start unstaking (3-day cooldown).
75
85
  brainchain-worker withdraw Withdraw stake once the cooldown has passed.
76
86
  brainchain-worker --version Print the version.
@@ -105,8 +115,10 @@ const FLAGS = {
105
115
  credit: { "agent-id": false, premium: false },
106
116
  propose: { target: false, call: false, args: false, data: false, title: false, rationale: false, current: false, proposed: false, dry: true },
107
117
  proposal: {},
118
+ dispute: {},
119
+ appeal: {},
108
120
  };
109
- const POSITIONAL = { price: 1, proposal: 1 };
121
+ const POSITIONAL = { price: 1, proposal: 1, dispute: 1, appeal: 1 };
110
122
  export function parse(command, args) {
111
123
  const known = FLAGS[command];
112
124
  if (known === undefined)
@@ -446,6 +458,8 @@ export async function main(argv) {
446
458
  ...(keyed ? { report: createReporter(network, account) } : {}),
447
459
  veto,
448
460
  log: console.log,
461
+ // Each run of --once is a new process, so it keeps its place on disk; a first run checks the latest few.
462
+ ...(once ? { seenDecision: readWatched(log), recent: FIRST_RUN_DECISIONS, saw: (count) => writeWatched(log, count) } : {}),
449
463
  });
450
464
  if (veto && !network.contracts.gate)
451
465
  throw new UsageError(`${network.name} has no guardian gate yet, so there is nothing to veto through`);
@@ -470,6 +484,12 @@ export async function main(argv) {
470
484
  return proposal(network, createProposals(network, reader), BigInt(id), console.log);
471
485
  }
472
486
  const worker = signer();
487
+ if (command === "dispute" || command === "appeal") {
488
+ const id = positional[0];
489
+ if (id === undefined || !/^[1-9]\d{0,17}$/.test(id))
490
+ throw new UsageError(`Usage: brainchain-worker ${command} <task>, such as ${command} 12`);
491
+ return (command === "dispute" ? dispute : appeal)(network, createDisputeChain(network, worker.account), BigInt(id), console.log);
492
+ }
473
493
  if (command === "propose") {
474
494
  if (!values.has("target"))
475
495
  throw new UsageError("--target is required: board, executor or registry");
@@ -0,0 +1,98 @@
1
+ import { createPublicClient, createWalletClient, formatUnits, parseAbi } from "viem";
2
+ import { brainchainTransport, erc20Abi, RECEIPT_MS, Status } from "./chain.js";
3
+ import { chainOf } from "./network.js";
4
+ /** TaskBoard statuses from set 3.5.0 on: a ruling that waits out its appeal window, and one under appeal. */
5
+ export const Ruled = 8;
6
+ export const Appealed = 9;
7
+ const boardAbi = parseAbi([
8
+ "function get(uint256 id) view returns ((address poster, uint128 bounty, uint64 deadline, uint8 status, bool accepted, bool disputed, bool disputedByModel, bytes32 model, bytes32 briefHash, bytes32 resultHash, uint64 submittedAt, uint64 gradedAt, uint64 disputedAt, uint64 postedAt, address payee, address feeRecipient, uint16 feeBps, uint16 hireThresholdBps, uint16 gradeThresholdBps, uint16 ruleThresholdBps, uint64 gradeWindow, uint64 disputeWindow, uint64 ruleWindow))",
9
+ "function appealOf(uint256 id) view returns ((uint64 window, uint16 bondBps, uint64 ruledAt, bool pays, bool confident, address appellant, uint128 bond, uint64 appealedAt))",
10
+ "function appealBond(uint256 id) view returns (uint128)",
11
+ "function dispute(uint256 id)",
12
+ "function appeal(uint256 id)",
13
+ ]);
14
+ export function createDisputeChain(network, account) {
15
+ const chain = chainOf(network);
16
+ const transport = brainchainTransport(network);
17
+ const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
18
+ const wallet = createWalletClient({ account, chain, transport });
19
+ const board = { address: network.contracts.taskBoard, abi: boardAbi };
20
+ const token = { address: network.contracts.bountyToken, abi: erc20Abi };
21
+ async function send(functionName, id) {
22
+ const hash = await wallet.writeContract({ ...board, functionName, args: [id] });
23
+ const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
24
+ if (receipt.status !== "success")
25
+ throw new Error(`Transaction ${hash} reverted`);
26
+ return hash;
27
+ }
28
+ return {
29
+ account: account.address,
30
+ async now() {
31
+ const block = await client.getBlock();
32
+ const wall = BigInt(Math.floor(Date.now() / 1000));
33
+ return block.timestamp > wall ? block.timestamp : wall;
34
+ },
35
+ task: (id) => client.readContract({ ...board, functionName: "get", args: [id] }),
36
+ appealOf: (id) => client.readContract({ ...board, functionName: "appealOf", args: [id] }),
37
+ appealBond: (id) => client.readContract({ ...board, functionName: "appealBond", args: [id] }),
38
+ bountyBalance: () => client.readContract({ ...token, functionName: "balanceOf", args: [account.address] }),
39
+ async approveBounty(amount) {
40
+ const current = await client.readContract({ ...token, functionName: "allowance", args: [account.address, board.address] });
41
+ if (current >= amount)
42
+ return;
43
+ const hash = await wallet.writeContract({ ...token, functionName: "approve", args: [board.address, amount] });
44
+ const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
45
+ if (receipt.status !== "success")
46
+ throw new Error(`Approval ${hash} reverted`);
47
+ },
48
+ dispute: (id) => send("dispute", id),
49
+ appeal: (id) => send("appeal", id),
50
+ };
51
+ }
52
+ const same = (a, b) => a.toLowerCase() === b.toLowerCase();
53
+ const at = (seconds) => new Date(Number(seconds) * 1000).toISOString().replace(/\.000Z$/, "Z");
54
+ /** Disputes a grade on a task the worker delivered, within the dispute window. */
55
+ export async function dispute(network, chain, id, log) {
56
+ const [task, now] = await Promise.all([chain.task(id), chain.now()]);
57
+ if (task.status === 0)
58
+ throw new Error(`There is no task ${id} on ${network.name}.`);
59
+ if (!same(task.payee, chain.account) && !same(task.poster, chain.account)) {
60
+ throw new Error(`Only the poster or the account that delivered task ${id} can dispute it, and ${chain.account} is neither.`);
61
+ }
62
+ if (task.status !== Status.Graded || task.disputed)
63
+ throw new Error(`Task ${id} is not waiting on a dispute: a grade can be disputed once, while it is graded and unsettled.`);
64
+ const closes = task.gradedAt + task.disputeWindow;
65
+ if (now > closes)
66
+ throw new Error(`The dispute window for task ${id} closed at ${at(closes)}.`);
67
+ const tx = await chain.dispute(id);
68
+ log(`Disputed the grade on task ${id} (${task.accepted ? "it accepted the work" : "it rejected the work"}): ${tx}`);
69
+ log(`Jev rules within ${task.ruleWindow / 3600n} hours. A confident ruling decides; otherwise the grade stands.`);
70
+ }
71
+ /** Appeals the ruling on a task, as the side it went against, with the bond the board fixed when the task was posted. */
72
+ export async function appeal(network, chain, id, log) {
73
+ const [task, now] = await Promise.all([chain.task(id), chain.now()]);
74
+ if (task.status === 0)
75
+ throw new Error(`There is no task ${id} on ${network.name}.`);
76
+ if (task.status === Status.Disputed)
77
+ throw new Error(`Task ${id} is still waiting for Jev's ruling. Appeal once it is ruled.`);
78
+ if (task.status === Appealed)
79
+ throw new Error(`Task ${id} is already under appeal; a ruling can be appealed once.`);
80
+ if (task.status !== Ruled)
81
+ throw new Error(`Task ${id} has no ruling open to appeal.`);
82
+ const terms = await chain.appealOf(id);
83
+ const appellant = terms.pays ? task.poster : task.payee;
84
+ if (!same(appellant, chain.account)) {
85
+ throw new Error(`The ruling on task ${id} ${terms.pays ? "pays the model, so only the poster" : "refunds the poster, so only the account that delivered"} can appeal it.`);
86
+ }
87
+ const closes = terms.ruledAt + terms.window;
88
+ if (now > closes)
89
+ throw new Error(`The appeal window for task ${id} closed at ${at(closes)}.`);
90
+ const [bond, balance] = await Promise.all([chain.appealBond(id), chain.bountyBalance()]);
91
+ const amount = `${formatUnits(bond, 6)} ${network.bountySymbol}`;
92
+ if (balance < bond)
93
+ throw new Error(`The appeal bond is ${amount}, and the worker holds ${formatUnits(balance, 6)}. Bridge more with \`npx brainchain-worker bridge --bounty <amount>\`.`);
94
+ await chain.approveBounty(bond);
95
+ const tx = await chain.appeal(id);
96
+ log(`Appealed the ruling on task ${id} with a ${amount} bond: ${tx}`);
97
+ log(`Jev rules again within ${task.ruleWindow / 3600n} hours. The bond comes back unless Jev confidently rules the same way.`);
98
+ }
package/dist/network.js CHANGED
@@ -6,7 +6,7 @@ export const TESTNET = {
6
6
  chainId: 274630,
7
7
  rpc: "https://rpc.testnet.usebrainchain.com",
8
8
  service: "https://brain.testnet.usebrainchain.com",
9
- fromBlock: 2141n,
9
+ fromBlock: 2288n,
10
10
  explorer: "https://usebrainchain.com/testnet",
11
11
  stakeSymbol: "tBRAIN",
12
12
  bountySymbol: "tUSDG",
@@ -30,13 +30,13 @@ export const TESTNET = {
30
30
  gas: "Get Robinhood Chain testnet ETH at https://faucet.testnet.chain.robinhood.com",
31
31
  },
32
32
  contracts: {
33
- taskBoard: "0x67e0C5F884E1df61AC585B6d158c6E8175956AD1",
34
- staking: "0xd2161eE4194Aaec3c2a7Aa83ccDEeafDf94A72c2",
35
- bonds: "0x85Fd1aE9049ae52DeCdd27b833B16b877e1ce662",
36
- executor: "0x5BFbAf3986E4aFaeb2895535E0bE206F76e5EC63",
37
- registry: "0x7A10D8361917d4446cAEfBd3F71694A438cC6BF8",
38
- log: "0x49019b7ad5B4b688A1bF3c52D9f3a1DF5870F59f",
39
- gate: "0xE59A4d0De46d055196FF73AF605aabBe4B94A70F",
33
+ taskBoard: "0x7A7cC5D35A642CCc268C7B263fF800bc7eBD52A4",
34
+ staking: "0x6aFC3EFB49CF9b14C2F7c6C4f6C0bb3eF0125e2b",
35
+ bonds: "0x2f15B42131544e6763dbECC7C954C7823AAa0D47",
36
+ executor: "0xA3a40A2D46D54dc6e88c08a9d98Ec47785CB2366",
37
+ registry: "0x3e5e23a5fF6548958ab3dDf42c51A59713cE550C",
38
+ log: "0x4E4cB48f3802bD519E0Eed896dF82197B0ecBc09",
39
+ gate: "0x84d01e0869fbCb07c0141E49c390ffdc064D6882",
40
40
  stakeToken: "0xc717546497343911750768335b649b86d0a6eCcf",
41
41
  bountyToken: "0xC4ec72794bF3Ce9ba1DE4B38085464c2eb0cDCf2",
42
42
  },
package/dist/state.js CHANGED
@@ -2,6 +2,22 @@ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, re
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  export const stateDir = () => join(homedir(), ".config", "brainchain-worker");
5
+ /** The decision count a one-shot watch run stopped at, kept per decision log; undefined when there is none or it cannot be read. */
6
+ export function readWatched(log, dir = stateDir()) {
7
+ try {
8
+ const { seen } = JSON.parse(readFileSync(join(dir, `watch.${log.toLowerCase()}.json`), "utf8"));
9
+ return typeof seen === "string" && /^\d+$/.test(seen) ? BigInt(seen) : undefined;
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ }
15
+ export function writeWatched(log, seen, dir = stateDir()) {
16
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
17
+ const file = join(dir, `watch.${log.toLowerCase()}.json`);
18
+ writeFileSync(`${file}.tmp`, JSON.stringify({ seen: seen.toString() }), { mode: 0o600 });
19
+ renameSync(`${file}.tmp`, file);
20
+ }
5
21
  const empty = (board) => ({ board: board.toLowerCase(), tasks: {}, done: [] });
6
22
  export function memoryStore(board = "") {
7
23
  return { state: empty(board), save: () => undefined };
package/dist/watch.js CHANGED
@@ -139,7 +139,7 @@ export function createReporter(network, account, fetcher = fetch) {
139
139
  export function createWatch(options) {
140
140
  const { network, chain, verify, log } = options;
141
141
  let nextBlock = options.fromBlock ?? network.fromBlock;
142
- let seenDecision;
142
+ let seenDecision = options.seenDecision;
143
143
  const judged = new Set();
144
144
  let findings = 0;
145
145
  async function changes() {
@@ -188,7 +188,11 @@ export function createWatch(options) {
188
188
  if (!verify)
189
189
  return;
190
190
  const count = await chain.decisions();
191
- // The first round only notes where the log stands; later ones check what was added.
191
+ if (seenDecision === undefined && options.recent !== undefined)
192
+ seenDecision = count > options.recent ? count - options.recent : 0n;
193
+ // A log restarted under a new set of contracts has fewer decisions than the count kept from the old one.
194
+ if (seenDecision !== undefined && seenDecision > count)
195
+ seenDecision = options.recent === undefined ? undefined : count > options.recent ? count - options.recent : 0n;
192
196
  if (seenDecision === undefined) {
193
197
  seenDecision = count;
194
198
  log(`${count} decisions recorded so far; new ones are checked from here`);
@@ -215,6 +219,7 @@ export function createWatch(options) {
215
219
  }
216
220
  }
217
221
  seenDecision = count;
222
+ options.saw?.(count);
218
223
  if (!options.report || (checked.length === 0 && found.length === 0))
219
224
  return;
220
225
  try {
package/dist/work.js CHANGED
@@ -75,7 +75,9 @@ function signalGroup(pid, signal) {
75
75
  process.kill(-pid, signal);
76
76
  }
77
77
  catch (error) {
78
- if (error.code !== "ESRCH")
78
+ // macOS answers EPERM for a group whose members are already exiting; the group is the worker's own child either way.
79
+ const { code } = error;
80
+ if (code !== "ESRCH" && code !== "EPERM")
79
81
  throw error;
80
82
  }
81
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-worker",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
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>",
@@ -28,7 +28,7 @@
28
28
  "prepack": "npm run build"
29
29
  },
30
30
  "dependencies": {
31
- "brainchain-verify": "^0.1.2",
31
+ "brainchain-verify": "^0.3.1",
32
32
  "viem": "^2.0.0"
33
33
  },
34
34
  "devDependencies": {