brainchain-worker 0.16.0 → 0.17.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 out on Robinhood Chain within minutes, less a
232
+ 0.5% fee, instead of the bridge's seven-day withdrawal. `--to-solana` then moves it on to USDC on Solana through Relay.
233
+ Without `--to-solana`, `exit` stops on Robinhood Chain; it works on testnet too, where Relay does not.
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,18 @@ 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
+ Move the worker's USDG on BrainChain to its wallet on Robinhood Chain
80
+ within minutes, not the bridge's seven days: the desk pays it out there
81
+ less a 0.5% fee. Without --amount, the whole balance. --to-solana then
82
+ moves it on to USDC on Solana through Relay (mainnet; needs a little
83
+ ETH on Robinhood Chain for gas). --dry checks and sends nothing.`,
84
+ fund: ` brainchain-worker fund --from-solana --usdc <amount> [--stake] [--gas-usdc <amount>] [--dry]
85
+ Fund the worker from a Solana wallet: Relay turns its USDC into ETH
86
+ for gas (2 USDC by default) and, with --stake, the rest into the stake
87
+ token, on Robinhood Chain, and bridge moves both to BrainChain. Signed
88
+ with BRAINCHAIN_SOLANA_KEY or BRAINCHAIN_SOLANA_KEY_FILE; the wallet
89
+ needs about 0.002 SOL for fees. Mainnet.`,
76
90
  propose: ` brainchain-worker propose --target board|executor|registry --call <function> --args '<JSON array>'
77
91
  --title <text> --rationale <text> [--current <text>] [--proposed <text>] [--dry]
78
92
  Bond the stake token and propose a change to BrainChain for Jev to
@@ -118,6 +132,10 @@ Environment:
118
132
  A file that holds only the key, used instead, so no process's environment holds it.
119
133
  BRAINCHAIN_NETWORK testnet (the default) or mainnet.
120
134
  BRAINCHAIN_RPC_URL Overrides the network's RPC.
