brainchain-worker 0.4.0 → 0.4.2

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.
@@ -0,0 +1,318 @@
1
+ import { BaseError, createPublicClient, createWalletClient, decodeFunctionData, encodeAbiParameters, encodeFunctionData, getAddress, http, isAddress, isHex, keccak256, parseAbi, parseEventLogs, size, } from "viem";
2
+ import { bondsAbi, erc20Abi, executorAbi, RECEIPT_MS } from "./chain.js";
3
+ import { chainOf } from "./network.js";
4
+ import { stake, when } from "./standing.js";
5
+ /** Every call the executor can be asked to make, by the contract it calls. */
6
+ export const GOVERNED = {
7
+ board: parseAbi([
8
+ "struct Config { uint16 hireThresholdBps; uint16 gradeThresholdBps; uint16 ruleThresholdBps; uint64 minDuration; uint64 maxDuration; uint64 gradeWindow; uint64 disputeWindow; uint64 ruleWindow; uint128 minBounty; }",
9
+ "function setConfig(Config config)",
10
+ "function setFee(uint16 feeBps, address feeRouter)",
11
+ "function setPaused(bool paused)",
12
+ "function setWorkerAllowed(address account, bool allowed)",
13
+ ]),
14
+ executor: parseAbi([
15
+ "function setParameters(uint16 changeThresholdBps, uint64 timelock, uint32 houseTasksPerDay, uint128 maxHouseBounty, uint128 houseSpendPerDay)",
16
+ "function transfer(address to, uint256 amount)",
17
+ ]),
18
+ registry: parseAbi(["function setModel(bytes32 id, address account, uint128 price, bool active)"]),
19
+ };
20
+ const TARGETS = Object.keys(GOVERNED);
21
+ /** What the brain reads from a proposal's URI, cut to the lengths it keeps. */
22
+ export const LIMITS = { title: 120, rationale: 1_500, current: 300, proposed: 300 };
23
+ const proposalsAbi = parseAbi([
24
+ "event Expired(uint256 indexed id)",
25
+ "function decideWithin() view returns (uint64)",
26
+ ]);
27
+ const executorReadsAbi = parseAbi([
28
+ "function vetoedCall(bytes32) view returns (bool)",
29
+ "function changeThresholdBps() view returns (uint16)",
30
+ ]);
31
+ /** Mirrors ProposalBonds.State. */
32
+ const State = { None: 0, Open: 1, Returned: 2, Burned: 3 };
33
+ export class ProposalError extends Error {
34
+ }
35
+ function functions(target) {
36
+ return GOVERNED[target].filter((item) => item.type === "function").map((item) => item.name);
37
+ }
38
+ /** A JSON value as the ABI parameter it is passed for: integers become bigints, tuples objects. */
39
+ export function coerce(param, value) {
40
+ const name = param.name ?? param.type;
41
+ const fail = (expected) => new ProposalError(`${name} must be ${expected}, not ${JSON.stringify(value)}`);
42
+ if (/^u?int\d*$/.test(param.type)) {
43
+ const signed = param.type.startsWith("int");
44
+ if (typeof value === "number" && Number.isSafeInteger(value))
45
+ return BigInt(value);
46
+ if (typeof value === "string" && (signed ? /^-?\d+$/ : /^\d+$/).test(value))
47
+ return BigInt(value);
48
+ throw fail(`a whole number${signed ? "" : " from 0"}, as a JSON number or a string of digits`);
49
+ }
50
+ if (param.type === "bool") {
51
+ if (typeof value === "boolean")
52
+ return value;
53
+ throw fail("true or false");
54
+ }
55
+ if (param.type === "address") {
56
+ if (typeof value === "string" && isAddress(value, { strict: false }))
57
+ return getAddress(value);
58
+ throw fail("an address");
59
+ }
60
+ const bytes = /^bytes(\d+)$/.exec(param.type);
61
+ if (bytes) {
62
+ if (typeof value === "string" && isHex(value) && size(value) === Number(bytes[1]))
63
+ return value.toLowerCase();
64
+ throw fail(`${bytes[1]} bytes of hex`);
65
+ }
66
+ if (param.type === "tuple" && "components" in param) {
67
+ const parts = param.components;
68
+ if (Array.isArray(value) && value.length === parts.length) {
69
+ return Object.fromEntries(parts.map((part, i) => [part.name, coerce(part, value[i])]));
70
+ }
71
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
72
+ const given = value;
73
+ const extra = Object.keys(given).filter((key) => !parts.some((part) => part.name === key));
74
+ if (extra.length > 0)
75
+ throw new ProposalError(`${name} has no field ${extra[0]}; its fields are ${parts.map((p) => p.name).join(", ")}`);
76
+ return Object.fromEntries(parts.map((part) => {
77
+ if (!(part.name in given))
78
+ throw new ProposalError(`${name} is missing ${part.name}; give every field: ${parts.map((p) => p.name).join(", ")}`);
79
+ return [part.name, coerce(part, given[part.name])];
80
+ }));
81
+ }
82
+ throw fail(`an object with ${parts.map((p) => p.name).join(", ")}`);
83
+ }
84
+ throw fail(param.type);
85
+ }
86
+ const show = (value) => {
87
+ if (typeof value === "bigint")
88
+ return value.toString();
89
+ if (Array.isArray(value))
90
+ return `(${value.map(show).join(", ")})`;
91
+ if (typeof value === "object" && value !== null)
92
+ return `{${Object.entries(value).map(([k, v]) => `${k}: ${show(v)}`).join(", ")}}`;
93
+ return String(value);
94
+ };
95
+ function resolveTarget(network, target) {
96
+ const addresses = { board: network.contracts.taskBoard, executor: network.contracts.executor, registry: network.contracts.registry };
97
+ const named = TARGETS.find((t) => t === target || addresses[t].toLowerCase() === target.toLowerCase());
98
+ if (!named)
99
+ throw new ProposalError(`--target is board, executor or registry, not "${target}"`);
100
+ return { target: named, address: addresses[named] };
101
+ }
102
+ /**
103
+ * The call a proposal asks the executor to make, from --call and --args or
104
+ * from raw --data, refused here when the executor would refuse it anyway.
105
+ */
106
+ export function buildCall(network, proposer, input) {
107
+ const { target, address } = resolveTarget(network, input.target);
108
+ const abi = GOVERNED[target];
109
+ const offered = `The ${target} takes ${functions(target).join(", ")}`;
110
+ let data;
111
+ if (input.data !== undefined) {
112
+ if (input.call !== undefined || input.args !== undefined)
113
+ throw new ProposalError("Give either --data or --call with --args, not both");
114
+ if (!isHex(input.data) || input.data.length < 10)
115
+ throw new ProposalError("--data must be the call's hex calldata, starting 0x");
116
+ data = input.data.toLowerCase();
117
+ }
118
+ else {
119
+ if (!input.call)
120
+ throw new ProposalError(`--call is required: the function to call. ${offered}.`);
121
+ const fn = abi.find((item) => item.type === "function" && item.name === input.call);
122
+ if (!fn || fn.type !== "function")
123
+ throw new ProposalError(`${offered}, not ${input.call}. setGuardian and setOwner are never queued.`);
124
+ let args;
125
+ try {
126
+ args = JSON.parse(input.args ?? "[]");
127
+ }
128
+ catch {
129
+ throw new ProposalError(`--args must be a JSON array, such as '[300, "0x…"]'`);
130
+ }
131
+ if (!Array.isArray(args) || args.length !== fn.inputs.length) {
132
+ 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`);
133
+ }
134
+ data = encodeFunctionData({ abi, functionName: fn.name, args: fn.inputs.map((param, i) => coerce(param, args[i])) });
135
+ }
136
+ let decoded;
137
+ try {
138
+ decoded = decodeFunctionData({ abi, data });
139
+ }
140
+ catch {
141
+ throw new ProposalError(`${offered}; the selector ${data.slice(0, 10)} is none of them`);
142
+ }
143
+ if (encodeFunctionData({ abi, functionName: decoded.functionName, args: decoded.args }).toLowerCase() !== data) {
144
+ throw new ProposalError("--data has bytes beyond the call's arguments");
145
+ }
146
+ const args = decoded.args ?? [];
147
+ if (target === "board" && decoded.functionName === "setWorkerAllowed" && args[1] === true && String(args[0]).toLowerCase() === proposer.toLowerCase()) {
148
+ throw new ProposalError("Allowing your own wallet to work is an application: run join, which stakes and applies in one go");
149
+ }
150
+ return { target, address, data, text: `${decoded.functionName}(${args.map(show).join(", ")})` };
151
+ }
152
+ /** The proposal's URI: base64 JSON the brain shows Jev as the proposer's case. */
153
+ export function caseURI(call, input) {
154
+ const title = input.title.trim();
155
+ const rationale = input.rationale.trim();
156
+ if (!title)
157
+ throw new ProposalError(`--title is required, up to ${LIMITS.title} characters`);
158
+ if (!rationale)
159
+ throw new ProposalError(`--rationale is required: why the change is better, up to ${LIMITS.rationale} characters. Jev reads it to decide.`);
160
+ const body = { title, rationale, current: input.current?.trim() ?? "", proposed: input.proposed?.trim() || call.text };
161
+ for (const key of Object.keys(LIMITS)) {
162
+ if (body[key].length > LIMITS[key])
163
+ throw new ProposalError(`--${key} is at most ${LIMITS[key]} characters`);
164
+ }
165
+ return `data:application/json;base64,${Buffer.from(JSON.stringify(body)).toString("base64")}`;
166
+ }
167
+ const LOG_RANGE = 50000n;
168
+ export function createProposals(network, account) {
169
+ const chain = chainOf(network);
170
+ const transport = http(network.rpc, { timeout: 20_000, retryCount: 3 });
171
+ const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
172
+ const wallet = createWalletClient({ account, chain, transport });
173
+ const { bonds, executor, stakeToken } = network.contracts;
174
+ const proposals = { address: bonds, abi: [...bondsAbi, ...proposalsAbi] };
175
+ async function scan(fetch) {
176
+ const to = await client.getBlockNumber();
177
+ const all = [];
178
+ for (let start = network.fromBlock; start <= to; start += LOG_RANGE) {
179
+ all.push(...(await fetch(start, start + LOG_RANGE - 1n < to ? start + LOG_RANGE - 1n : to)));
180
+ }
181
+ return all;
182
+ }
183
+ async function send(request) {
184
+ const hash = await wallet.writeContract(request);
185
+ const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
186
+ if (receipt.status !== "success")
187
+ throw new Error(`Transaction ${hash} reverted`);
188
+ return receipt;
189
+ }
190
+ return {
191
+ account: account.address,
192
+ now: async () => (await client.getBlock()).timestamp,
193
+ ethBalance: () => client.getBalance({ address: account.address }),
194
+ stakeBalance: () => client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
195
+ bond: () => client.readContract({ ...proposals, functionName: "bond" }),
196
+ decideWithin: () => client.readContract({ ...proposals, functionName: "decideWithin" }),
197
+ changeThresholdBps: async () => BigInt(await client.readContract({ address: executor, abi: executorReadsAbi, functionName: "changeThresholdBps" })),
198
+ timelock: () => client.readContract({ address: executor, abi: executorAbi, functionName: "timelock" }),
199
+ vetoed: (call) => client.readContract({
200
+ address: executor,
201
+ abi: executorReadsAbi,
202
+ functionName: "vetoedCall",
203
+ args: [keccak256(encodeAbiParameters([{ type: "address" }, { type: "bytes" }], [call.address, call.data]))],
204
+ }),
205
+ async rehearse(call) {
206
+ try {
207
+ await client.call({ account: executor, to: call.address, data: call.data });
208
+ return undefined;
209
+ }
210
+ catch (error) {
211
+ return error instanceof BaseError ? error.shortMessage : String(error);
212
+ }
213
+ },
214
+ async propose(call, uri, bond) {
215
+ const allowance = await client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "allowance", args: [account.address, bonds] });
216
+ if (allowance < bond)
217
+ await send({ address: stakeToken, abi: erc20Abi, functionName: "approve", args: [bonds, bond] });
218
+ const receipt = await send({ ...proposals, functionName: "propose", args: [call.address, call.data, uri] });
219
+ const [log] = parseEventLogs({ abi: bondsAbi, eventName: "Proposed", logs: receipt.logs }).filter((l) => l.address.toLowerCase() === bonds.toLowerCase());
220
+ if (!log)
221
+ throw new Error(`The proposal landed in ${receipt.transactionHash} but logged no id`);
222
+ return { id: log.args.id, tx: receipt.transactionHash };
223
+ },
224
+ async get(id) {
225
+ const p = await client.readContract({ ...proposals, functionName: "get", args: [id] });
226
+ return { id, proposer: p.proposer, bond: p.bond, createdAt: p.createdAt, state: p.state };
227
+ },
228
+ async outcome(id) {
229
+ const [settled, expired] = await Promise.all([
230
+ scan((fromBlock, toBlock) => client.getContractEvents({ ...proposals, eventName: "Settled", args: { id }, fromBlock, toBlock })),
231
+ scan((fromBlock, toBlock) => client.getContractEvents({ ...proposals, eventName: "Expired", args: { id }, fromBlock, toBlock })),
232
+ ]);
233
+ if (settled[0])
234
+ return { decisionId: settled[0].args.decisionId, adopted: settled[0].args.adopted };
235
+ return expired[0] ? "expired" : undefined;
236
+ },
237
+ async queued(decisionId) {
238
+ const [log] = await scan((fromBlock, toBlock) => client.getContractEvents({ address: executor, abi: executorAbi, eventName: "Queued", args: { decisionId }, fromBlock, toBlock }));
239
+ if (!log)
240
+ return undefined;
241
+ const change = await client.readContract({ address: executor, abi: executorAbi, functionName: "getChange", args: [log.args.changeId] });
242
+ return { changeId: log.args.changeId, eta: change.eta, executed: change.executed, vetoed: change.vetoed };
243
+ },
244
+ };
245
+ }
246
+ const hours = (seconds) => `${Number(seconds) / 3600} hours`;
247
+ const days = (seconds) => `${Number(seconds) / 86_400} days`;
248
+ const percent = (bps) => `${Number(bps) / 100}%`;
249
+ export async function propose(network, chain, call, uri, options) {
250
+ const { log } = options;
251
+ const [bond, balance, gas, within, threshold, timelock, vetoed, revert] = await Promise.all([
252
+ chain.bond(),
253
+ chain.stakeBalance(),
254
+ chain.ethBalance(),
255
+ chain.decideWithin(),
256
+ chain.changeThresholdBps(),
257
+ chain.timelock(),
258
+ chain.vetoed(call),
259
+ chain.rehearse(call),
260
+ ]);
261
+ if (vetoed)
262
+ throw new ProposalError(`The guardian vetoed ${call.text} on the ${call.target} before, so the executor never queues it again`);
263
+ if (revert)
264
+ throw new ProposalError(`${call.text} would fail if the executor made it now (${revert}), so the bond would be burned for nothing`);
265
+ log(`Proposal on ${network.name}
266
+ Call ${call.text} on the ${call.target} (${call.address})
267
+ Data ${call.data}
268
+ Bond ${stake(network, bond)}, returned if Jev adopts the change with at least ${percent(threshold)} confidence, burned otherwise
269
+ Decision within ${days(within)}; an adopted change runs ${hours(timelock)} later`);
270
+ if (options.dry) {
271
+ log("Dry run, nothing sent.");
272
+ return;
273
+ }
274
+ if (balance < bond) {
275
+ const buy = network.buyStake ? ` ${network.buyStake}` : "";
276
+ throw new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, bond)} bond.${buy}`);
277
+ }
278
+ if (gas === 0n)
279
+ throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}; 0.001 ETH is plenty.`);
280
+ log(`Bonding ${stake(network, bond)} and proposing...`);
281
+ const { id, tx } = await chain.propose(call, uri, bond);
282
+ log(`Proposal ${id} is waiting for Jev (${tx}). Follow it with \`npx brainchain-worker proposal ${id}\`.`);
283
+ }
284
+ export async function proposal(network, chain, id, log) {
285
+ const p = await chain.get(id);
286
+ if (p.state === State.None)
287
+ throw new ProposalError(`There is no proposal ${id} on ${network.name}`);
288
+ const head = `Proposal ${id} by ${p.proposer}, bond ${stake(network, p.bond)}`;
289
+ if (p.state === State.Open) {
290
+ const [within, now] = await Promise.all([chain.decideWithin(), chain.now()]);
291
+ const closes = p.createdAt + within;
292
+ const after = now > closes ? `its window closed at ${when(closes)}, so anyone can return the bond with expire` : `Jev decides by ${when(closes)}`;
293
+ log(`${head}\n Waiting for Jev: ${after}.`);
294
+ return;
295
+ }
296
+ const outcome = await chain.outcome(id);
297
+ if (outcome === "expired") {
298
+ log(`${head}\n Not decided in its window; the bond was returned.`);
299
+ return;
300
+ }
301
+ if (outcome === undefined) {
302
+ log(`${head}\n ${p.state === State.Returned ? "Settled, bond returned" : "Settled, bond burned"}.`);
303
+ return;
304
+ }
305
+ if (!outcome.adopted) {
306
+ log(`${head}\n Jev kept the current setting (decision ${outcome.decisionId}); the bond was burned.`);
307
+ return;
308
+ }
309
+ const change = await chain.queued(outcome.decisionId);
310
+ const state = !change
311
+ ? "not queued yet"
312
+ : change.executed
313
+ ? `change ${change.changeId} has run`
314
+ : change.vetoed
315
+ ? `change ${change.changeId} was vetoed by the guardian`
316
+ : `change ${change.changeId} runs from ${when(change.eta)}`;
317
+ log(`${head}\n Jev adopted it (decision ${outcome.decisionId}) and the bond was returned; ${state}.`);
318
+ }
package/dist/standing.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import { formatUnits } from "viem";
2
2
  import { ApplicationState } from "./chain.js";
