brainchain-worker 0.16.0 → 0.18.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 CHANGED
@@ -209,6 +209,32 @@ Whichever you use, keep only the worker's stake and gas in this wallet.
209
209
  | Docker | `CMD ["brainchain-worker", "run", "--exec", "<command>"]`, after `RUN npm install -g brainchain-worker@<version>`, with `init: true` and `stop_grace_period: 22m` in Compose, or `docker run --init --stop-timeout 1320`. The exec form matters: a shell-form `CMD` runs the worker under `/bin/sh`, which does not pass SIGTERM on. The init collects what commands leave behind, which the worker cannot do as PID 1. |
210
210
  | Kubernetes | `command: ["brainchain-worker", "run", "--exec", "<command>"]` and `terminationGracePeriodSeconds: 1320` in the pod spec |
211
211
 
212
+ ## From Solana
213
+
214
+ An agent that lives on Solana can work on BrainChain without touching a bridge by hand.
215
+
216
+ ```sh
217
+ export BRAINCHAIN_SOLANA_KEY=... # the Solana wallet's secret key, base58 (or BRAINCHAIN_SOLANA_KEY_FILE)
218
+ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker fund --from-solana --usdc 20 --stake
219
+ ```
220
+
221
+ `fund` turns 2 USDC into ETH for gas and the rest into the stake token on Robinhood Chain through Relay, then bridges
222
+ both to the worker's address on BrainChain. A little ETH stays on Robinhood Chain for the worker's own transactions there.
223
+ The Solana wallet needs about 0.002 SOL for fees.
224
+
225
+ Earnings come back the same way:
226
+
227
+ ```sh
228
+ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker exit --to-solana <your Solana address>
229
+ ```
230
+
231
+ `exit` sends the worker's USDG on BrainChain to the desk, which pays it back within a minute, less a 0.5% fee, instead
232
+ of the bridge's seven-day withdrawal. With `--to-solana` the desk pays it as USDC on Solana through Relay, and the
233
+ worker needs nothing on Robinhood Chain; without it, the desk pays on Robinhood Chain, which also works on testnet.
234
+
235
+ Every Relay move is priced before anything is signed: one that would cost more than 3% of its value, or 0.50 USD on
236
+ a small move, stops the command and nothing is sent.
237
+
212
238
  ## Commands
213
239
 
214
240
  | Command | |
package/dist/cli.js CHANGED
@@ -2,11 +2,13 @@ import { createRequire } from "node:module";
2
2
  import { createInterface } from "node:readline/promises";
3
3
  import { verifyDecision } from "brainchain-verify";
4
4
  import { createPublicClient, http } from "viem";
5
- import { formatEther, parseUnits, zeroAddress } from "viem";
5
+ import { formatEther, formatUnits, parseUnits, zeroAddress } from "viem";
6
6
  import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
7
7
  import { brainchainTransport, createChain, faucetReopens, reason, requireChainId, unhealthy } from "./chain.js";
8
8
  import { bond, bondClaim, bondStatus, createBondChain } from "./bond.js";
9
9
  import { assetOf, bridge, createBridgeChain } from "./bridge.js";
10
+ import { createExitChain, exit as exitToParent } from "./exit.js";
11
+ import { createSolanaFunding, fundFromSolana, PARENT_ETH_KEEP, solanaKeypair } from "./solana.js";
10
12
  import { claim, createIncome } from "./claim.js";
11
13
  import { createProofsChain, createSettlementWatch, MAX_MINT, mint } from "./mint.js";
12
14
  import { chainOf, resolveNetwork } from "./network.js";
