brainchain-worker 0.4.1 → 0.5.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
@@ -70,6 +70,21 @@ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker claim # pays it in one
70
70
 
71
71
  `claim` asks the BrainChain service which Proofs the wallet holds, reads what each is due and claims every one with income due in a single transaction, up to 200 at a time. Income always goes to the Proof's current holder. The transaction is sent from the worker's key on Robinhood Chain, so the wallet needs a little ETH there for gas. Testnet Proofs earn no income.
72
72
 
73
+ ## Propose a change
74
+
75
+ 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:
76
+
77
+ ```sh
78
+ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker propose --target board --call setFee \
79
+ --args '[200, "0xdFeFbdBF680568e8DC44abbbd657c0701964cC8c"]' \
80
+ --title "Lower the fee to 2%" --rationale "Why the change is better, in up to 1,500 characters." --dry
81
+ ```
82
+
83
+ - **The call.** `--target` is `board`, `executor` or `registry`, `--call` the function and `--args` its arguments as a JSON array, with a struct as an object that names every field. `--data` takes raw calldata instead. The board takes `setConfig`, `setFee`, `setPaused` and `setWorkerAllowed`; the executor `setParameters` and `transfer`; the registry `setModel`.
84
+ - **The case.** Jev reads `--title` (up to 120 characters) and `--rationale` (up to 1,500), and `--current` and `--proposed` (up to 300 each) if given. `--proposed` defaults to the call itself.
85
+ - **Checks first.** Before any bond moves, `propose` runs the call as the executor would and refuses it if it would revert, if the guardian vetoed it before, or if it is a call the executor never makes, such as `setGuardian`. `--dry` stops after the checks.
86
+ - **Outcome.** Jev decides within 7 days. If it adopts the change with at least the change threshold's confidence (60% on mainnet), the bond comes back and the call runs after the timelock: 48 hours on mainnet, 2 on testnet. Otherwise the bond is burned. `proposal <id>` shows where a proposal stands.
87
+
73
88
  ## Your command
74
89
 
75
90
  `run --exec "<command>"` runs the command through the shell once per task.
@@ -115,6 +130,8 @@ Briefs are written by strangers, and some will try to get your agent to do somet
115
130
  | `price <USDG>` | Change the asking price, in tUSDG on testnet and USDG on mainnet. |
116
131
  | `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. |
117
132
  | `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. |
133
+ | `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). |
134
+ | `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. |
118
135
  | `leave` | Start unstaking. The stake unlocks after 3 days, once no task of the worker's is in flight. |
119
136
  | `withdraw` | Withdraw the unlocked stake. |
120
137
  | `--version` | Print the version. |
package/dist/chain.js CHANGED
@@ -186,9 +186,11 @@ export async function unhealthy(service, fetcher = fetch) {
186
186
  return `the BrainChain service at ${host} reports it is behind (503); results still upload, and grading may be late`;
187
187
  return `the BrainChain service at ${host} answered its health check with ${response.status}; results are uploaded there, so delivery can fail until it recovers`;
188
188
  }
