brainchain-worker 0.18.3 → 0.19.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/dist/solana.js CHANGED
@@ -1,21 +1,43 @@
1
1
  // `fund --from-solana`: an agent that lives on Solana funds its BrainChain worker in one command. Relay turns its USDC
2
2
  // on Solana into ETH (and, with --stake, BRAIN) for the worker's wallet on Robinhood Chain in about a second, signed by
3
- // the agent's Solana key; `bridge` then moves gas and stake on to BrainChain. A little ETH stays on Robinhood Chain for
4
- // the worker's own transactions there, such as an exit to Solana.
3
+ // the agent's Solana key; `bridge` then moves on to BrainChain what this run brought. A little ETH stays on Robinhood
4
+ // Chain for the worker's own transactions there.
5
5
  import { readFileSync } from "node:fs";
6
6
  import bs58 from "bs58";
7
- import { Connection, Keypair, PublicKey, TransactionInstruction, TransactionMessage, VersionedTransaction } from "@solana/web3.js";
8
- import { formatUnits, parseUnits } from "viem";
9
- import { checkReturn, NATIVE, relayQuote, SOLANA_CHAIN_ID, SOLANA_USDC, waitForRelay } from "./relay.js";
7
+ import { Connection, Keypair, PublicKey, SendTransactionError, TransactionInstruction, TransactionMessage, VersionedTransaction } from "@solana/web3.js";
8
+ import { formatEther, formatUnits, parseUnits } from "viem";
9
+ import { reason } from "./chain.js";
10
+ import { expandHome } from "./key.js";
11
+ import { checkReturn, MAX_LOSS, MAX_STAKE_LOSS, NATIVE, RELAY_API, RelayError, relayQuote, SOLANA_CHAIN_ID, SOLANA_USDC, waitForRelay } from "./relay.js";
10
12
  export const SOLANA_RPC = "https://api.mainnet-beta.solana.com";
11
13
  /** USDC turned into ETH for gas, unless --gas-usdc says otherwise. */
12
14
  export const DEFAULT_GAS_USDC = "2";
13
15
  /** ETH left on Robinhood Chain when the rest goes to BrainChain: enough for a token deposit and a few transactions there. */
14
16
  export const PARENT_ETH_KEEP = 300000000000000n;
17
+ /** How often a balance is read after Relay reports a move settled, and how far apart: the chain's RPC can lag Relay's word. */
18
+ const ARRIVAL_READS = 10;
19
+ const ARRIVAL_WAIT_MS = 3_000;
20
+ /** Relay's depository program, the instruction that deposits a token into it, and the accounts it keeps. */
21
+ export const RELAY_DEPOSITORY = "99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2";
22
+ const DEPOSIT_TOKEN = "0b9c60da27a3b413";
23
+ export const DEPOSITORY_STATE = "Dodg2HifwU8rmaVVyMyUZDGTRbqAJTyVYxXPwcbNpBKc";
24
+ export const DEPOSITORY_VAULT = "7uTT8Xi5RWXzy7h9XL244GRgEycDYDhLjr3ZyNdXi8pZ";
25
+ export const DEPOSITORY_VAULT_USDC = "4nvJ5zWdVspxJiNZzB127U6amPH98SFFkBx2JZrAduia";
26
+ export const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
27
+ export const ASSOCIATED_TOKEN_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
28
+ const SYSTEM_PROGRAM = "11111111111111111111111111111111";
29
+ /** A wallet's associated token account for USDC. */
30
+ export function usdcAccount(owner) {
31
+ const seeds = [new PublicKey(owner).toBuffer(), new PublicKey(TOKEN_PROGRAM).toBuffer(), new PublicKey(SOLANA_USDC).toBuffer()];
32
+ return PublicKey.findProgramAddressSync(seeds, new PublicKey(ASSOCIATED_TOKEN_PROGRAM))[0].toBase58();
33
+ }
15
34
  /** The Solana key from BRAINCHAIN_SOLANA_KEY (base58) or BRAINCHAIN_SOLANA_KEY_FILE (a Solana CLI keypair file). */
