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/README.md +57 -42
- package/dist/bond.js +55 -24
- package/dist/chain.js +58 -5
- package/dist/cli.js +84 -56
- package/dist/credit.js +4 -1
- package/dist/exit.js +82 -19
- package/dist/key.js +24 -10
- package/dist/mint.js +3 -1
- package/dist/network.js +2 -0
- package/dist/propose.js +29 -32
- package/dist/relay.js +18 -12
- package/dist/solana.js +177 -36
- package/dist/watch.js +80 -23
- package/dist/work.js +11 -1
- package/package.json +1 -1
package/dist/propose.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { BaseError, createPublicClient, createWalletClient, decodeErrorResult, decodeFunctionData,
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
|
319
|
-
const
|
|
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.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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(
|
|
34
|
-
outAmount: BigInt(
|
|
35
|
-
outFormatted: String(
|
|
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
|
|
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 *
|
|
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 (
|
|
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
|
|
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
|
|
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
|
}
|