135
+ BRAINCHAIN_SOLANA_KEY For fund --from-solana: the Solana wallet's secret key, base58.
136
+ BRAINCHAIN_SOLANA_KEY_FILE
137
+ Or a Solana CLI keypair file holding it.
138
+ BRAINCHAIN_SOLANA_RPC Overrides the Solana RPC (https://api.mainnet-beta.solana.com).
121
139
  BRAINCHAIN_SERVICE_URL Overrides the BrainChain service that keeps results: https, or http on
122
140
  localhost or 127.0.0.1.
123
141
 
@@ -144,6 +162,8 @@ const FLAGS = {
144
162
  bond: { yes: true },
145
163
  mint: { count: false, dry: true },
146
164
  bridge: { eth: false, stake: false, bounty: false, dry: true },
165
+ exit: { amount: false, "to-solana": false, dry: true },
166
+ fund: { "from-solana": true, usdc: false, stake: true, "gas-usdc": false, dry: true },
147
167
  watch: { veto: true, once: true },
148
168
  credit: { "agent-id": false, premium: false },
149
169
  propose: { target: false, call: false, args: false, data: false, title: false, rationale: false, current: false, proposed: false, dry: true },
@@ -620,6 +640,51 @@ export async function main(argv) {
620
640
  }
621
641
  return bridge(network, createBridgeChain(network, signer().account), { asset, amount, dry: switches.has("dry"), log: console.log });
622
642
  }
643
+ if (command === "exit") {
644
+ const amount = values.get("amount");
645
+ if (amount !== undefined && (!/^\d{1,9}(\.\d{1,6})?$/.test(amount) || Number(amount) <= 0))
646
+ throw new UsageError("--amount is USDG above zero with at most 6 decimals, such as --amount 5");
647
+ const worker = signer();
648
+ 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 });
649
+ }
650
+ if (command === "fund") {
651
+ if (!switches.has("from-solana"))
652
+ throw new UsageError("fund takes --from-solana: the only source it knows today");
653
+ const usdc = values.get("usdc");
654
+ const gasUsdc = values.get("gas-usdc");
655
+ for (const [flag, v] of [["usdc", usdc], ["gas-usdc", gasUsdc]]) {
656
+ if (v !== undefined && (!/^\d{1,9}(\.\d{1,6})?$/.test(v) || Number(v) <= 0))
657
+ throw new UsageError(`--${flag} is USDC above zero with at most 6 decimals`);
658
+ }
659
+ if (!usdc)
660
+ throw new UsageError("--usdc is required: how much USDC on Solana to use, such as --usdc 5");
661
+ if (network.id !== "mainnet" || !network.bridge)
662
+ throw new Error("fund --from-solana is mainnet only: Relay connects Solana with Robinhood Chain mainnet, not the testnets");
663
+ const worker = signer();
664
+ const bridgeChain = createBridgeChain(network, worker.account);
665
+ const funding = createSolanaFunding(solanaKeypair(), network.bridge.chainId, process.env.BRAINCHAIN_SOLANA_RPC?.trim() || undefined);
666
+ return fundFromSolana({
667
+ funding,
668
+ worker: worker.account.address,
669
+ usdc,
670
+ ...(gasUsdc ? { gasUsdc } : {}),
671
+ stake: switches.has("stake"),
672
+ ...(network.bridge.tokens?.stake ? { stakeToken: network.bridge.tokens.stake } : {}),
673
+ dry: switches.has("dry"),
674
+ log: console.log,
675
+ async bridgeOn(asset) {
676
+ if (asset === "eth") {
677
+ const eth = await bridgeChain.parentEth();
678
+ if (eth <= PARENT_ETH_KEEP)
679
+ return console.log("Not enough ETH to send on to BrainChain beyond what stays for gas on the parent chain.");
680
+ return bridge(network, bridgeChain, { asset: "eth", amount: formatEther(eth - PARENT_ETH_KEEP), dry: false, log: console.log });
681
+ }
682
+ const held = await bridgeChain.tokenBalance("stake");
683
+ const { decimals } = assetOf(network, "stake");
684
+ return bridge(network, bridgeChain, { asset: "stake", amount: formatUnits(held, decimals), dry: false, log: console.log });
685
+ },
686
+ });
687
+ }
623
688
  if (command === "credit") {
624
689
  if (!network.priors)
625
690
  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,152 @@
1
+ // `exit`: the worker's USDG on BrainChain back on Robinhood Chain within minutes instead of the bridge's seven days.
2
+ // The desk pays it out on Robinhood Chain and keeps a small fee; `--to-solana` then moves it on to USDC on Solana
3
+ // through Relay from the worker's own wallet there.
4
+ import { createPublicClient, createWalletClient, erc20Abi, formatUnits, http, parseUnits } from "viem";
5
+ import { RECEIPT_MS } from "./chain.js";
6
+ import { chainOf, parentChainOf } from "./network.js";
7
+ import { checkReturn, relayQuote, SOLANA_CHAIN_ID, SOLANA_USDC, waitForRelay } from "./relay.js";
8
+ /** A Solana address: 32 to 44 base58 characters. */
9
+ export function isSolanaAddress(value) {
10
+ return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(value);
11
+ }
12
+ export function exitFee(amount, terms) {
13
+ const proportional = (amount * BigInt(terms.feeBps)) / 10000n;
14
+ const floor = BigInt(terms.minFeeUnits);
15
+ const fee = proportional > floor ? proportional : floor;
16
+ return fee > amount ? amount : fee;
17
+ }
18
+ export async function exitTerms(service, fetchImpl = fetch) {
19
+ const response = await fetchImpl(`${service}/exits`, { signal: AbortSignal.timeout(20_000) });
20
+ if (response.status === 404)
21
+ throw new Error("Fast exits are off on this network");
22
+ if (!response.ok)
23
+ throw new Error(`${service}/exits answered ${response.status}`);
24
+ return (await response.json());
25
+ }
26
+ export async function exitRecord(service, tx, fetchImpl = fetch) {
27
+ const response = await fetchImpl(`${service}/exits/${tx}`, { signal: AbortSignal.timeout(20_000) });
28
+ if (response.status === 404)
29
+ return undefined;
30
+ if (!response.ok)
31
+ throw new Error(`${service}/exits answered ${response.status}`);
32
+ return (await response.json()).exits[0];
33
+ }
34
+ export function createExitChain(network, account) {
35
+ const bridge = network.bridge;
36
+ const l3 = { read: createPublicClient({ chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000 }) }) };
37
+ const l3Wallet = createWalletClient({ account, chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000 }) });
38
+ const parent = bridge ? createPublicClient({ chain: parentChainOf(bridge), transport: http(bridge.rpc, { timeout: 20_000 }) }) : undefined;
39
+ const parentWallet = bridge ? createWalletClient({ account, chain: parentChainOf(bridge), transport: http(bridge.rpc, { timeout: 20_000 }) }) : undefined;
40
+ return {
41
+ account: account.address,
42
+ bountyBalance: (token) => l3.read.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
43
+ async sendToDesk(token, desk, amount) {
44
+ const hash = await l3Wallet.writeContract({ address: token, abi: erc20Abi, functionName: "transfer", args: [desk, amount] });
45
+ const receipt = await l3.read.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
46
+ if (receipt.status !== "success")
47
+ throw new Error(`the transfer to the desk reverted (${hash})`);
48
+ return hash;
49
+ },
50
+ parentEth: () => (parent ? parent.getBalance({ address: account.address }) : Promise.resolve(0n)),
51
+ async sendParent(item) {
52
+ if (!parent || !parentWallet)
53
+ throw new Error(`${network.name} has no parent chain`);
54
+ const hash = await parentWallet.sendTransaction({ to: item.to, data: item.data, value: BigInt(item.value ?? "0") });
55
+ const receipt = await parent.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
56
+ if (receipt.status !== "success")
57
+ throw new Error(`Relay's transaction ${hash} reverted`);
58
+ return hash;
59
+ },
60
+ };
61
+ }
62
+ /** Gas the two Relay transactions on Robinhood Chain need, with room to spare. */
63
+ export const RELAY_GAS_WEI = 20000000000000n;
64
+ export async function exit(network, chain, options) {
65
+ const { log, dry } = options;
66
+ const fetchImpl = options.fetchImpl ?? fetch;
67
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
68
+ if (options.toSolana !== undefined) {
69
+ if (!isSolanaAddress(options.toSolana))
70
+ throw new Error(`"${options.toSolana}" is not a Solana address`);
71
+ if (network.id !== "mainnet")
72
+ throw new Error("--to-solana is mainnet only: Relay connects Solana with Robinhood Chain mainnet, not the testnets");
73
+ }
74
+ const terms = await exitTerms(network.service, fetchImpl);
75
+ const balance = await chain.bountyBalance(terms.token);
76
+ const amount = options.amount === undefined ? balance : parseUnits(options.amount, 6);
77
+ const min = BigInt(terms.minUnits);
78
+ const max = BigInt(terms.maxUnits);
79
+ const symbol = network.bountySymbol;
80
+ const show = (v) => `${formatUnits(v, 6)} ${symbol}`;
81
+ if (amount === 0n)
82
+ throw new Error(`The wallet holds no ${symbol} on ${network.name}`);
83
+ if (amount > balance)
84
+ throw new Error(`The wallet holds ${show(balance)} on ${network.name}, less than ${show(amount)}`);
85
+ if (amount < min)
86
+ throw new Error(`An exit is at least ${show(min)}`);
87
+ if (amount > max)
88
+ throw new Error(`An exit is at most ${show(max)}; send the rest in another exit`);
89
+ const fee = exitFee(amount, terms);
90
+ const net = amount - fee;
91
+ log(`Exit ${show(amount)} from ${network.name}: ${show(net)} arrives on the parent chain (chain ${terms.payoutChainId}) after a ${show(fee)} fee.`);
92
+ if (options.toSolana !== undefined) {
93
+ const eth = await chain.parentEth();
94
+ if (eth < RELAY_GAS_WEI) {
95
+ throw new Error(`Moving it on to Solana needs about 0.00002 ETH on ${network.bridge?.name ?? "the parent chain"} for gas; the wallet holds ${formatUnits(eth, 18)}. \`fund --from-solana\` brings some.`);
96
+ }
97
+ }
98
+ const solanaQuote = (net) => (options.quote ?? relayQuote)({ user: chain.account, recipient: options.toSolana, originChainId: terms.payoutChainId, destinationChainId: SOLANA_CHAIN_ID, originCurrency: terms.payoutToken, destinationCurrency: SOLANA_USDC, amount: net }, fetchImpl);
99
+ // Priced before anything leaves BrainChain, so a refused Solana leg never strands the exit halfway.
100
+ if (options.toSolana !== undefined) {
101
+ const preview = await solanaQuote(net);
102
+ checkReturn(preview);
103
+ log(`Relay quotes ${preview.outFormatted} USDC on Solana for it.`);
104
+ }
105
+ if (dry) {
106
+ log("Dry run, nothing sent.");
107
+ return;
108
+ }
109
+ const tx = await chain.sendToDesk(terms.token, terms.desk, amount);
110
+ log(`Sent to the desk on ${network.name} (${tx}). Waiting for the payout; the desk picks it up within a round, usually a few minutes.`);
111
+ const end = Date.now() + (options.waitMs ?? 1_800_000);
112
+ let last = "";
113
+ let record;
114
+ for (;;) {
115
+ record = await exitRecord(network.service, tx, fetchImpl).catch(() => undefined);
116
+ if (record?.state === "paid" || record?.state === "returned")
117
+ break;
118
+ if (record?.reason && record.reason !== last)
119
+ log(`Waiting: ${record.reason}.`);
120
+ last = record?.reason ?? last;
121
+ if (Date.now() > end) {
122
+ 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}.`);
123
+ return;
124
+ }
125
+ await sleep(options.pollMs ?? 10_000);
126
+ }
127
+ if (record.state === "returned") {
128
+ log(`The desk sent it back on ${network.name}: ${record.reason ?? "no reason given"} (${record.returnTx}).`);
129
+ process.exitCode = 1;
130
+ return;
131
+ }
132
+ log(`Paid on the parent chain: ${show(BigInt(record.net))} (${record.payoutTx}).`);
133
+ if (options.toSolana === undefined)
134
+ return;
135
+ const quote = await solanaQuote(BigInt(record.net));
136
+ checkReturn(quote);
137
+ await sendEvmSteps(quote, chain);
138
+ log(`Moving ${show(BigInt(record.net))} to Solana: ${quote.outFormatted} USDC to ${options.toSolana}.`);
139
+ const status = await waitForRelay(quote.requestId, 600_000, fetchImpl);
140
+ if (status === "success")
141
+ log(`Arrived on Solana. Relay request ${quote.requestId}.`);
142
+ else {
143
+ log(`Relay reports "${status}" for request ${quote.requestId}. A refund comes back to the wallet on the parent chain.`);
144
+ process.exitCode = 1;
145
+ }
146
+ }
147
+ async function sendEvmSteps(quote, chain) {
148
+ for (const step of quote.steps) {
149
+ for (const item of step.items)
150
+ await chain.sendParent(item.data);
151
+ }
152
+ }
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.17.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": {