16
35
  export function solanaKeypair(env = process.env) {
17
36
  const file = env.BRAINCHAIN_SOLANA_KEY_FILE?.trim();
18
- const raw = file ? readFileSync(file, "utf8").trim() : env.BRAINCHAIN_SOLANA_KEY?.trim();
37
+ const inline = env.BRAINCHAIN_SOLANA_KEY?.trim();
38
+ if (file && inline)
39
+ throw new Error("Set BRAINCHAIN_SOLANA_KEY or BRAINCHAIN_SOLANA_KEY_FILE, not both");
40
+ const raw = file ? readKeyFile(file) : inline;
19
41
  if (!raw)
20
42
  throw new Error("Set BRAINCHAIN_SOLANA_KEY to the Solana wallet's secret key (base58), or BRAINCHAIN_SOLANA_KEY_FILE to its keypair file");
21
43
  try {
@@ -26,15 +48,70 @@ export function solanaKeypair(env = process.env) {
26
48
  throw new Error("The Solana key must be a 64-byte secret key, in base58 or as a keypair file's JSON array");
27
49
  }
28
50
  }
29
- /** How a USDC amount splits between gas and stake. */
51
+ function readKeyFile(path) {
52
+ const file = expandHome(path);
53
+ try {
54
+ return readFileSync(file, "utf8").trim();
55
+ }
56
+ catch (error) {
57
+ throw new Error(`BRAINCHAIN_SOLANA_KEY_FILE names ${file}, which could not be read (${error.code ?? "unknown error"})`);
58
+ }
59
+ }
60
+ /** How a USDC amount splits between gas and stake. Without --stake, what gas does not take stays on Solana. */
30
61
  export function split(usdc, gasUsdc, stake) {
31
62
  if (!stake)
32
- return { gas: usdc, stake: 0n };
63
+ return { gas: usdc < gasUsdc ? usdc : gasUsdc, stake: 0n };
33
64
  if (usdc <= gasUsdc)
34
65
  throw new Error(`With --stake the amount must be more than the ${formatUnits(gasUsdc, 6)} USDC kept for gas`);
35
66
  return { gas: gasUsdc, stake: usdc - gasUsdc };
36
67
  }
37
- /** Sends Relay's Solana deposit: its instructions, compiled against its lookup tables, signed by the agent's key. */
68
+ /**
69
+ * What is wrong with a quote's Solana side, or undefined when it is the one
70
+ * thing this command signs: one deposit into Relay's depository, of the USDC
71
+ * asked for, from this wallet's own token account, paying the worker on the
72
+ * parent chain. Every field a real quote carries is checked, so anything else
73
+ * Relay's API answers is refused.
74
+ */
75
+ export function depositProblem(quote, want) {
76
+ try {
77
+ return readDeposit(quote, want);
78
+ }
79
+ catch {
80
+ return "it is in a form this command cannot read";
81
+ }
82
+ }
83
+ function readDeposit(quote, want) {
84
+ const [step, ...others] = quote.steps;
85
+ if (step === undefined || others.length > 0 || step.id !== "deposit" || step.items?.length !== 1)
86
+ return "it is not one deposit";
87
+ const { instructions } = step.items[0].data;
88
+ if (!Array.isArray(instructions) || instructions.length !== 1)
89
+ return `it carries ${Array.isArray(instructions) ? instructions.length : "no"} instructions where a deposit has one`;
90
+ const { programId, data, keys } = instructions[0];
91
+ if (programId !== RELAY_DEPOSITORY)
92
+ return `it calls the program ${programId}, not Relay's depository`;
93
+ const bytes = Buffer.from(String(data).replace(/^0x/, ""), "hex");
94
+ if (bytes.length !== 48 || bytes.subarray(0, 8).toString("hex") !== DEPOSIT_TOKEN)
95
+ return "it calls Relay's depository for something other than a token deposit";
96
+ const amount = bytes.readBigUInt64LE(8);
97
+ if (amount !== want.amount)
98
+ return `it deposits ${formatUnits(amount, 6)} USDC, not the ${formatUnits(want.amount, 6)} asked for`;
99
+ const accounts = [DEPOSITORY_STATE, want.owner, want.owner, DEPOSITORY_VAULT, SOLANA_USDC, usdcAccount(want.owner), DEPOSITORY_VAULT_USDC, TOKEN_PROGRAM, ASSOCIATED_TOKEN_PROGRAM, SYSTEM_PROGRAM];
100
+ if (!Array.isArray(keys) || keys.length !== accounts.length)
101
+ return `it names ${Array.isArray(keys) ? keys.length : "no"} accounts where a deposit names ${accounts.length}`;
102
+ const wrong = accounts.findIndex((account, i) => keys[i].pubkey !== account);
103
+ if (wrong >= 0)
104
+ return `its account ${wrong} is ${keys[wrong].pubkey}, not ${accounts[wrong]}`;
105
+ if (keys.some((key, i) => key.isSigner !== (i === 1)))
106
+ return "it asks another account to sign";
107
+ if (quote.recipient?.toLowerCase() !== want.recipient.toLowerCase())
108
+ return `it pays ${quote.recipient ?? "no named recipient"}, not ${want.recipient}`;
109
+ if (quote.outCurrency?.toLowerCase() !== want.currency.toLowerCase() || quote.outChainId !== want.chainId)
110
+ return "it does not pay the currency asked for on Robinhood Chain";
111
+ return undefined;
112
+ }
113
+ const notConfirmed = (signature, why) => new Error(`the Solana transaction ${signature} could not be confirmed (${reason(why)}). Check it before running fund again: if it landed, its USDC left the wallet, and another run moves USDC again.`);
114
+ /** Sends Relay's Solana deposit: its instructions, compiled against its lookup tables, signed by the agent's key. Each failure says whether the USDC moved. */
38
115
  export async function sendSvmStep(connection, keypair, item) {
39
116
  const instructions = item.instructions.map((i) => new TransactionInstruction({
40
117
  programId: new PublicKey(i.programId),
@@ -51,12 +128,31 @@ export async function sendSvmStep(connection, keypair, item) {
51
128
  const message = new TransactionMessage({ payerKey: keypair.publicKey, recentBlockhash: blockhash, instructions }).compileToV0Message(tables);
52
129
  const transaction = new VersionedTransaction(message);
53
130
  transaction.sign([keypair]);
54
- const signature = await connection.sendTransaction(transaction, { maxRetries: 3 });
55
- const confirmed = await connection.confirmTransaction({ signature, blockhash, lastValidBlockHeight }, "confirmed");
131
+ // A signed transaction's first signature is its id, known whether or not the send comes back.
132
+ const signature = bs58.encode(transaction.signatures[0]);
133
+ try {
134
+ await connection.sendTransaction(transaction, { maxRetries: 3 });
135
+ }
136
+ catch (error) {
137
+ if (error instanceof SendTransactionError)
138
+ throw new Error(`Solana refused the deposit, so its USDC did not move: ${error.message.split("\n")[0]}`);
139
+ throw notConfirmed(signature, error);
140
+ }
141
+ const confirmed = await connection.confirmTransaction({ signature, blockhash, lastValidBlockHeight }, "confirmed").catch((error) => {
142
+ throw notConfirmed(signature, error);
143
+ });
56
144
  if (confirmed.value.err)
57
- throw new Error(`the Solana transaction failed: ${JSON.stringify(confirmed.value.err)}`);
145
+ throw new Error(`the Solana transaction ${signature} failed, so its USDC did not move: ${JSON.stringify(confirmed.value.err)}`);
58
146
  return signature;
59
147
  }
148
+ /** What to say about a deposit Relay has not filled, or undefined once it has. Only `refund` says the USDC is back; `failure` is usually not final, and `pending` is a wait that ran out. */
149
+ export function unsettled(status, requestId, signature, owner) {
150
+ if (status === "success")
151
+ return undefined;
152
+ if (status === "refund")
153
+ return `Relay reports a refund for request ${requestId}: the USDC of Solana transaction ${signature} goes back to ${owner}. Check the wallet before running fund again.`;
154
+ return `Relay has not settled request ${requestId} (it reports "${status}"). The USDC left the wallet in Solana transaction ${signature}. Check ${RELAY_API}/intents/status/v2?requestId=${requestId} before running fund again: another run moves USDC again.`;
155
+ }
60
156
  export function createSolanaFunding(keypair, parentChainId, rpc = SOLANA_RPC) {
61
157
  const connection = new Connection(rpc, "confirmed");
62
158
  const owner = keypair.publicKey.toBase58();
@@ -71,38 +167,50 @@ export function createSolanaFunding(keypair, parentChainId, rpc = SOLANA_RPC) {
71
167
  },
72
168
  async preview(currency, amount, recipient) {
73
169
  const quote = await relayQuote({ user: owner, recipient, originChainId: SOLANA_CHAIN_ID, destinationChainId: parentChainId, originCurrency: SOLANA_USDC, destinationCurrency: currency, amount });
74
- checkReturn(quote);
170
+ checkReturn(quote, currency === NATIVE ? MAX_LOSS : MAX_STAKE_LOSS);
171
+ const problem = depositProblem(quote, { owner, amount, recipient, currency, chainId: parentChainId });
172
+ if (problem)
173
+ throw new RelayError(`Relay's quote was refused before signing: ${problem}. Nothing was sent.`);
75
174
  return quote;
76
175
  },
77
- async move(currency, amount, recipient) {
78
- const quote = await this.preview(currency, amount, recipient);
79
- let signature = "";
80
- for (const step of quote.steps)
81
- for (const item of step.items)
82
- signature = await sendSvmStep(connection, keypair, item.data);
83
- const status = await waitForRelay(quote.requestId);
84
- if (status !== "success")
85
- throw new Error(`Relay reports "${status}" for request ${quote.requestId}; a refund comes back to the Solana wallet`);
86
- return { quote, signature };
176
+ async send(quote) {
177
+ const signature = await sendSvmStep(connection, keypair, quote.steps[0].items[0].data);
178
+ const problem = unsettled(await waitForRelay(quote.requestId), quote.requestId, signature, owner);
179
+ if (problem)
180
+ throw new Error(problem);
181
+ return signature;
87
182
  },
88
183
  };
89
184
  }
90
185
  /** The least SOL the wallet needs to pay Solana's fees for the deposits. */
91
186
  export const MIN_LAMPORTS = 2000000n;
187
+ /** What a move added to a balance, no more than was quoted, so a deposit from elsewhere stays out of it. */
188
+ async function arrival(read, before, quoted, sleep) {
189
+ let held = await read();
190
+ for (let tries = 1; held <= before && tries < ARRIVAL_READS; tries++) {
191
+ await sleep(ARRIVAL_WAIT_MS);
192
+ held = await read();
193
+ }
194
+ const rise = held > before ? held - before : 0n;
195
+ return { held, brought: rise < quoted ? rise : quoted };
196
+ }
92
197
  export async function fundFromSolana(options) {
93
198
  const { funding, log } = options;
199
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
94
200
  const usdc = parseUnits(options.usdc, 6);
95
201
  const gasUsdc = parseUnits(options.gasUsdc ?? DEFAULT_GAS_USDC, 6);
96
202
  const parts = split(usdc, gasUsdc, options.stake);
97
203
  if (options.stake && !options.stakeToken)
98
204
  throw new Error("This network's stake token is not on the parent chain");
205
+ const spends = parts.gas + parts.stake;
99
206
  const [held, lamports] = await Promise.all([funding.usdcBalance(), funding.solBalance()]);
100
- if (held < usdc)
101
- throw new Error(`The Solana wallet ${funding.owner} holds ${formatUnits(held, 6)} USDC, less than ${options.usdc}`);
207
+ if (held < spends)
208
+ throw new Error(`The Solana wallet ${funding.owner} holds ${formatUnits(held, 6)} USDC, less than ${formatUnits(spends, 6)}`);
102
209
  if (lamports < MIN_LAMPORTS)
103
210
  throw new Error(`The Solana wallet ${funding.owner} needs about 0.002 SOL for Solana's fees; it holds ${formatUnits(lamports, 9)}`);
104
- log(`From ${funding.owner} on Solana: ${formatUnits(parts.gas, 6)} USDC to ETH for gas${parts.stake ? ` and ${formatUnits(parts.stake, 6)} USDC to the stake token` : ""}, for ${options.worker}.`);
105
- // Both legs are priced before either moves, so a refused stake leg never leaves the gas leg spent alone.
211
+ const stays = usdc > spends ? ` The other ${formatUnits(usdc - spends, 6)} USDC stays on Solana.` : "";
212
+ log(`From ${funding.owner} on Solana: ${formatUnits(parts.gas, 6)} USDC to ETH for gas${parts.stake ? ` and ${formatUnits(parts.stake, 6)} USDC to the stake token` : ""}, for ${options.worker}.${stays}`);
213
+ // Both legs are priced and checked before either moves, and those same quotes are signed, so a refused leg never leaves the other spent alone.
106
214
  const gasQuote = await funding.preview(NATIVE, parts.gas, options.worker);
107
215
  const stakeQuote = parts.stake ? await funding.preview(options.stakeToken, parts.stake, options.worker) : undefined;
108
216
  log(`Relay quotes ${gasQuote.outFormatted} ETH${stakeQuote ? ` and ${stakeQuote.outFormatted} of the stake token` : ""}.`);
@@ -110,15 +218,48 @@ export async function fundFromSolana(options) {
110
218
  log("Dry run, nothing sent.");
111
219
  return;
112
220
  }
113
- const gas = await funding.move(NATIVE, parts.gas, options.worker);
114
- log(`ETH arrived on the parent chain: ${gas.quote.outFormatted} ETH (Solana ${gas.signature}).`);
115
- if (parts.stake) {
116
- const stake = await funding.move(options.stakeToken, parts.stake, options.worker);
117
- log(`Stake token arrived on the parent chain: ${stake.quote.outFormatted} (Solana ${stake.signature}).`);
221
+ // Read before anything moves: only what this run adds goes on to BrainChain, never what the wallet already held.
222
+ const readStake = () => (stakeQuote ? options.parentStake() : Promise.resolve(0n));
223
+ const [ethBefore, stakeBefore] = await Promise.all([options.parentEth(), readStake()]).catch((error) => {
224
+ throw new Error(`Could not read the worker's balances on Robinhood Chain, so what this run brings could not be told from what it holds: ${reason(error).replace(/\.$/, "")}. Nothing was sent.`);
225
+ });
226
+ const onParent = new Map();
227
+ try {
228
+ const gas = await funding.send(gasQuote);
229
+ onParent.set("eth", `${gasQuote.outFormatted} ETH`);
230
+ log(`ETH arrived on Robinhood Chain: ${gasQuote.outFormatted} ETH (Solana ${gas}).`);
231
+ if (stakeQuote) {
232
+ const stake = await funding.send(stakeQuote);
233
+ onParent.set("stake", `${stakeQuote.outFormatted} of the stake token`);
234
+ log(`Stake token arrived on Robinhood Chain: ${stakeQuote.outFormatted} (Solana ${stake}).`);
235
+ }
236
+ const eth = await arrival(options.parentEth, ethBefore, gasQuote.outAmount, sleep);
237
+ const stake = stakeQuote ? await arrival(options.parentStake, stakeBefore, stakeQuote.outAmount, sleep) : undefined;
238
+ if (eth.brought === 0n || stake?.brought === 0n) {
239
+ const holds = `${formatEther(eth.held)} ETH${stake ? ` and ${formatEther(stake.held)} of the stake token` : ""}`;
240
+ const had = `${formatEther(ethBefore)} ETH${stake ? ` and ${formatEther(stakeBefore)}` : ""}`;
241
+ throw new Error(`Relay reports the move settled, but the wallet on Robinhood Chain shows no increase, so what this run brought cannot be told from what it held. It holds ${holds}, and held ${had} before. Nothing was bridged.`);
242
+ }
243
+ // The stake goes first: its deposit pays for its arrival in ETH on the parent chain, out of what the ETH bridge would take.
244
+ if (stake) {
245
+ await options.bridgeOn("stake", stake.brought);
246
+ onParent.delete("stake");
247
+ }
248
+ const left = await options.parentEth();
249
+ const spent = eth.held > left ? eth.held - left : 0n;
250
+ const ours = eth.brought > spent ? eth.brought - spent : 0n;
251
+ const room = left > PARENT_ETH_KEEP ? left - PARENT_ETH_KEEP : 0n;
252
+ const onward = ours < room ? ours : room;
253
+ if (onward === 0n)
254
+ log("Not enough ETH to send on to BrainChain beyond what stays for gas on Robinhood Chain.");
255
+ else
256
+ await options.bridgeOn("eth", onward);
257
+ }
258
+ catch (error) {
259
+ const moved = [...onParent.values()].join(" and ");
260
+ if (!moved)
261
+ throw new Error(reason(error));
262
+ throw new Error(`${reason(error).replace(/\.?$/, ".")} This run already brought ${moved} to ${options.worker} on Robinhood Chain; \`brainchain-worker bridge\` moves that on to BrainChain, and running fund again moves more USDC.`);
118
263
  }
119
- // The stake goes first: its deposit pays for its arrival in ETH on the parent chain, which the ETH bridge would take.
120
- if (parts.stake)
121
- await options.bridgeOn("stake");
122
- await options.bridgeOn("eth");
123
264
  log("Done. `brainchain-worker status` shows the balances on BrainChain once the deposits arrive, in about a minute.");
124
265
  }
package/dist/state.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
2
+ import { copyFileSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
3
3
  import { homedir, hostname } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { startTime } from "./proc.js";
@@ -82,6 +82,19 @@ export function fileStore(file, board, warn = console.warn) {
82
82
  const saved = text === undefined ? undefined : parse(text);
83
83
  if (saved?.board === state.board)
84
84
  state = saved;
85
+ else if (saved) {
86
+ // Task ids restart with each board, so none of this can carry over, but it must not vanish in silence.
87
+ const left = [
88
+ [Object.keys(saved.tasks).length, "tasks with results"],
89
+ [saved.hired?.tasks.length ?? 0, "hired tasks"],
90
+ [Object.keys(saved.proofs ?? {}).length, "tasks waiting to settle or mint"],
91
+ ].flatMap(([count, what]) => (count ? [`${count} ${what}`] : []));
92
+ if (left.length > 0) {
93
+ const aside = `${file}.${saved.board}`;
94
+ copyFileSync(file, aside);
95
+ warn(`${file} holds work for the task board ${saved.board}, not this one: ${left.join(", ")}. This run starts without it, and a copy is at ${aside}.`);
96
+ }
97
+ }
85
98
  if (text !== undefined && saved === undefined) {
86
99
  // Refusing to start would turn one bad file into a crash loop under a supervisor.
87
100
  const aside = `${file}.corrupt-${new Date().toISOString().replace(/\.\d+Z$/, "Z").replace(/[-:]/g, "")}`;
package/dist/watch.js CHANGED
@@ -2,9 +2,11 @@ import { concat, createPublicClient, createWalletClient, keccak256, parseAbi, to
2
2
  import { brainchainTransport, executorAbi, reason } from "./chain.js";
3
3
  import { chainOf } from "./network.js";
4
4
  /**
5
- * The constitution's floors, by the function a change calls. A change that
6
- * crosses one is what the guardian's veto is for. Values in the units the
7
- * contracts take: basis points, seconds, and USDG in 6 decimals.
5
+ * The calls the constitution's monitor knows, by the function a change calls.
6
+ * A change that crosses a floor is what the guardian's veto is for, and any
7
+ * other call counts as crossing one. Three known calls have no floor, and say
8
+ * what they do instead. Values in the units the contracts take: basis points,
9
+ * seconds, and USDG in 6 decimals.
8
10
  */
9
11
  export const FLOORS = {
10
12
  "0x85327a44": {
@@ -32,9 +34,9 @@ export const FLOORS = {
32
34
  name: "setModel",
33
35
  check: (w) => [w[1] === 0n && "a house model with no account", w[2] > 5000000n && "a house model priced above 5 USDG"].filter((r) => typeof r === "string"),
34
36
  },
35
- "0x16c38b3c": { name: "setPaused", check: () => [] },
36
- "0x1f1e4d9f": { name: "setWorkerAllowed", check: () => [] },
37
- "0xa9059cbb": { name: "transfer", check: () => [] },
37
+ "0x16c38b3c": { name: "setPaused", check: () => [], does: "pauses or resumes new posts" },
38
+ "0x1f1e4d9f": { name: "setWorkerAllowed", check: () => [], does: "allows or removes a worker" },
39
+ "0xa9059cbb": { name: "transfer", check: () => [], does: "moves house treasury funds" },
38
40
  };
39
41
  /** The static words after the selector; a call with dynamic arguments is read as far as its words go. */
40
42
  export function argumentsOf(data) {
@@ -44,13 +46,13 @@ export function argumentsOf(data) {
44
46
  words.push(BigInt(`0x${body.slice(i, i + 64)}`));
45
47
  return words;
46
48
  }
47
- /** What a queued change does to the constitution: nothing, or the floors it crosses. */
49
+ /** What a queued change does to the constitution: nothing, the floors it crosses, or, for a known call with no floor, what it does. */
48
50
  export function judge(data) {
49
51
  const selector = data.slice(0, 10).toLowerCase();
50
52
  const floor = FLOORS[selector];
51
53
  if (!floor)
52
- return { name: selector, broken: ["a call the constitution does not list"] };
53
- return { name: floor.name, broken: floor.check(argumentsOf(data)) };
54
+ return { name: selector, broken: ["a call the monitor does not know, which counts as breaking a floor"] };
55
+ return { name: floor.name, broken: floor.check(argumentsOf(data)), ...(floor.does ? { does: floor.does } : {}) };
54
56
  }
55
57
  const gateAbi = parseAbi(["function veto(uint256 changeId, string reason)", "function watcher() view returns (address)"]);
56
58
  const logAbi = parseAbi(["function count() view returns (uint256)"]);
@@ -108,10 +110,53 @@ export async function requireWatcher(chain, log) {
108
110
  throw new Error(`This key cannot veto: the gate's watcher is ${watcher}, not ${chain.account}. Set BRAINCHAIN_WORKER_KEY to the watcher's key, or run watch without --veto.`);
109
111
  }
110
112
  }
113
+ /** How long after a decision was recorded a record that cannot be read may hold a round's place. */
114
+ export const UNREADABLE_MS = 86_400_000;
115
+ /**
116
+ * A record that cannot be read stops the round and holds its place, so a host's
117
+ * outage costs no check. Only for a day from the decision's block time: one dead
118
+ * address would otherwise blind a watcher to every decision after it, so past
119
+ * that it is a finding, reported once, and the round moves on.
120
+ */
121
+ function unreadable(why, recordedAt, now) {
122
+ if (recordedAt === undefined || !(now - Date.parse(recordedAt) >= UNREADABLE_MS))
123
+ throw new Error(why);
124
+ return { findings: [`the record could not be read a day or more after it was recorded: ${why}`] };
125
+ }
126
+ /**
127
+ * What one decision's checks come to. A check that failed is a finding, whatever
128
+ * else could not be read. A check that could not run says nothing about the
129
+ * decision, so it follows the rule for an unreadable record. A record served
130
+ * without its work leaves its hash checks unrun, which is neither a finding nor a
131
+ * verified decision: `withheld` names them. With `refetch`, a verified decision
132
+ * comes with the record's bytes as served, for the report, and a failure to fetch
133
+ * them follows the same rule.
134
+ */
135
+ export async function outcome(report, options = {}) {
136
+ const now = options.now ?? Date.now();
137
+ const said = (status) => report.checks.filter((c) => c.status === status).map((c) => `${c.name}: ${c.message}`);
138
+ const findings = said("fail");
139
+ if (findings.length > 0)
140
+ return { findings };
141
+ const unread = said("error");
142
+ if (unread.length > 0)
143
+ return unreadable(unread.join("; "), report.recordedAt, now);
144
+ if (report.withheld)
145
+ return { findings, withheld: report.checks.filter((c) => c.status === "skip" && c.name.endsWith("-hash")).map((c) => c.name) };
146
+ if (!options.refetch || !report.payloadURI)
147
+ return { findings };
148
+ try {
149
+ return { findings, record: await options.refetch(report.payloadURI) };
150
+ }
151
+ catch (error) {
152
+ return unreadable(reason(error), report.recordedAt, now);
153
+ }
154
+ }
111
155
  /** The service's prefix on a report; the worker signs the prefix and the report's JSON text. */
112
156
  export const REPORT_PREFIX = "BrainChain watchtower report\n";
113
- /** A report lists at most this many decisions checked. */
157
+ /** A report lists at most this many decisions checked and this many findings; a longer round sends several. */
114
158
  export const MAX_CHECKED = 200;
159
+ export const MAX_FINDINGS = 50;
115
160
  /** The proof a record was fetched: the hash of its bytes followed by the worker's address, which nothing on chain gives away. */
116
161
  export const fetchProof = (bytes, worker) => keccak256(concat([bytes, toBytes(worker)]));
117
162
  /** The service answered and will not take this report, so sending it again would not help. */
@@ -123,14 +168,17 @@ export class ReportRefused extends Error {
123
168
  * Resolves to the line to log.
124
169
  */
125
170
  export function createReporter(network, account, fetcher = fetch) {
126
- return async (checked, findings) => {
171
+ // The service takes a report only if it was signed later than the last it took, by the second.
172
+ let lastAt = 0;
173
+ async function send(checked, findings) {
174
+ lastAt = Math.max(Math.floor(Date.now() / 1000), lastAt + 1);
127
175
  const report = {
128
176
  worker: account.address,
129
177
  chainId: network.chainId,
130
178
  log: network.contracts.log,
131
- at: Math.floor(Date.now() / 1000),
132
- checked: checked.slice(0, MAX_CHECKED).map((c) => ({ id: Number(c.id), proof: c.proof })),
133
- findings: findings.slice(0, 50).map((f) => ({ id: Number(f.id), checks: f.checks.slice(0, 20).map((c) => c.slice(0, 400)) })),
179
+ at: lastAt,
180
+ checked: checked.map((c) => ({ id: Number(c.id), proof: c.proof })),
181
+ findings: findings.map((f) => ({ id: Number(f.id), checks: f.checks.slice(0, 20).map((c) => c.slice(0, 400)) })),
134
182
  };
135
183
  const text = JSON.stringify(report);
136
184
  const signature = await account.signMessage({ message: `${REPORT_PREFIX}${text}` });
@@ -148,6 +196,13 @@ export function createReporter(network, account, fetcher = fetch) {
148
196
  return body.mark
149
197
  ? `reported to ${network.name}: ${body.checked} decisions re-verified in all, watchtower mark since ${body.mark.since}`
150
198
  : `reported to ${network.name}: ${body.checked} of the ${body.markAfter} re-verified decisions that earn the watchtower mark`;
199
+ }
200
+ return async (checked, findings) => {
201
+ let line = await send(checked.slice(0, MAX_CHECKED), findings.slice(0, MAX_FINDINGS));
202
+ for (let i = 1; MAX_CHECKED * i < checked.length || MAX_FINDINGS * i < findings.length; i++) {
203
+ line = await send(checked.slice(MAX_CHECKED * i, MAX_CHECKED * (i + 1)), findings.slice(MAX_FINDINGS * i, MAX_FINDINGS * (i + 1)));
204
+ }
205
+ return line;
151
206
  };
152
207
  }
153
208
  /**
@@ -176,7 +231,7 @@ export function createWatch(options) {
176
231
  const key = change.changeId.toString();
177
232
  if (judged.has(key))
178
233
  continue;
179
- const { name, broken } = judge(change.data);
234
+ const { name, broken, does } = judge(change.data);
180
235
  // A failed read leaves the change unjudged and the block range unread, so the next round comes back to it.
181
236
  const open = await chain.open(change.changeId);
182
237
  judged.add(key);
@@ -185,9 +240,9 @@ export function createWatch(options) {
185
240
  log(`change ${change.changeId}: ${name} on ${change.target}${broken.length > 0 ? ` broke the constitution (${why})` : ""}; closed, it has run or was vetoed`);
186
241
  continue;
187
242
  }
188
- const runsAt = new Date(Number(change.eta) * 1000).toISOString().replace("T", " ").slice(0, 16);
243
+ const runsAt = new Date(Number(change.eta) * 1000).toISOString().replace("T", " ").slice(0, 19);
189
244
  if (broken.length === 0) {
190
- log(`change ${change.changeId}: ${name} on ${change.target}, within the constitution, runs after ${runsAt} UTC`);
245
+ log(`change ${change.changeId}: ${name} on ${change.target}${does ? ` ${does}, which no floor covers` : ", within the constitution"}, runs after ${runsAt} UTC`);
191
246
  continue;
192
247
  }
193
248
  findings += 1;
@@ -239,8 +294,11 @@ export function createWatch(options) {
239
294
  let reached = seenDecision;
240
295
  for (let id = seenDecision + 1n; id <= count; id++) {
241
296
  try {
242
- const { findings: wrong, record } = await verify(id);
243
- if (wrong.length === 0) {
297
+ const { findings: wrong, record, withheld } = await verify(id);
298
+ if (withheld) {
299
+ log(`decision ${id} withheld: its record is served without the work, so ${withheld.join(" and ")} did not run; the checks that ran agree with the chain`);
300
+ }
301
+ else if (wrong.length === 0) {
244
302
  log(`decision ${id} verified`);
245
303
  if (record)
246
304
  checked.push({ id, proof: fetchProof(record, chain.account) });
@@ -266,11 +324,10 @@ export function createWatch(options) {
266
324
  }
267
325
  catch (error) {
268
326
  log(`report not taken: ${reason(error)}`);
269
- // Only an answer from the service settles a report; these decisions are checked and sent again otherwise.
270
- if (!(error instanceof ReportRefused)) {
271
- incomplete = true;
327
+ incomplete = true;
328
+ // Only an answer from the service settles a report: a refusal would only repeat, and these decisions are checked and sent again otherwise.
329
+ if (!(error instanceof ReportRefused))
272
330
  reached = seenDecision;
273
- }
274
331
  }
275
332
  }
276
333
  seenDecision = reached;
package/dist/work.js CHANGED
@@ -36,6 +36,16 @@ export const printable = (text) => stripAnsi(text)
36
36
  .replace(/[\t\n\r]+/g, " ")
37
37
  .replace(/[\x00-\x1f\x7f-\x9f]/g, "")
38
38
  .replace(new RegExp(HIDDEN.source, "g"), "");
39
+ /** The longest title the log quotes. */
40
+ const LOGGED_TITLE = 80;
41
+ /**
42
+ * A title from a stranger as the log shows it: one line, cut short and JSON-quoted,
43
+ * so it cannot close its quotes and pass for the worker's own words.
44
+ */
45
+ export function loggedTitle(title) {
46
+ const characters = [...printable(title)];
47
+ return JSON.stringify(characters.length > LOGGED_TITLE ? `${characters.slice(0, LOGGED_TITLE - 1).join("")}…` : characters.join(""));
48
+ }
39
49
  export function parseBrief(text) {
40
50
  let value;
41
51
  try {
@@ -437,7 +447,7 @@ export function createWork(options) {
437
447
  // A stop asked for while the brief downloaded or the balance was read starts no command.
438
448
  if (options.stopping?.())
439
449
  continue;
440
- log(`task ${task.id}: working on "${brief.title}"`);
450
+ log(`task ${task.id}: working on ${loggedTitle(brief.title)}`);
441
451
  const input = options.format === "json" ? JSON.stringify(taskInput(task, brief)) : taskPrompt(task, brief);
442
452
  const timeoutMs = Math.min(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, Number(task.deadline - MARGIN_SEC - now) * 1000);
443
453
  const output = stripAnsi(await command.run(input, { BRAINCHAIN_TASK_ID: key }, timeoutMs, remember).finally(forget)).trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-worker",
3
- "version": "0.18.3",
3
+ "version": "0.19.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>",