brainchain-worker 0.18.2 → 0.18.4

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/propose.js CHANGED
@@ -1,6 +1,6 @@
1
- import { BaseError, createPublicClient, createWalletClient, decodeErrorResult, decodeFunctionData, encodeAbiParameters, encodeFunctionData, ExecutionRevertedError, getAddress, isAddress, isHex, keccak256, parseAbi, parseEventLogs, size, slice, } from "viem";
2
- import { erc20Errors } from "./abi.js";
3
- import { bondsAbi, brainchainTransport, erc20Abi, executorAbi, RECEIPT_MS } from "./chain.js";
1
+ import { BaseError, createPublicClient, createWalletClient, decodeErrorResult, decodeFunctionData, encodeFunctionData, ExecutionRevertedError, getAddress, isAddress, isHex, parseAbi, parseEventLogs, size, } from "viem";
2
+ import { erc20Errors, transferErrors } from "./abi.js";
3
+ import { bondsAbi, brainchainTransport, callVetoed, erc20Abi, executorAbi, openProposalOf, RECEIPT_MS } from "./chain.js";
4
4
  import { chainOf } from "./network.js";
5
5
  import { gasHelp, stake, when } from "./standing.js";
6
6
  /** Every call the executor can be asked to make, by the contract it calls. */
@@ -19,21 +19,20 @@ export const GOVERNED = {
19
19
  registry: parseAbi(["function setModel(bytes32 id, address account, uint128 price, bool active)"]),
20
20
  };
21
21
  const TARGETS = Object.keys(GOVERNED);
22
- /** What the brain reads from a proposal's URI, cut to the lengths it keeps. */
22
+ /** What the brain reads from a proposal's URI, cut to the lengths it keeps, in UTF-16 units as a JavaScript string counts them. */
23
23
  export const LIMITS = { title: 120, rationale: 1_500, current: 300, proposed: 300 };
24
+ /** The most the brain reads of a whole case as JSON; past it, the case reads as blank and Jev decides on the call alone. */
25
+ export const CASE_BYTES = 4_096;
24
26
  const proposalsAbi = parseAbi([
25
27
  "event Expired(uint256 indexed id)",
26
28
  "function decideWithin() view returns (uint64)",
27
29
  ]);
28
- const executorReadsAbi = parseAbi([
29
- "function vetoed(address target, bytes4 selector) view returns (bool)",
30
- "function vetoedCall(bytes32) view returns (bool)",
31
- "function changeThresholdBps() view returns (uint16)",
32
- ]);
30
+ const executorReadsAbi = parseAbi(["function changeThresholdBps() view returns (uint16)"]);
33
31
  // The reverts a governed call can hit, so a rehearsal names the rule it broke.
34
32
  const callErrorsAbi = [
35
- ...parseAbi(["error BadConfig()", "error BadFee()", "error ZeroAddress()", "error NotOwner()", "error NotSelf()", "error NotGuardian()"]),
33
+ ...parseAbi(["error BadConfig()", "error BadFee()", "error ZeroAddress()", "error NotOwner()", "error NotSelf()", "error NotGuardian()", "error AccountShapedId()", "error UnknownModel()"]),
36
34
  ...erc20Errors,
35
+ ...transferErrors,
37
36
  ];
38
37
  /** Mirrors ProposalBonds.State. */
39
38
  const State = { None: 0, Open: 1, Returned: 2, Burned: 3 };
@@ -169,7 +168,10 @@ export function caseURI(call, input) {
169
168
  if (body[key].length > LIMITS[key])
170
169
  throw new ProposalError(`--${key} is at most ${LIMITS[key]} characters`);
171
170
  }
172
- return `data:application/json;base64,${Buffer.from(JSON.stringify(body)).toString("base64")}`;
171
+ const json = Buffer.from(JSON.stringify(body));
172
+ if (json.byteLength > CASE_BYTES)
173
+ throw new ProposalError(`The case is ${json.byteLength} bytes as JSON, and Jev reads at most ${CASE_BYTES}, which text outside the Latin alphabet reaches before the character limits. Shorten it.`);
174
+ return `data:application/json;base64,${json.toString("base64")}`;
173
175
  }
174
176
  const LOG_RANGE = 50000n;