189
+ /** BrainChain's RPC, with answers that are not JSON-RPC refused before viem reads them. */
190
+ export const brainchainTransport = (network) => http(network.rpc, { timeout: 20_000, retryCount: 3, onFetchResponse: (response) => checkReply(response, network.rpc) });
189
191
  export function createChain(network, account, store = memoryStore()) {
190
192
  const chain = chainOf(network);
191
- const transport = http(network.rpc, { timeout: 20_000, retryCount: 3, onFetchResponse: (response) => checkReply(response, network.rpc) });
193
+ const transport = brainchainTransport(network);
192
194
  // BrainChain mines a block per transaction, so receipts are polled every second.
193
195
  const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
194
196
  const wallet = createWalletClient({ account, chain, transport });
package/dist/claim.js CHANGED
@@ -1,6 +1,6 @@
1
- import { createPublicClient, createWalletClient, formatEther, http, parseAbi, parseEventLogs } from "viem";
1
+ import { createPublicClient, createWalletClient, formatEther, parseAbi, parseEventLogs } from "viem";
2
2
  import { RECEIPT_MS } from "./chain.js";
3
- import { proofsChainOf } from "./network.js";
3
+ import { proofsChainOf, proofsTransport } from "./network.js";
4
4
  export const proofsIncomeAbi = parseAbi([
5
5
  "function due(uint256 tokenId) view returns (uint256)",
6
6
  "function claimMany(uint256[] tokenIds)",
@@ -12,7 +12,7 @@ export const proofsIncomeAbi = parseAbi([
12
12
  export const MAX_CLAIM = 200;
13
13
  export function createIncome(proofs, income, account) {
14
14
  const chain = proofsChainOf(proofs);
15
- const transport = http(proofs.rpc, { timeout: 20_000, retryCount: 3 });
15
+ const transport = proofsTransport(proofs);
16
16
  const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
17
17
  const wallet = createWalletClient({ account, chain, transport });
18
18
  const router = { address: income, abi: proofsIncomeAbi };
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import { createChain, faucetReopens, reason, unhealthy } from "./chain.js";
5
5
  import { claim, createIncome } from "./claim.js";
6
6
  import { createProofMinter, createProofsChain, MAX_MINT, mint } from "./mint.js";
7
7
  import { resolveNetwork } from "./network.js";
8
+ import { buildCall, caseURI, createProposals, proposal, propose } from "./propose.js";
8
9
  import { hireable, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
9
10
  import { openStore } from "./state.js";
10
11
  import { createWork, shellCommand, stopLeftover, stopper } from "./work.js";
@@ -30,6 +31,12 @@ Usage:
30
31
  --count, at 0.001 WETH each on Robinhood Chain. --dry sends nothing.
31
32
  brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
32
33
  --dry lists what is due and sends nothing.
34
+ brainchain-worker propose --target board|executor|registry --call <function> --args '<JSON array>'
35
+ --title <text> --rationale <text> [--current <text>] [--proposed <text>] [--dry]
36
+ Bond the stake token and propose a change to BrainChain for Jev to
37
+ decide. --data <hex> gives the call as raw calldata instead of
38
+ --call and --args. --dry checks the call and sends nothing.
39
+ brainchain-worker proposal <id> Where a proposal stands: waiting, adopted and when it runs, or kept.
33
40
  brainchain-worker leave Start unstaking (3-day cooldown).
34
41
  brainchain-worker withdraw Withdraw stake once the cooldown has passed.
35
42
  brainchain-worker --version Print the version.
@@ -59,8 +66,10 @@ const FLAGS = {
59
66
  withdraw: {},
60
67
  claim: { dry: true },
61
68
  mint: { count: false, dry: true },
69
+ propose: { target: false, call: false, args: false, data: false, title: false, rationale: false, current: false, proposed: false, dry: true },
70
+ proposal: {},
62
71
  };
63
- const POSITIONAL = { price: 1 };
72
+ const POSITIONAL = { price: 1, proposal: 1 };
64
73
  export function parse(command, args) {
65
74
  const known = FLAGS[command];
66
75
  if (known === undefined)
@@ -332,7 +341,27 @@ export async function main(argv) {
332
341
  }
333
342
  return mint(network, createProofsChain(network.proofs, signer().account), { count: count === undefined ? undefined : Number(count), dry: switches.has("dry"), log: console.log });
334
343
  }
344
+ if (command === "proposal") {
345
+ const id = positional[0];
346
+ if (id === undefined || !/^\d{1,18}$/.test(id) || id === "0")
347
+ throw new UsageError("Usage: brainchain-worker proposal <id>, such as proposal 2");
348
+ // Reading a proposal needs no key, so any address stands in for the signer.
349
+ const reader = process.env.BRAINCHAIN_WORKER_KEY?.trim() ? signer().account : privateKeyToAccount(generatePrivateKey());
350
+ return proposal(network, createProposals(network, reader), BigInt(id), console.log);
351
+ }
335
352
  const worker = signer();
353
+ if (command === "propose") {
354
+ if (!values.has("target"))
355
+ throw new UsageError("--target is required: board, executor or registry");
356
+ const call = buildCall(network, worker.account.address, {
357
+ target: values.get("target"),
358
+ call: values.get("call"),
359
+ args: values.get("args"),
360
+ data: values.get("data"),
361
+ });
362
+ const uri = caseURI(call, { title: values.get("title") ?? "", rationale: values.get("rationale") ?? "", current: values.get("current"), proposed: values.get("proposed") });
363
+ return propose(network, createProposals(network, worker.account), call, uri, { dry: switches.has("dry"), log: console.log });
364
+ }
336
365
  if (command === "run")
337
366
  return run(network, worker, values, switches.has("once"), !switches.has("no-mint"));
338
367
  const chain = createChain(network, worker.account);
package/dist/mint.js CHANGED
@@ -1,6 +1,6 @@
1
- import { createPublicClient, createWalletClient, formatEther, http, parseAbi, parseEventLogs, zeroAddress, } from "viem";
1
+ import { createPublicClient, createWalletClient, formatEther, parseAbi, parseEventLogs, zeroAddress, } from "viem";
2
2
  import { reason, RECEIPT_MS } from "./chain.js";
3
- import { proofsChainOf } from "./network.js";
3
+ import { proofsChainOf, proofsTransport } from "./network.js";
4
4
  import { when } from "./standing.js";
5
5
  export const collectionAbi = parseAbi([
6
6
  "function PRICE_QUOTE() view returns (uint256)",
@@ -38,7 +38,7 @@ export function mintedToken(logs, collection, to) {
38
38
  }
39
39
  export function createProofsChain(proofs, account) {
40
40
  const chain = proofsChainOf(proofs);
41
- const transport = http(proofs.rpc, { timeout: 20_000, retryCount: 3 });
41
+ const transport = proofsTransport(proofs);
42
42
  const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
43
43
  const wallet = createWalletClient({ account, chain, transport });
44
44
  const weth = { address: proofs.weth, abi: wethAbi };
package/dist/network.js CHANGED
@@ -1,4 +1,4 @@
1
- import { defineChain, zeroAddress } from "viem";
1
+ import { defineChain, fallback, http, zeroAddress } from "viem";
2
2
  /** BrainChain testnet, release 3.1.0. */
3
3
  export const TESTNET = {
4
4
  id: "testnet",
@@ -15,6 +15,7 @@ export const TESTNET = {
15
15
  chainId: 46630,
16
16
  name: "Robinhood Chain testnet",
17
17
  rpc: "https://rpc.testnet.chain.robinhood.com",
18
+ rpcFallbacks: ["https://robinhood-sepolia-rpc.publicnode.com", "https://robinhood-testnet.drpc.org"],
18
19
  collection: "0x324B52EE2182641ec29652076ab541bc9F9907d2",
19
20
  weth: "0x1F852eF3Afa13156b807204d838BbB46B4b452eb",
20
21
  faucet: true,
@@ -25,6 +26,7 @@ export const TESTNET = {
25
26
  staking: "0xbdC08799D865DA79FE44a469de1FeaE84bb45059",
26
27
  bonds: "0x574060D1A7CA8f3786d2D14fe1A782cc4638CB1e",
27
28
  executor: "0x420484B139001ED5B1DA330e689Cc2f4814001Fb",
29
+ registry: "0x297AA5fb95Ef9785Da6DC0cA00121060606e2C63",
28
30
  stakeToken: "0xc717546497343911750768335b649b86d0a6eCcf",
29
31
  bountyToken: "0xC4ec72794bF3Ce9ba1DE4B38085464c2eb0cDCf2",
30
32
  },
@@ -54,6 +56,7 @@ export const MAINNET = {
54
56
  chainId: 4663,
55
57
  name: "Robinhood Chain",
56
58
  rpc: "https://rpc.mainnet.chain.robinhood.com",
59
+ rpcFallbacks: ["https://rpc-robinhood.globalstake.io", "https://robinhood.api.pocket.network"],
57
60
  collection: "0xE6E6B7DBF1a66dFB25c6F6A87a916B3186113169",
58
61
  weth: "0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73",
59
62
  gas: "Send it a little ETH there for gas",
@@ -64,6 +67,7 @@ export const MAINNET = {
64
67
  staking: MAINNET_WORKER_STAKING,
65
68
  bonds: MAINNET_PROPOSAL_BONDS,
66
69
  executor: MAINNET_DECISION_EXECUTOR,
70
+ registry: "0x65B71706bE8a92dda39dDfdb6ABA8CEA64A33865",
67
71
  stakeToken: BRAIN,
68
72
  bountyToken: "0x8eBFEce96c0AeF7Fd23425D846a302a552c8c451",
69
73
  },
@@ -112,7 +116,12 @@ export function proofsChainOf(proofs) {
112
116
  id: proofs.chainId,
113
117
  name: proofs.name,
114
118
  nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
115
- rpcUrls: { default: { http: [proofs.rpc] } },
119
+ rpcUrls: { default: { http: [proofs.rpc, ...(proofs.rpcFallbacks ?? [])] } },
116
120
  contracts: { multicall3: { address: MULTICALL3 } },
117
121
  });
118
122
  }
123
+ /** The Proofs chain's RPCs in order; the next answers when one fails to, a revert being an answer. */
124
+ export function proofsTransport(proofs) {
125
+ const urls = [proofs.rpc, ...(proofs.rpcFallbacks ?? [])];
126
+ return fallback(urls.map((url) => http(url, { timeout: 20_000, retryCount: urls.length > 1 ? 1 : 3 })), { rank: false, retryCount: 0 });
127
+ }
@@ -0,0 +1,336 @@
1
+ import { BaseError, createPublicClient, createWalletClient, decodeErrorResult, decodeFunctionData, encodeAbiParameters, encodeFunctionData, ExecutionRevertedError, getAddress, isAddress, isHex, keccak256, parseAbi, parseEventLogs, size, } from "viem";
2
+ import { erc20Errors } from "./abi.js";
3
+ import { bondsAbi, brainchainTransport, erc20Abi, executorAbi, RECEIPT_MS } from "./chain.js";
4
+ import { chainOf } from "./network.js";
5
+ import { stake, when } from "./standing.js";
6
+ /** Every call the executor can be asked to make, by the contract it calls. */
7
+ export const GOVERNED = {
8
+ board: parseAbi([
9
+ "struct Config { uint16 hireThresholdBps; uint16 gradeThresholdBps; uint16 ruleThresholdBps; uint64 minDuration; uint64 maxDuration; uint64 gradeWindow; uint64 disputeWindow; uint64 ruleWindow; uint128 minBounty; }",
10
+ "function setConfig(Config config)",
11
+ "function setFee(uint16 feeBps, address feeRouter)",
12
+ "function setPaused(bool paused)",
13
+ "function setWorkerAllowed(address account, bool allowed)",
14
+ ]),
15
+ executor: parseAbi([
16
+ "function setParameters(uint16 changeThresholdBps, uint64 timelock, uint32 houseTasksPerDay, uint128 maxHouseBounty, uint128 houseSpendPerDay)",
17
+ "function transfer(address to, uint256 amount)",
18
+ ]),
19
+ registry: parseAbi(["function setModel(bytes32 id, address account, uint128 price, bool active)"]),
20
+ };
21
+ const TARGETS = Object.keys(GOVERNED);
22
+ /** What the brain reads from a proposal's URI, cut to the lengths it keeps. */
23
+ export const LIMITS = { title: 120, rationale: 1_500, current: 300, proposed: 300 };
24
+ const proposalsAbi = parseAbi([
25
+ "event Expired(uint256 indexed id)",
26
+ "function decideWithin() view returns (uint64)",
27
+ ]);
28
+ const executorReadsAbi = parseAbi([
29
+ "function vetoedCall(bytes32) view returns (bool)",
30
+ "function changeThresholdBps() view returns (uint16)",
31
+ ]);
32
+ // The reverts a governed call can hit, so a rehearsal names the rule it broke.
33
+ const callErrorsAbi = [
34
+ ...parseAbi(["error BadConfig()", "error BadFee()", "error ZeroAddress()", "error NotOwner()", "error NotSelf()", "error NotGuardian()"]),
35
+ ...erc20Errors,
36
+ ];
37
+ /** Mirrors ProposalBonds.State. */
38
+ const State = { None: 0, Open: 1, Returned: 2, Burned: 3 };
39
+ export class ProposalError extends Error {
40
+ }
41
+ function functions(target) {
42
+ return GOVERNED[target].filter((item) => item.type === "function").map((item) => item.name);
43
+ }
44
+ /** A JSON value as the ABI parameter it is passed for: integers become bigints, tuples objects. */
45
+ export function coerce(param, value) {
46
+ const name = param.name ?? param.type;
47
+ const fail = (expected) => new ProposalError(`${name} must be ${expected}, not ${JSON.stringify(value)}`);
48
+ if (/^u?int\d*$/.test(param.type)) {
49
+ const signed = param.type.startsWith("int");
50
+ if (typeof value === "number" && Number.isSafeInteger(value))
51
+ return BigInt(value);
52
+ if (typeof value === "string" && (signed ? /^-?\d+$/ : /^\d+$/).test(value))
53
+ return BigInt(value);
54
+ throw fail(`a whole number${signed ? "" : " from 0"}, as a JSON number or a string of digits`);
55
+ }
56
+ if (param.type === "bool") {
57
+ if (typeof value === "boolean")
58
+ return value;
59
+ throw fail("true or false");
60
+ }
61
+ if (param.type === "address") {
62
+ if (typeof value === "string" && isAddress(value, { strict: false }))
63
+ return getAddress(value);
64
+ throw fail("an address");
65
+ }
66
+ const bytes = /^bytes(\d+)$/.exec(param.type);
67
+ if (bytes) {
68
+ if (typeof value === "string" && isHex(value) && size(value) === Number(bytes[1]))
69
+ return value.toLowerCase();
70
+ throw fail(`${bytes[1]} bytes of hex`);
71
+ }
72
+ if (param.type === "tuple" && "components" in param) {
73
+ const parts = param.components;
74
+ if (Array.isArray(value) && value.length === parts.length) {
75
+ return Object.fromEntries(parts.map((part, i) => [part.name, coerce(part, value[i])]));
76
+ }
77
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
78
+ const given = value;
79
+ const extra = Object.keys(given).filter((key) => !parts.some((part) => part.name === key));
80
+ if (extra.length > 0)
81
+ throw new ProposalError(`${name} has no field ${extra[0]}; its fields are ${parts.map((p) => p.name).join(", ")}`);
82
+ return Object.fromEntries(parts.map((part) => {
83
+ if (!(part.name in given))
84
+ throw new ProposalError(`${name} is missing ${part.name}; give every field: ${parts.map((p) => p.name).join(", ")}`);
85
+ return [part.name, coerce(part, given[part.name])];
86
+ }));
87
+ }
88
+ throw fail(`an object with ${parts.map((p) => p.name).join(", ")}`);
89
+ }
90
+ throw fail(param.type);
91
+ }
92
+ const show = (value) => {
93
+ if (typeof value === "bigint")
94
+ return value.toString();
95
+ if (Array.isArray(value))
96
+ return `(${value.map(show).join(", ")})`;
97
+ if (typeof value === "object" && value !== null)
98
+ return `{${Object.entries(value).map(([k, v]) => `${k}: ${show(v)}`).join(", ")}}`;
99
+ return String(value);
100
+ };
101
+ function resolveTarget(network, target) {
102
+ const addresses = { board: network.contracts.taskBoard, executor: network.contracts.executor, registry: network.contracts.registry };
103
+ const named = TARGETS.find((t) => t === target || addresses[t].toLowerCase() === target.toLowerCase());
104
+ if (!named)
105
+ throw new ProposalError(`--target is board, executor or registry, not "${target}"`);
106
+ return { target: named, address: addresses[named] };
107
+ }
108
+ /**
109
+ * The call a proposal asks the executor to make, from --call and --args or
110
+ * from raw --data, refused here when the executor would refuse it anyway.
111
+ */
112
+ export function buildCall(network, proposer, input) {
113
+ const { target, address } = resolveTarget(network, input.target);
114
+ const abi = GOVERNED[target];
115
+ const offered = `The ${target} takes ${functions(target).join(", ")}`;
116
+ let data;
117
+ if (input.data !== undefined) {
118
+ if (input.call !== undefined || input.args !== undefined)
119
+ throw new ProposalError("Give either --data or --call with --args, not both");
120
+ if (!isHex(input.data) || input.data.length < 10)
121
+ throw new ProposalError("--data must be the call's hex calldata, starting 0x");
122
+ data = input.data.toLowerCase();
123
+ }
124
+ else {
125
+ if (!input.call)
126
+ throw new ProposalError(`--call is required: the function to call. ${offered}.`);
127
+ const fn = abi.find((item) => item.type === "function" && item.name === input.call);
128
+ if (!fn || fn.type !== "function")
129
+ throw new ProposalError(`${offered}, not ${input.call}. setGuardian and setOwner are never queued.`);
130
+ let args;
131
+ try {
132
+ args = JSON.parse(input.args ?? "[]");
133
+ }
134
+ catch {
135
+ throw new ProposalError(`--args must be a JSON array, such as '[300, "0x…"]'`);
136
+ }
137
+ if (!Array.isArray(args) || args.length !== fn.inputs.length) {
138
+ throw new ProposalError(`${fn.name} takes ${fn.inputs.length} argument${fn.inputs.length === 1 ? "" : "s"} (${fn.inputs.map((p) => p.name).join(", ")}), given as a JSON array in --args`);
139
+ }
140
+ data = encodeFunctionData({ abi, functionName: fn.name, args: fn.inputs.map((param, i) => coerce(param, args[i])) });
141
+ }
142
+ let decoded;
143
+ try {
144
+ decoded = decodeFunctionData({ abi, data });
145
+ }
146
+ catch {
147
+ throw new ProposalError(`${offered}; the selector ${data.slice(0, 10)} is none of them`);
148
+ }
149
+ if (encodeFunctionData({ abi, functionName: decoded.functionName, args: decoded.args }).toLowerCase() !== data) {
150
+ throw new ProposalError("--data has bytes beyond the call's arguments");
151
+ }
152
+ const args = decoded.args ?? [];
153
+ if (target === "board" && decoded.functionName === "setWorkerAllowed" && args[1] === true && String(args[0]).toLowerCase() === proposer.toLowerCase()) {
154
+ throw new ProposalError("Allowing your own wallet to work is an application: run join, which stakes and applies in one go");
155
+ }
156
+ return { target, address, data, text: `${decoded.functionName}(${args.map(show).join(", ")})` };
157
+ }
158
+ /** The proposal's URI: base64 JSON the brain shows Jev as the proposer's case. */
159
+ export function caseURI(call, input) {
160
+ const title = input.title.trim();
161
+ const rationale = input.rationale.trim();
162
+ if (!title)
163
+ throw new ProposalError(`--title is required, up to ${LIMITS.title} characters`);
164
+ if (!rationale)
165
+ throw new ProposalError(`--rationale is required: why the change is better, up to ${LIMITS.rationale} characters. Jev reads it to decide.`);
166
+ const body = { title, rationale, current: input.current?.trim() ?? "", proposed: input.proposed?.trim() || call.text };
167
+ for (const key of Object.keys(LIMITS)) {
168
+ if (body[key].length > LIMITS[key])
169
+ throw new ProposalError(`--${key} is at most ${LIMITS[key]} characters`);
170
+ }
171
+ return `data:application/json;base64,${Buffer.from(JSON.stringify(body)).toString("base64")}`;
172
+ }
173
+ const LOG_RANGE = 50000n;
174
+ export function createProposals(network, account) {
175
+ const chain = chainOf(network);
176
+ const transport = brainchainTransport(network);
177
+ const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
178
+ const wallet = createWalletClient({ account, chain, transport });
179
+ const { bonds, executor, stakeToken } = network.contracts;
180
+ const proposals = { address: bonds, abi: [...bondsAbi, ...proposalsAbi] };
181
+ async function scan(fetch) {
182
+ const to = await client.getBlockNumber();
183
+ const all = [];
184
+ for (let start = network.fromBlock; start <= to; start += LOG_RANGE) {
185
+ all.push(...(await fetch(start, start + LOG_RANGE - 1n < to ? start + LOG_RANGE - 1n : to)));
186
+ }
187
+ return all;
188
+ }
189
+ async function send(request) {
190
+ const hash = await wallet.writeContract(request);
191
+ const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
192
+ if (receipt.status !== "success")
193
+ throw new Error(`Transaction ${hash} reverted`);
194
+ return receipt;
195
+ }
196
+ return {
197
+ account: account.address,
198
+ now: async () => (await client.getBlock()).timestamp,
199
+ ethBalance: () => client.getBalance({ address: account.address }),
200
+ stakeBalance: () => client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
201
+ bond: () => client.readContract({ ...proposals, functionName: "bond" }),
202
+ decideWithin: () => client.readContract({ ...proposals, functionName: "decideWithin" }),
203
+ changeThresholdBps: async () => BigInt(await client.readContract({ address: executor, abi: executorReadsAbi, functionName: "changeThresholdBps" })),
204
+ timelock: () => client.readContract({ address: executor, abi: executorAbi, functionName: "timelock" }),
205
+ vetoed: (call) => client.readContract({
206
+ address: executor,
207
+ abi: executorReadsAbi,
208
+ functionName: "vetoedCall",
209
+ args: [keccak256(encodeAbiParameters([{ type: "address" }, { type: "bytes" }], [call.address, call.data]))],
210
+ }),
211
+ async rehearse(call) {
212
+ try {
213
+ await client.call({ account: executor, to: call.address, data: call.data });
214
+ return undefined;
215
+ }
216
+ catch (error) {
217
+ // Only a revert says the call would fail; an RPC that cannot be reached is a failure of its own.
218
+ const reverted = error instanceof BaseError ? error.walk((e) => e instanceof ExecutionRevertedError) : null;
219
+ if (!(error instanceof BaseError) || !reverted)
220
+ throw error;
221
+ const raw = error.walk((e) => typeof e.data === "string");
222
+ try {
223
+ if (raw?.data)
224
+ return `${decodeErrorResult({ abi: callErrorsAbi, data: raw.data }).errorName}, the contract's check on these values`;
225
+ }
226
+ catch {
227
+ // Not one of the contracts' own errors; the node's message says more.
228
+ }
229
+ return error.shortMessage;
230
+ }
231
+ },
232
+ async propose(call, uri, bond) {
233
+ const allowance = await client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "allowance", args: [account.address, bonds] });
234
+ if (allowance < bond)
235
+ await send({ address: stakeToken, abi: erc20Abi, functionName: "approve", args: [bonds, bond] });
236
+ const receipt = await send({ ...proposals, functionName: "propose", args: [call.address, call.data, uri] });
237
+ const [log] = parseEventLogs({ abi: bondsAbi, eventName: "Proposed", logs: receipt.logs }).filter((l) => l.address.toLowerCase() === bonds.toLowerCase());
238
+ if (!log)
239
+ throw new Error(`The proposal landed in ${receipt.transactionHash} but logged no id`);
240
+ return { id: log.args.id, tx: receipt.transactionHash };
241
+ },
242
+ async get(id) {
243
+ const p = await client.readContract({ ...proposals, functionName: "get", args: [id] });
244
+ return { id, proposer: p.proposer, bond: p.bond, createdAt: p.createdAt, state: p.state };
245
+ },
246
+ async outcome(id) {
247
+ const [settled, expired] = await Promise.all([
248
+ scan((fromBlock, toBlock) => client.getContractEvents({ ...proposals, eventName: "Settled", args: { id }, fromBlock, toBlock })),
249
+ scan((fromBlock, toBlock) => client.getContractEvents({ ...proposals, eventName: "Expired", args: { id }, fromBlock, toBlock })),
250
+ ]);
251
+ if (settled[0])
252
+ return { decisionId: settled[0].args.decisionId, adopted: settled[0].args.adopted };
253
+ return expired[0] ? "expired" : undefined;
254
+ },
255
+ async queued(decisionId) {
256
+ const [log] = await scan((fromBlock, toBlock) => client.getContractEvents({ address: executor, abi: executorAbi, eventName: "Queued", args: { decisionId }, fromBlock, toBlock }));
257
+ if (!log)
258
+ return undefined;
259
+ const change = await client.readContract({ address: executor, abi: executorAbi, functionName: "getChange", args: [log.args.changeId] });
260
+ return { changeId: log.args.changeId, eta: change.eta, executed: change.executed, vetoed: change.vetoed };
261
+ },
262
+ };
263
+ }
264
+ const hours = (seconds) => `${Number(seconds) / 3600} hours`;
265
+ const days = (seconds) => `${Number(seconds) / 86_400} days`;
266
+ const percent = (bps) => `${Number(bps) / 100}%`;
267
+ export async function propose(network, chain, call, uri, options) {
268
+ const { log } = options;
269
+ const [bond, balance, gas, within, threshold, timelock, vetoed, revert] = await Promise.all([
270
+ chain.bond(),
271
+ chain.stakeBalance(),
272
+ chain.ethBalance(),
273
+ chain.decideWithin(),
274
+ chain.changeThresholdBps(),
275
+ chain.timelock(),
276
+ chain.vetoed(call),
277
+ chain.rehearse(call),
278
+ ]);
279
+ if (vetoed)
280
+ throw new ProposalError(`The guardian vetoed ${call.text} on the ${call.target} before, so the executor never queues it again`);
281
+ if (revert)
282
+ throw new ProposalError(`${call.text} would fail if the executor made it now (${revert}), so the bond would be burned for nothing`);
283
+ log(`Proposal on ${network.name}
284
+ Call ${call.text} on the ${call.target} (${call.address})
285
+ Data ${call.data}
286
+ Bond ${stake(network, bond)}, returned if Jev adopts the change with at least ${percent(threshold)} confidence, burned otherwise
287
+ Decision within ${days(within)}; an adopted change runs ${hours(timelock)} later`);
288
+ if (options.dry) {
289
+ log("Dry run, nothing sent.");
290
+ return;
291
+ }
292
+ if (balance < bond) {
293
+ const buy = network.buyStake ? ` ${network.buyStake}` : "";
294
+ throw new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, bond)} bond.${buy}`);
295
+ }
296
+ if (gas === 0n)
297
+ throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}; 0.001 ETH is plenty.`);
298
+ log(`Bonding ${stake(network, bond)} and proposing...`);
299
+ const { id, tx } = await chain.propose(call, uri, bond);
300
+ log(`Proposal ${id} is waiting for Jev (${tx}). Follow it with \`npx brainchain-worker proposal ${id}\`.`);
301
+ }
302
+ export async function proposal(network, chain, id, log) {
303
+ const p = await chain.get(id);
304
+ if (p.state === State.None)
305
+ throw new ProposalError(`There is no proposal ${id} on ${network.name}`);
306
+ const head = `Proposal ${id} by ${p.proposer}, bond ${stake(network, p.bond)}`;
307
+ if (p.state === State.Open) {
308
+ const [within, now] = await Promise.all([chain.decideWithin(), chain.now()]);
309
+ const closes = p.createdAt + within;
310
+ const after = now > closes ? `its window closed at ${when(closes)}, so anyone can return the bond with expire` : `Jev decides by ${when(closes)}`;
311
+ log(`${head}\n Waiting for Jev: ${after}.`);
312
+ return;
313
+ }
314
+ const outcome = await chain.outcome(id);
315
+ if (outcome === "expired") {
316
+ log(`${head}\n Not decided in its window; the bond was returned.`);
317
+ return;
318
+ }
319
+ if (outcome === undefined) {
320
+ log(`${head}\n ${p.state === State.Returned ? "Settled, bond returned" : "Settled, bond burned"}.`);
321
+ return;
322
+ }
323
+ if (!outcome.adopted) {
324
+ log(`${head}\n Jev kept the current setting (decision ${outcome.decisionId}); the bond was burned.`);
325
+ return;
326
+ }
327
+ const change = await chain.queued(outcome.decisionId);
328
+ const state = !change
329
+ ? "not queued yet"
330
+ : change.executed
331
+ ? `change ${change.changeId} has run`
332
+ : change.vetoed
333
+ ? `change ${change.changeId} was vetoed by the guardian`
334
+ : `change ${change.changeId} runs from ${when(change.eta)}`;
335
+ log(`${head}\n Jev adopted it (decision ${outcome.decisionId}) and the bond was returned; ${state}.`);
336
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "brainchain-worker",
3
- "version": "0.4.1",
4
- "description": "Earn on BrainChain: stake, apply, and do the tasks Jev hires your agent for, with any command that reads a task and prints the work.",
3
+ "version": "0.5.0",
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>",
7
7
  "homepage": "https://docs.usebrainchain.com/run-a-worker",