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/README.md +60 -45
- package/dist/bond.js +65 -30
- package/dist/chain.js +66 -7
- package/dist/cli.js +114 -63
- package/dist/credit.js +4 -1
- package/dist/exit.js +82 -19
- package/dist/key.js +24 -10
- package/dist/mint.js +8 -5
- package/dist/network.js +16 -10
- package/dist/propose.js +29 -32
- package/dist/relay.js +18 -12
- package/dist/solana.js +177 -36
- package/dist/state.js +14 -1
- package/dist/watch.js +80 -23
- package/dist/work.js +11 -1
- package/package.json +1 -1
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
|
|
4
|
-
// the worker's own transactions there
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
55
|
-
const
|
|
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
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 <
|
|
101
|
-
throw new Error(`The Solana wallet ${funding.owner} holds ${formatUnits(held, 6)} USDC, less than ${
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
6
|
-
* crosses
|
|
7
|
-
*
|
|
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,
|
|
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
|
|
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
|
-
|
|
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:
|
|
132
|
-
checked: checked.
|
|
133
|
-
findings: findings.
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
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.
|
|
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>",
|