175
177
  export function createProposals(network, account) {
@@ -204,20 +206,8 @@ export function createProposals(network, account) {
204
206
  decideWithin: () => client.readContract({ ...proposals, functionName: "decideWithin" }),
205
207
  changeThresholdBps: async () => BigInt(await client.readContract({ address: executor, abi: executorReadsAbi, functionName: "changeThresholdBps" })),
206
208
  timelock: () => client.readContract({ address: executor, abi: executorAbi, functionName: "timelock" }),
207
- // A set 3.4.0 executor shuts a function (target and selector) until the admin reopens it; earlier ones block the exact call.
208
- vetoed: async (call) => {
209
- try {
210
- return await client.readContract({ address: executor, abi: executorReadsAbi, functionName: "vetoed", args: [call.address, slice(call.data, 0, 4)] });
211
- }
212
- catch {
213
- return client.readContract({
214
- address: executor,
215
- abi: executorReadsAbi,
216
- functionName: "vetoedCall",
217
- args: [keccak256(encodeAbiParameters([{ type: "address" }, { type: "bytes" }], [call.address, call.data]))],
218
- });
219
- }
220
- },
209
+ vetoed: (call) => callVetoed(client, executor, call.address, call.data),
210
+ openProposal: (call) => openProposalOf(client, network, call.address, call.data),
221
211
  async rehearse(call) {
222
212
  try {
223
213
  await client.call({ account: executor, to: call.address, data: call.data });
@@ -230,8 +220,13 @@ export function createProposals(network, account) {
230
220
  throw error;
231
221
  const raw = error.walk((e) => typeof e.data === "string");
232
222
  try {
233
- if (raw?.data)
234
- return `${decodeErrorResult({ abi: callErrorsAbi, data: raw.data }).errorName}, the contract's check on these values`;
223
+ if (raw?.data) {
224
+ const decoded = decodeErrorResult({ abi: callErrorsAbi, data: raw.data });
225
+ // A plain Solidity revert carries its reason as text, and its error's name says nothing.
226
+ if (decoded.errorName === "Error")
227
+ return String(decoded.args?.[0]);
228
+ return `${decoded.errorName}, the contract's check on these values`;
229
+ }
235
230
  }
236
231
  catch {
237
232
  // Not one of the contracts' own errors; the node's message says more.
@@ -251,7 +246,7 @@ export function createProposals(network, account) {
251
246
  },
252
247
  async get(id) {
253
248
  const p = await client.readContract({ ...proposals, functionName: "get", args: [id] });
254
- return { id, proposer: p.proposer, bond: p.bond, createdAt: p.createdAt, state: p.state };
249
+ return { id, proposer: p.proposer, bond: p.bond, createdAt: p.createdAt, state: p.state, deadline: p.deadline };
255
250
  },
256
251
  async outcome(id) {
257
252
  const [settled, expired] = await Promise.all([
@@ -276,7 +271,7 @@ const days = (seconds) => `${Number(seconds) / 86_400} days`;
276
271
  const percent = (bps) => `${Number(bps) / 100}%`;
277
272
  export async function propose(network, chain, call, uri, options) {
278
273
  const { log } = options;
279
- const [bond, balance, gas, within, threshold, timelock, vetoed, revert] = await Promise.all([
274
+ const [bond, balance, gas, within, threshold, timelock, vetoed, open, revert] = await Promise.all([
280
275
  chain.bond(),
281
276
  chain.stakeBalance(),
282
277
  chain.ethBalance(),
@@ -284,10 +279,13 @@ export async function propose(network, chain, call, uri, options) {
284
279
  chain.changeThresholdBps(),
285
280
  chain.timelock(),
286
281
  chain.vetoed(call),
282
+ chain.openProposal(call),
287
283
  chain.rehearse(call),
288
284
  ]);
289
285
  if (vetoed)
290
286
  throw new ProposalError(`The guardian vetoed ${call.text} on the ${call.target}, so the executor does not queue it until the admin reopens that function`);
287
+ if (open > 0n)
288
+ throw new ProposalError(`Proposal ${open} already asks for ${call.text} on the ${call.target} and is still open, and the contract takes one at a time. Follow it with \`npx brainchain-worker proposal ${open}\`.`);
291
289
  if (revert)
292
290
  throw new ProposalError(`${call.text} would fail if the executor made it now (${revert}), so the bond would be burned for nothing`);
293
291
  log(`Proposal on ${network.name}
@@ -315,9 +313,8 @@ export async function proposal(network, chain, id, log) {
315
313
  throw new ProposalError(`There is no proposal ${id} on ${network.name}`);
316
314
  const head = `Proposal ${id} by ${p.proposer}, bond ${stake(network, p.bond)}`;
317
315
  if (p.state === State.Open) {
318
- const [within, now] = await Promise.all([chain.decideWithin(), chain.now()]);
319
- const closes = p.createdAt + within;
320
- const after = now > closes ? `its window closed at ${when(closes)}, so anyone can return the bond with expire` : `Jev decides by ${when(closes)}`;
316
+ const now = await chain.now();
317
+ const after = now > p.deadline ? `its window closed at ${when(p.deadline)}, so anyone can return the bond by calling expire on ${network.contracts.bonds}` : `Jev decides by ${when(p.deadline)}`;
321
318
  log(`${head}\n Waiting for Jev: ${after}.`);
322
319
  return;
323
320
  }
package/dist/relay.js CHANGED
@@ -1,6 +1,6 @@
1
- // Relay (relay.link) moves value between Solana and Robinhood Chain in about a second. The worker uses it to fund
2
- // itself from Solana and to send its earnings there; every quote is checked against what goes in before anything is
3
- // signed, so a bad price stops the move instead of costing the worker.
1
+ // Relay (relay.link) moves value between Solana and Robinhood Chain in about a second. `fund` uses it to bring USDC
2
+ // from Solana; every quote is checked against what goes in before anything is signed, so a bad price stops the move
3
+ // instead of costing the worker.
4
4
  export const RELAY_API = "https://api.relay.link";
5
5
  export const SOLANA_CHAIN_ID = 792703809;
6
6
  /** USDC on Solana. */
@@ -8,6 +8,8 @@ export const SOLANA_USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
8
8
  export const NATIVE = "0x0000000000000000000000000000000000000000";
9
9
  /** The most a move may cost, as a share of its dollar value; small moves may cost up to FIXED_LOSS_USD instead. */
10
10
  export const MAX_LOSS = 0.03;
11
+ /** The stake token's market alone costs about 3.7% at any size on top of Relay's fee, so the stake leg gets more room. */
12
+ export const MAX_STAKE_LOSS = 0.05;
11
13
  /** Relay's fee is partly fixed (about 0.2 USD), so a small move is allowed to lose up to this much in dollars. */
12
14
  export const FIXED_LOSS_USD = 0.5;
13
15
  export class RelayError extends Error {
@@ -26,33 +28,37 @@ export async function relayQuote(request, fetchImpl = fetch) {
26
28
  const requestId = body.steps.find((s) => s.requestId)?.requestId;
27
29
  if (!requestId)
28
30
  throw new RelayError("Relay's quote carries no request id to follow");
31
+ const out = body.details.currencyOut;
29
32
  return {
30
33
  requestId,
31
34
  steps: body.steps,
32
35
  inUsd: Number(body.details.currencyIn?.amountUsd ?? 0),
33
- outUsd: Number(body.details.currencyOut?.amountUsd ?? 0),
34
- outAmount: BigInt(body.details.currencyOut?.amount ?? 0),
35
- outFormatted: String(body.details.currencyOut?.amountFormatted ?? "0"),
36
+ outUsd: Number(out?.amountUsd ?? 0),
37
+ outAmount: /^\d+$/.test(String(out?.amount)) ? BigInt(out.amount) : 0n,
38
+ outFormatted: String(out?.amountFormatted ?? "0"),
39
+ recipient: body.details.recipient,
40
+ outCurrency: out?.currency?.address,
41
+ outChainId: out?.currency?.chainId,
36
42
  };
37
43
  }
38
- /** Refuses a quote that loses more than MAX_LOSS of its dollar value, or FIXED_LOSS_USD on a small move. */
39
- export function checkReturn(quote) {
44
+ /** Refuses a quote that loses more than `maxLoss` of its dollar value, or FIXED_LOSS_USD on a small move. */
45
+ export function checkReturn(quote, maxLoss = MAX_LOSS) {
40
46
  if (!(quote.inUsd > 0))
41
47
  throw new RelayError("Relay's quote has no dollar value for what goes in, so its price cannot be checked");
42
48
  const loss = quote.inUsd - quote.outUsd;
43
- const allowed = Math.max(quote.inUsd * MAX_LOSS, FIXED_LOSS_USD);
49
+ const allowed = Math.max(quote.inUsd * maxLoss, FIXED_LOSS_USD);
44
50
  if (loss > allowed) {
45
- throw new RelayError(`Relay would keep ${loss.toFixed(2)} USD of ${quote.inUsd.toFixed(2)}, more than the ${allowed.toFixed(2)} this command accepts (3%, or 0.50 USD on a small move). Nothing was sent; try again later.`);
51
+ throw new RelayError(`Relay would keep ${loss.toFixed(2)} USD of ${quote.inUsd.toFixed(2)}, more than the ${allowed.toFixed(2)} this command accepts (${Math.round(maxLoss * 100)}%, or 0.50 USD on a small move). Nothing was sent; try again later.`);
46
52
  }
47
53
  }
48
54
  export async function relayStatus(requestId, fetchImpl = fetch) {
49
- const response = await fetchImpl(`${RELAY_API}/intents/status/v2?requestId=${requestId}`, { signal: AbortSignal.timeout(20_000) });
55
+ const response = await fetchImpl(`${RELAY_API}/intents/status/v2?requestId=${encodeURIComponent(requestId)}`, { signal: AbortSignal.timeout(20_000) });
50
56
  const body = (await response.json().catch(() => ({})));
51
57
  if (body.status === "success" || body.status === "failure" || body.status === "refund")
52
58
  return body.status;
53
59
  return "pending";
54
60
  }
55
- /** Follows a move until Relay says it landed, failed or was refunded, for at most `ms`. */
61
+ /** Follows a move until Relay answers anything but pending, for at most `ms`. Its `failure` is usually not final: Relay can still retry or refund. */
56
62
  export async function waitForRelay(requestId, ms = 600_000, fetchImpl = fetch, sleep = (t) => new Promise((r) => setTimeout(r, t))) {
57
63
  const end = Date.now() + ms;
58
64
  for (;;) {
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
  }