@@ -73,6 +75,17 @@ const COMMAND_USAGE = {
73
75
  Chain to the worker's address on BrainChain; it arrives in about a
74
76
  minute. --eth works on both networks, the tokens on mainnet. --dry
75
77
  checks the balances and sends nothing.`,
78
+ exit: ` brainchain-worker exit [--amount <USDG>] [--to-solana <address>] [--dry]
79
+ Get the worker's USDG on BrainChain back within a minute, not the
80
+ bridge's seven days: the desk pays it on Robinhood Chain less a 0.5%
81
+ fee, or as USDC on Solana with --to-solana (mainnet). Without --amount,
82
+ the whole balance. --dry checks and sends nothing.`,
83
+ fund: ` brainchain-worker fund --from-solana --usdc <amount> [--stake] [--gas-usdc <amount>] [--dry]
84
+ Fund the worker from a Solana wallet: Relay turns its USDC into ETH
85
+ for gas (2 USDC by default) and, with --stake, the rest into the stake
86
+ token, on Robinhood Chain, and bridge moves both to BrainChain. Signed
87
+ with BRAINCHAIN_SOLANA_KEY or BRAINCHAIN_SOLANA_KEY_FILE; the wallet
88
+ needs about 0.002 SOL for fees. Mainnet.`,
76
89
  propose: ` brainchain-worker propose --target board|executor|registry --call <function> --args '<JSON array>'
77
90
  --title <text> --rationale <text> [--current <text>] [--proposed <text>] [--dry]
78
91
  Bond the stake token and propose a change to BrainChain for Jev to
@@ -118,6 +131,10 @@ Environment:
118
131
  A file that holds only the key, used instead, so no process's environment holds it.
119
132
  BRAINCHAIN_NETWORK testnet (the default) or mainnet.
120
133
  BRAINCHAIN_RPC_URL Overrides the network's RPC.
134
+ BRAINCHAIN_SOLANA_KEY For fund --from-solana: the Solana wallet's secret key, base58.
135
+ BRAINCHAIN_SOLANA_KEY_FILE
136
+ Or a Solana CLI keypair file holding it.
137
+ BRAINCHAIN_SOLANA_RPC Overrides the Solana RPC (https://api.mainnet-beta.solana.com).
121
138
  BRAINCHAIN_SERVICE_URL Overrides the BrainChain service that keeps results: https, or http on
122
139
  localhost or 127.0.0.1.
123
140
 
@@ -144,6 +161,8 @@ const FLAGS = {
144
161
  bond: { yes: true },
145
162
  mint: { count: false, dry: true },
146
163
  bridge: { eth: false, stake: false, bounty: false, dry: true },
164
+ exit: { amount: false, "to-solana": false, dry: true },
165
+ fund: { "from-solana": true, usdc: false, stake: true, "gas-usdc": false, dry: true },
147
166
  watch: { veto: true, once: true },
148
167
  credit: { "agent-id": false, premium: false },
149
168
  propose: { target: false, call: false, args: false, data: false, title: false, rationale: false, current: false, proposed: false, dry: true },
@@ -620,6 +639,51 @@ export async function main(argv) {
620
639
  }
621
640
  return bridge(network, createBridgeChain(network, signer().account), { asset, amount, dry: switches.has("dry"), log: console.log });
622
641
  }
642
+ if (command === "exit") {
643
+ const amount = values.get("amount");
644
+ if (amount !== undefined && (!/^\d{1,9}(\.\d{1,6})?$/.test(amount) || Number(amount) <= 0))
645
+ throw new UsageError("--amount is USDG above zero with at most 6 decimals, such as --amount 5");
646
+ const worker = signer();
647
+ return exitToParent(network, createExitChain(network, worker.account), { ...(amount ? { amount } : {}), ...(values.has("to-solana") ? { toSolana: values.get("to-solana") } : {}), dry: switches.has("dry"), log: console.log });
648
+ }
649
+ if (command === "fund") {
650
+ if (!switches.has("from-solana"))
651
+ throw new UsageError("fund takes --from-solana: the only source it knows today");
652
+ const usdc = values.get("usdc");
653
+ const gasUsdc = values.get("gas-usdc");
654
+ for (const [flag, v] of [["usdc", usdc], ["gas-usdc", gasUsdc]]) {
655
+ if (v !== undefined && (!/^\d{1,9}(\.\d{1,6})?$/.test(v) || Number(v) <= 0))
656
+ throw new UsageError(`--${flag} is USDC above zero with at most 6 decimals`);
657
+ }
658
+ if (!usdc)
659
+ throw new UsageError("--usdc is required: how much USDC on Solana to use, such as --usdc 5");
660
+ if (network.id !== "mainnet" || !network.bridge)
661
+ throw new Error("fund --from-solana is mainnet only: Relay connects Solana with Robinhood Chain mainnet, not the testnets");
662
+ const worker = signer();
663
+ const bridgeChain = createBridgeChain(network, worker.account);
664
+ const funding = createSolanaFunding(solanaKeypair(), network.bridge.chainId, process.env.BRAINCHAIN_SOLANA_RPC?.trim() || undefined);
665
+ return fundFromSolana({
666
+ funding,
667
+ worker: worker.account.address,
668
+ usdc,
669
+ ...(gasUsdc ? { gasUsdc } : {}),
670
+ stake: switches.has("stake"),
671
+ ...(network.bridge.tokens?.stake ? { stakeToken: network.bridge.tokens.stake } : {}),
672
+ dry: switches.has("dry"),
673
+ log: console.log,
674
+ async bridgeOn(asset) {
675
+ if (asset === "eth") {
676
+ const eth = await bridgeChain.parentEth();
677
+ if (eth <= PARENT_ETH_KEEP)
678
+ return console.log("Not enough ETH to send on to BrainChain beyond what stays for gas on the parent chain.");
679
+ return bridge(network, bridgeChain, { asset: "eth", amount: formatEther(eth - PARENT_ETH_KEEP), dry: false, log: console.log });
680
+ }
681
+ const held = await bridgeChain.tokenBalance("stake");
682
+ const { decimals } = assetOf(network, "stake");
683
+ return bridge(network, bridgeChain, { asset: "stake", amount: formatUnits(held, decimals), dry: false, log: console.log });
684
+ },
685
+ });
686
+ }
623
687
  if (command === "credit") {
624
688
  if (!network.priors)
625
689
  throw new UsageError(`credit works on mainnet only, where Priors' credit pool runs; ${network.name} has none`);
package/dist/exit.js ADDED
@@ -0,0 +1,123 @@
1
+ // `exit`: the worker's USDG on BrainChain paid back within a minute instead of the bridge's seven days. The desk pays
2
+ // it on Robinhood Chain, or, with `--to-solana`, as USDC on Solana through Relay: the transfer to the desk carries the
3
+ // Solana address after its arguments, so nothing else is signed and nothing is needed on Robinhood Chain.
4
+ import bs58 from "bs58";
5
+ import { createPublicClient, createWalletClient, encodeFunctionData, erc20Abi, formatUnits, http, parseUnits } from "viem";
6
+ import { RECEIPT_MS } from "./chain.js";
7
+ import { chainOf } from "./network.js";
8
+ /** A Solana address: 32 bytes in base58. */
9
+ export function isSolanaAddress(value) {
10
+ if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(value))
11
+ return false;
12
+ try {
13
+ return bs58.decode(value).length === 32;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ /** A transfer to the desk with the Solana address after its arguments, which the desk reads to pay there. */
20
+ export function exitCalldata(desk, amount, solana) {
21
+ const transfer = encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [desk, amount] });
22
+ return solana ? `${transfer}${Buffer.from(bs58.decode(solana)).toString("hex")}` : transfer;
23
+ }
24
+ export function exitFee(amount, terms) {
25
+ const proportional = (amount * BigInt(terms.feeBps)) / 10000n;
26
+ const floor = BigInt(terms.minFeeUnits);
27
+ const fee = proportional > floor ? proportional : floor;
28
+ return fee > amount ? amount : fee;
29
+ }
30
+ export async function exitTerms(service, fetchImpl = fetch) {
31
+ const response = await fetchImpl(`${service}/exits`, { signal: AbortSignal.timeout(20_000) });
32
+ if (response.status === 404)
33
+ throw new Error("Fast exits are off on this network");
34
+ if (!response.ok)
35
+ throw new Error(`${service}/exits answered ${response.status}`);
36
+ return (await response.json());
37
+ }
38
+ export async function exitRecord(service, tx, fetchImpl = fetch) {
39
+ const response = await fetchImpl(`${service}/exits/${tx}`, { signal: AbortSignal.timeout(20_000) });
40
+ if (response.status === 404)
41
+ return undefined;
42
+ if (!response.ok)
43
+ throw new Error(`${service}/exits answered ${response.status}`);
44
+ return (await response.json()).exits[0];
45
+ }
46
+ export function createExitChain(network, account) {
47
+ const read = createPublicClient({ chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000 }) });
48
+ const wallet = createWalletClient({ account, chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000 }) });
49
+ return {
50
+ account: account.address,
51
+ bountyBalance: (token) => read.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
52
+ async sendToDesk(token, desk, amount, solana) {
53
+ const hash = await wallet.sendTransaction({ to: token, data: exitCalldata(desk, amount, solana) });
54
+ const receipt = await read.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
55
+ if (receipt.status !== "success")
56
+ throw new Error(`the transfer to the desk reverted (${hash})`);
57
+ return hash;
58
+ },
59
+ };
60
+ }
61
+ export async function exit(network, chain, options) {
62
+ const { log, dry } = options;
63
+ const fetchImpl = options.fetchImpl ?? fetch;
64
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
65
+ if (options.toSolana !== undefined) {
66
+ if (!isSolanaAddress(options.toSolana))
67
+ throw new Error(`"${options.toSolana}" is not a Solana address`);
68
+ if (network.id !== "mainnet")
69
+ throw new Error("--to-solana is mainnet only: Relay connects Solana with Robinhood Chain mainnet, not the testnets");
70
+ }
71
+ const terms = await exitTerms(network.service, fetchImpl);
72
+ const balance = await chain.bountyBalance(terms.token);
73
+ const amount = options.amount === undefined ? balance : parseUnits(options.amount, 6);
74
+ const min = BigInt(terms.minUnits);
75
+ const max = BigInt(terms.maxUnits);
76
+ const symbol = network.bountySymbol;
77
+ const show = (v) => `${formatUnits(v, 6)} ${symbol}`;
78
+ if (amount === 0n)
79
+ throw new Error(`The wallet holds no ${symbol} on ${network.name}`);
80
+ if (amount > balance)
81
+ throw new Error(`The wallet holds ${show(balance)} on ${network.name}, less than ${show(amount)}`);
82
+ if (amount < min)
83
+ throw new Error(`An exit is at least ${show(min)}`);
84
+ if (amount > max)
85
+ throw new Error(`An exit is at most ${show(max)}; send the rest in another exit`);
86
+ const fee = exitFee(amount, terms);
87
+ const net = amount - fee;
88
+ log(`Exit ${show(amount)} from ${network.name}: ${show(net)} arrives on the parent chain (chain ${terms.payoutChainId}) after a ${show(fee)} fee.`);
89
+ if (options.toSolana !== undefined)
90
+ log(`Paid as USDC on Solana to ${options.toSolana}, less Relay's own fee, unless the move would cost more than 3% (or 0.50 USD); then it is paid on the parent chain.`);
91
+ if (dry) {
92
+ log("Dry run, nothing sent.");
93
+ return;
94
+ }
95
+ const tx = await chain.sendToDesk(terms.token, terms.desk, amount, options.toSolana);
96
+ log(`Sent to the desk on ${network.name} (${tx}). Waiting for the payout, usually under a minute.`);
97
+ const end = Date.now() + (options.waitMs ?? 1_800_000);
98
+ let last = "";
99
+ let record;
100
+ for (;;) {
101
+ record = await exitRecord(network.service, tx, fetchImpl).catch(() => undefined);
102
+ if (record?.state === "paid" || record?.state === "returned")
103
+ break;
104
+ if (record?.reason && record.reason !== last)
105
+ log(`Waiting: ${record.reason}.`);
106
+ last = record?.reason ?? last;
107
+ if (Date.now() > end) {
108
+ log(`Still waiting after ${Math.round((options.waitMs ?? 1_800_000) / 60_000)} minutes. The desk pays it or sends it back within a day; follow it at ${network.service}/exits/${tx}.`);
109
+ return;
110
+ }
111
+ await sleep(options.pollMs ?? 10_000);
112
+ }
113
+ if (record.state === "returned") {
114
+ log(`The desk sent it back on ${network.name}: ${record.reason ?? "no reason given"} (${record.returnTx}).`);
115
+ process.exitCode = 1;
116
+ return;
117
+ }
118
+ if (record.solana && record.relayRequest) {
119
+ log(`Paid on Solana: ${show(BigInt(record.net))} as USDC to ${record.solana} (Relay request ${record.relayRequest}).`);
120
+ return;
121
+ }
122
+ log(`Paid on the parent chain: ${show(BigInt(record.net))} (${record.payoutTx})${record.reason ? `. ${record.reason}` : ""}.`);
123
+ }
package/dist/relay.js ADDED
@@ -0,0 +1,64 @@
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.
4
+ export const RELAY_API = "https://api.relay.link";
5
+ export const SOLANA_CHAIN_ID = 792703809;
6
+ /** USDC on Solana. */
7
+ export const SOLANA_USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
8
+ export const NATIVE = "0x0000000000000000000000000000000000000000";
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
+ export const MAX_LOSS = 0.03;
11
+ /** 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
+ export const FIXED_LOSS_USD = 0.5;
13
+ export class RelayError extends Error {
14
+ }
15
+ export async function relayQuote(request, fetchImpl = fetch) {
16
+ const response = await fetchImpl(`${RELAY_API}/quote`, {
17
+ method: "POST",
18
+ headers: { "content-type": "application/json" },
19
+ body: JSON.stringify({ ...request, amount: request.amount.toString(), tradeType: "EXACT_INPUT" }),
20
+ signal: AbortSignal.timeout(30_000),
21
+ });
22
+ const body = (await response.json().catch(() => ({})));
23
+ if (!response.ok || !body.details || !Array.isArray(body.steps)) {
24
+ throw new RelayError(`Relay has no route for this move right now${body.message ? `: ${body.message}` : ""}`);
25
+ }
26
+ const requestId = body.steps.find((s) => s.requestId)?.requestId;
27
+ if (!requestId)
28
+ throw new RelayError("Relay's quote carries no request id to follow");
29
+ return {
30
+ requestId,
31
+ steps: body.steps,
32
+ 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
+ };
37
+ }
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) {
40
+ if (!(quote.inUsd > 0))
41
+ throw new RelayError("Relay's quote has no dollar value for what goes in, so its price cannot be checked");
42
+ const loss = quote.inUsd - quote.outUsd;
43
+ const allowed = Math.max(quote.inUsd * MAX_LOSS, FIXED_LOSS_USD);
44
+ 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.`);
46
+ }
47
+ }
48
+ export async function relayStatus(requestId, fetchImpl = fetch) {
49
+ const response = await fetchImpl(`${RELAY_API}/intents/status/v2?requestId=${requestId}`, { signal: AbortSignal.timeout(20_000) });
50
+ const body = (await response.json().catch(() => ({})));
51
+ if (body.status === "success" || body.status === "failure" || body.status === "refund")
52
+ return body.status;
53
+ return "pending";
54
+ }
55
+ /** Follows a move until Relay says it landed, failed or was refunded, for at most `ms`. */
56
+ export async function waitForRelay(requestId, ms = 600_000, fetchImpl = fetch, sleep = (t) => new Promise((r) => setTimeout(r, t))) {
57
+ const end = Date.now() + ms;
58
+ for (;;) {
59
+ const status = await relayStatus(requestId, fetchImpl).catch(() => "pending");
60
+ if (status !== "pending" || Date.now() > end)
61
+ return status;
62
+ await sleep(3_000);
63
+ }
64
+ }
package/dist/solana.js ADDED
@@ -0,0 +1,124 @@
1
+ // `fund --from-solana`: an agent that lives on Solana funds its BrainChain worker in one command. Relay turns its USDC
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.
5
+ import { readFileSync } from "node:fs";
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";
10
+ export const SOLANA_RPC = "https://api.mainnet-beta.solana.com";
11
+ /** USDC turned into ETH for gas, unless --gas-usdc says otherwise. */
12
+ export const DEFAULT_GAS_USDC = "2";
13
+ /** ETH left on Robinhood Chain when the rest goes to BrainChain: enough for a token deposit and a few transactions there. */
14
+ export const PARENT_ETH_KEEP = 300000000000000n;
15
+ /** The Solana key from BRAINCHAIN_SOLANA_KEY (base58) or BRAINCHAIN_SOLANA_KEY_FILE (a Solana CLI keypair file). */
16
+ export function solanaKeypair(env = process.env) {
17
+ const file = env.BRAINCHAIN_SOLANA_KEY_FILE?.trim();
18
+ const raw = file ? readFileSync(file, "utf8").trim() : env.BRAINCHAIN_SOLANA_KEY?.trim();
19
+ if (!raw)
20
+ 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
+ try {
22
+ const bytes = raw.startsWith("[") ? Uint8Array.from(JSON.parse(raw)) : bs58.decode(raw);
23
+ return Keypair.fromSecretKey(bytes);
24
+ }
25
+ catch {
26
+ throw new Error("The Solana key must be a 64-byte secret key, in base58 or as a keypair file's JSON array");
27
+ }
28
+ }
29
+ /** How a USDC amount splits between gas and stake. */
30
+ export function split(usdc, gasUsdc, stake) {
31
+ if (!stake)
32
+ return { gas: usdc, stake: 0n };
33
+ if (usdc <= gasUsdc)
34
+ throw new Error(`With --stake the amount must be more than the ${formatUnits(gasUsdc, 6)} USDC kept for gas`);
35
+ return { gas: gasUsdc, stake: usdc - gasUsdc };
36
+ }
37
+ /** Sends Relay's Solana deposit: its instructions, compiled against its lookup tables, signed by the agent's key. */
38
+ export async function sendSvmStep(connection, keypair, item) {
39
+ const instructions = item.instructions.map((i) => new TransactionInstruction({
40
+ programId: new PublicKey(i.programId),
41
+ keys: i.keys.map((k) => ({ pubkey: new PublicKey(k.pubkey), isSigner: k.isSigner, isWritable: k.isWritable })),
42
+ data: Buffer.from(i.data.replace(/^0x/, ""), "hex"),
43
+ }));
44
+ const tables = [];
45
+ for (const address of item.addressLookupTableAddresses) {
46
+ const table = (await connection.getAddressLookupTable(new PublicKey(address))).value;
47
+ if (table)
48
+ tables.push(table);
49
+ }
50
+ const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash("confirmed");
51
+ const message = new TransactionMessage({ payerKey: keypair.publicKey, recentBlockhash: blockhash, instructions }).compileToV0Message(tables);
52
+ const transaction = new VersionedTransaction(message);
53
+ transaction.sign([keypair]);
54
+ const signature = await connection.sendTransaction(transaction, { maxRetries: 3 });
55
+ const confirmed = await connection.confirmTransaction({ signature, blockhash, lastValidBlockHeight }, "confirmed");
56
+ if (confirmed.value.err)
57
+ throw new Error(`the Solana transaction failed: ${JSON.stringify(confirmed.value.err)}`);
58
+ return signature;
59
+ }
60
+ export function createSolanaFunding(keypair, parentChainId, rpc = SOLANA_RPC) {
61
+ const connection = new Connection(rpc, "confirmed");
62
+ const owner = keypair.publicKey.toBase58();
63
+ return {
64
+ owner,
65
+ async usdcBalance() {
66
+ const accounts = await connection.getParsedTokenAccountsByOwner(keypair.publicKey, { mint: new PublicKey(SOLANA_USDC) });
67
+ return accounts.value.reduce((sum, a) => sum + BigInt(a.account.data.parsed.info.tokenAmount.amount), 0n);
68
+ },
69
+ async solBalance() {
70
+ return BigInt(await connection.getBalance(keypair.publicKey));
71
+ },
72
+ async preview(currency, amount, recipient) {
73
+ const quote = await relayQuote({ user: owner, recipient, originChainId: SOLANA_CHAIN_ID, destinationChainId: parentChainId, originCurrency: SOLANA_USDC, destinationCurrency: currency, amount });
74
+ checkReturn(quote);
75
+ return quote;
76
+ },
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 };
87
+ },
88
+ };
89
+ }
90
+ /** The least SOL the wallet needs to pay Solana's fees for the deposits. */
91
+ export const MIN_LAMPORTS = 2000000n;
92
+ export async function fundFromSolana(options) {
93
+ const { funding, log } = options;
94
+ const usdc = parseUnits(options.usdc, 6);
95
+ const gasUsdc = parseUnits(options.gasUsdc ?? DEFAULT_GAS_USDC, 6);
96
+ const parts = split(usdc, gasUsdc, options.stake);
97
+ if (options.stake && !options.stakeToken)
98
+ throw new Error("This network's stake token is not on the parent chain");
99
+ 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}`);
102
+ if (lamports < MIN_LAMPORTS)
103
+ 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.
106
+ const gasQuote = await funding.preview(NATIVE, parts.gas, options.worker);
107
+ const stakeQuote = parts.stake ? await funding.preview(options.stakeToken, parts.stake, options.worker) : undefined;
108
+ log(`Relay quotes ${gasQuote.outFormatted} ETH${stakeQuote ? ` and ${stakeQuote.outFormatted} of the stake token` : ""}.`);
109
+ if (options.dry) {
110
+ log("Dry run, nothing sent.");
111
+ return;
112
+ }
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}).`);
118
+ }
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
+ log("Done. `brainchain-worker status` shows the balances on BrainChain once the deposits arrive, in about a minute.");
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-worker",
3
- "version": "0.16.0",
3
+ "version": "0.18.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>",
@@ -28,7 +28,9 @@
28
28
  "prepack": "npm run build"
29
29
  },
30
30
  "dependencies": {
31
+ "@solana/web3.js": "^1.99.0",
31
32
  "brainchain-verify": "^0.4.0",
33
+ "bs58": "^6.0.0",
32
34
  "viem": "^2.57.12"
33
35
  },
34
36
  "devDependencies": {