3
- export const stake = (network, amount) => `${formatUnits(amount, 18)} ${network.stakeSymbol}`;
3
+ /** A token amount with thousands separators, such as 1,250,000; a fraction is kept as it is. */
4
+ function grouped(amount, decimals) {
5
+ const [whole, fraction] = formatUnits(amount, decimals).split(".");
6
+ return `${whole.replace(/\B(?=(\d{3})+$)/g, ",")}${fraction === undefined ? "" : `.${fraction}`}`;
7
+ }
8
+ export const stake = (network, amount) => `${grouped(amount, 18)} ${network.stakeSymbol}`;
4
9
  export const usdg = (network, amount) => `${formatUnits(amount, 6)} ${network.bountySymbol}`;
5
10
  export const when = (seconds) => new Date(Number(seconds) * 1000).toISOString().replace(".000Z", "Z");
6
11
  /** The worker's standing with the board and the staking contract, and what join would do about it. */
@@ -47,7 +52,8 @@ export function hireable({ allowed, w, min, applications, change, pending }, net
47
52
  }
48
53
  /**
49
54
  * The wallet's stake token and, below the minimum stake, where join would find
50
- * what it needs. A stake token without a faucet has no `faucet`.
55
+ * what it needs. A stake token without a faucet has no `faucet`; one that is
56
+ * bought instead says where.
51
57
  */
52
58
  export function wallet(network, s, balance, bond, faucet, now) {
53
59
  const has = stake(network, balance);
@@ -60,7 +66,8 @@ export function wallet(network, s, balance, bond, faucet, now) {
60
66
  return `${has}; join needs ${stake(network, need)} and claims the rest from the faucet, which gives ${stake(network, faucet.amount)} a day`;
61
67
  const reopens = faucet ? `. The faucet opens again for this wallet at ${when(faucet.opensAt)}` : "";
62
68
  const unstaking = s.w.unstaking > 0n ? `, and the ${stake(network, s.w.unstaking)} unstaking can be withdrawn ${now >= s.w.unlocksAt ? "now" : `after ${when(s.w.unlocksAt)}`}` : "";
63
- return `${has}; join needs ${stake(network, need)}${reopens}${unstaking}`;
69
+ const buy = network.buyStake ? `. ${network.buyStake}` : "";
70
+ return `${has}; join needs ${stake(network, need)}${reopens}${unstaking}${buy}`;
64
71
  }
65
72
  /** What join says when the wallet holds too little of a stake token that has no faucet. */
66
73
  export function shortOfStake(network, w, balance, need, now) {
package/dist/state.js CHANGED
@@ -126,8 +126,8 @@ export function lock(file) {
126
126
  }
127
127
  /**
128
128
  * Moves the state 0.3.0 and earlier kept per address alone to `file`, once,
129
- * when it was kept for `board`. State for the other network stays where it is
130
- * until a run there moves it.
129
+ * when it was kept for `board`, and says whether it did. State for the other
130
+ * network stays where it is until a run there moves it.
131
131
  */
132
132
  function migrate(old, file, board) {
133
133
  let text;
@@ -136,30 +136,32 @@ function migrate(old, file, board) {
136
136
  }
137
137
  catch (error) {
138
138
  if (error.code === "ENOENT")
139
- return;
139
+ return false;
140
140
  throw error;
141
141
  }
142
142
  if (parse(text)?.board !== board.toLowerCase())
143
- return;
143
+ return false;
144
144
  // A run from before the move may still be working on this network's tasks from the old file.
145
145
  const pid = holder(`${old}.lock`);
146
146
  if (alive(pid))
147
147
  throw held(pid, `${old}.lock`);
148
148
  if (existsSync(`${file}.json`))
149
- return;
149
+ return false;
150
150
  // Removed rather than moved: this run already holds the new lock.
151
151
  rmSync(`${old}.lock`, { force: true });
152
152
  renameSync(`${old}.json`, `${file}.json`);
153
+ return true;
153
154
  }
154
155
  /**
155
156
  * Takes the worker's lock on one network and loads its saved state. Each
156
157
  * network has its own `<address>.<network>.json` and `.lock`, so one key can
157
158
  * run on testnet and mainnet at once without sharing cursors or tasks.
158
159
  */
159
- export function openStore(address, network, warn, dir = stateDir()) {
160
+ export function openStore(address, network, log = console.log, dir = stateDir()) {
160
161
  const old = join(dir, address);
161
162
  const file = `${old}.${network.id}`;
162
163
  lock(`${file}.lock`);
163
- migrate(old, file, network.contracts.taskBoard);
164
- return fileStore(`${file}.json`, network.contracts.taskBoard, warn);
164
+ if (migrate(old, file, network.contracts.taskBoard))
165
+ log(`moved ${old}.json to ${file}.json`);
166
+ return fileStore(`${file}.json`, network.contracts.taskBoard, (message) => log(`warning: ${message}`));
165
167
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-worker",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
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.",
5
5
  "license": "MIT",
6
6
  "author": "BrainChain <hello@usebrainchain.com>",