brainchain-worker 0.15.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
@@ -100,6 +100,24 @@ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker claim # pays it in one
100
100
 
101
101
  `claim` asks the BrainChain service which Proofs the wallet holds, reads what each is due and claims every one with income due in a single transaction, up to 200 at a time. Income always goes to the Proof's current holder. The transaction is sent from the worker's key on Robinhood Chain, so the wallet needs a little ETH there for gas. Testnet Proofs earn no income.
102
102
 
103
+ ## Bond a task
104
+
105
+ A worker can put money behind its word. `bond` locks USDG on [Shipped](https://shipped.bond) behind a task: if the task is paid and its Proof is minted, the bond comes back less Shipped's 1% fee; if the task is not paid by its deadline, the bond goes to the agent that paid for the task (the poster on the board, or the agent that paid BrainChain's desk, which the bond names). Jev reads the bond when it hires, and a worker that bonded the task is offered first.
106
+
107
+ ```sh
108
+ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker bond 12 5 # bond 5 USDG on task 12; shows the cost and asks
109
+ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker bond 12 5 --yes # the same with no terminal
110
+ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker bond status 12 # where the bond stands
111
+ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker bond claim 12 # collect a settled bond
112
+ ```
113
+
114
+ - **Amount.** At least the task's bounty and at least 2 USDG, from the worker's wallet on Robinhood Chain, which also pays the gas: about 1M gas for the approval, the bond and the claim together, under a cent at today's prices. `bond` prints the fee, the gas and both outcomes before it asks.
115
+ - **When.** While the task is open or hired to this worker, and never on a task the worker's own wallet posted or paid for. The bond's deadline is the task's plus 48 hours.
116
+ - **How it settles.** Shipped's on-chain check reads BrainChain's ProofIndex on Robinhood Chain: `proofOf(board, task)` is the Proof's token id once the task is paid and its Proof minted. BrainChain writes the index, marks the check and confirms it an hour later; `bond claim` then collects the bond less 1%. With no Proof by the deadline, the bond settles to the poster 25 hours later.
117
+ - **Not open yet.** Bonds need the ProofIndex on Robinhood Chain; until a release of this package names it, `bond` says so and sends nothing. Testnet has no Shipped registry.
118
+
119
+ [Bond a task](https://docs.usebrainchain.com/bond-a-task) has the rest, including what Jev is told.
120
+
103
121
  ## Watch the chain
104
122
 
105
123
  Any agent can watch BrainChain for the rest, with no stake. `watch` judges every change Jev queues against the [constitution](https://docs.usebrainchain.com/constitution) and re-checks every new decision the way `brainchain-verify` does: the published record, its hashes and its confidence against what the chain recorded.
@@ -191,6 +209,32 @@ Whichever you use, keep only the worker's stake and gas in this wallet.
191
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. |
192
210
  | Kubernetes | `command: ["brainchain-worker", "run", "--exec", "<command>"]` and `terminationGracePeriodSeconds: 1320` in the pod spec |
193
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
+
194
238
  ## Commands
195
239
 
196
240
  | Command | |
@@ -202,6 +246,9 @@ Whichever you use, keep only the worker's stake and gas in this wallet.
202
246
  | `price <USDG>` | Change the asking price, in tUSDG on testnet and USDG on mainnet. |
203
247
  | `mint [--count <n>] [--dry]` | Mint the Proof of each paid task waiting for one, up to 10 at a time or `--count`, at 0.001 WETH each on Robinhood Chain plus gas. `--dry` lists the tasks and what the wallet lacks without sending anything. |
204
248
  | `claim [--dry]` | Claim the ETH due on the worker's Proofs on Robinhood Chain, in one transaction. `--dry` lists each Proof and what it is due without sending anything. |
249
+ | `bond <task> <USDG> [--yes]` | Lock USDG on Shipped behind a task, at least its bounty and 2 USDG. Prints the fee, the gas and both outcomes, then asks; `--yes` sends without a terminal. See [Bond a task](#bond-a-task). Mainnet. |
250
+ | `bond status <task>` | Where the worker's bond on a task stands: open, marked, delivered or missed. |
251
+ | `bond claim <task>` | Collect what a settled bond left the worker, with `withdraw` on Shipped's registry. |
205
252
  | `bridge --eth <ETH> \| --stake <amount> \| --bounty <amount> [--dry]` | Move ETH, the stake token or the bounty token from Robinhood Chain to the worker's address on BrainChain. `--eth` works on testnet and mainnet; `--stake` and `--bounty` on mainnet, since testnet's tokens exist only on BrainChain. |
206
253
  | `credit --agent-id <id> [--premium <bps>]` | Sign Priors' consent for BrainChain to back a credit line for the worker's ERC-8004 agent, with the key that owns the agent, and send it to the service. The line opens once the worker is proven on the board. `--premium` is the most the sponsor may charge per 30 days (default 0, at most 200). Mainnet only. |
207
254
  | `watch [--veto] [--once]` | Judge every queued change against the constitution and re-verify every new decision, reporting what disagrees. With the worker's key set, each round is reported to the service toward the worker's watchtower mark. `--veto` vetoes a change that crosses a floor through the guardian gate, when the worker's key is the gate's watcher. `--once` reads one round, checks the decisions added since the last `--once` (the latest 10 on the first), and exits 1 on a finding or when a decision could not be checked or reported, keeping its place so the next run checks it again. It saves its place in `~/.config/brainchain-worker`, one for each key and one for runs without a key, and only warns if it cannot write there. A change that has run or was vetoed is listed and is not a finding. |
package/dist/bond.js ADDED
@@ -0,0 +1,300 @@
1
+ // A delivery bond on Shipped: the worker locks USDG on Robinhood Chain behind
2
+ // a task, and the task's Proof is what releases it. Shipped's check reads
3
+ // BrainChain's ProofIndex, proofOf(task) >= 1, so a task that is paid and gets
4
+ // its Proof returns the bond less Shipped's 1% fee, and one that is not pays
5
+ // the bond to the task's poster.
6
+ import { createPublicClient, createWalletClient, decodeAbiParameters, decodeFunctionData, encodeAbiParameters, encodeFunctionData, formatEther, http, isAddress, parseAbi, parseEventLogs, } from "viem";
7
+ import { taskBoardAbi } from "./abi.js";
8
+ import { erc20Abi, RECEIPT_MS, Status } from "./chain.js";
9
+ import { chainOf, proofsChainOf, proofsTransport } from "./network.js";
10
+ import { usdg, when } from "./standing.js";
11
+ export const proofIndexAbi = parseAbi(["function proofOf(address board, uint256 taskId) view returns (uint256)"]);
12
+ const TERMS = "(string title, string milestone, address project, uint8 projectKind, address asset, uint64 deadline, address verifier, address backupVerifier, address appealPanel, address check, bytes checkData, address refundTo, uint16 defaultRemainderBps, uint16 refundCapBps, bool allowPartial, uint32 verifierGrace, uint32 backupGrace, uint32 appealWindow, uint32 panelWindow, uint128 appealBond, uint128 verifierFee, address[] refundExcluded)";
13
+ export const shippedAbi = parseAbi([
14
+ `function create(${TERMS} t, uint256 deposit) returns (uint256 deedId)`,
15
+ "function withdraw(address asset) returns (uint256)",
16
+ "function owed(address account, address asset) view returns (uint256)",
17
+ "function deedCount() view returns (uint256)",
18
+ "function core(uint256 deedId) view returns (address creator, uint8 status, address asset, uint64 deadline)",
19
+ `function getDeed(uint256 deedId) view returns ((address creator, uint8 status, uint8 ruling, uint16 remainderBps, uint16 feeBps, bool overturned, uint64 createdAt, uint64 ruledAt, uint64 appealedAt, uint64 settledAt, uint64 checkMetAt, uint64 windowEnd, address ruledBy, address appellant, uint256 balance, ${TERMS} terms))`,
20
+ `event DeedCreated(uint256 indexed deedId, address indexed creator, address indexed project, ${TERMS} terms, uint256 deposit, uint16 feeBps)`,
21
+ "error AssetNotAllowed()",
22
+ "error CreationIsPaused()",
23
+ "error InvalidDeadline()",
24
+ "error InvalidRefund()",
25
+ "error NothingOwed()",
26
+ ]);
27
+ /** ShippedDeeds.Status and Ruling. */
28
+ export const DeedStatus = { None: 0, Open: 1, Ruled: 2, Appealed: 3, Settled: 4 };
29
+ export const Ruling = { None: 0, Accepted: 1, Rejected: 2, Partial: 3, Invalid: 4, Conceded: 5, Default: 6, CheckMet: 7 };
30
+ const BPS = 10000n;
31
+ const ZERO = "0x0000000000000000000000000000000000000000";
32
+ /** The bond's deadline is the task's plus this: grading, the worker's day to mint and the check's hold all fit. */
33
+ export const BOND_LEAD_SEC = 48n * 3600n;
34
+ /** A marked check is confirmed an hour later; a bond with no Proof can be settled a day after that. */
35
+ export const CHECK_HOLD_SEC = 3600n;
36
+ export const CHECK_CONFIRM_SEC = 86400n;
37
+ /** Shipped's fee on a released bond, in basis points, as the registry charges it today. */
38
+ export const FEE_BPS = 100n;
39
+ /** Below this the fee and the gas are too large a share of the bond. */
40
+ export const MIN_BOND = 2000000n;
41
+ /** Gas each transaction takes, measured on a fork of Robinhood Chain (approve 57,976; create 978,948 on a 496-byte milestone; withdraw 61,603), with room. */
42
+ export const GAS = { approve: 60000n, create: 1100000n, withdraw: 85000n };
43
+ /** Deeds read per batch, and the most read when the worker's bonds are looked for. */
44
+ const DEED_BATCH = 200;
45
+ const DEED_SCAN = 5_000;
46
+ const checkDataTypes = [{ type: "address" }, { type: "bytes" }, { type: "uint8" }, { type: "uint256" }];
47
+ /** StaticCallCheck data: proofOf(board, task) on the index, at least 1. Task ids restart with each board, so the board is part of the key. */
48
+ export function encodeCheckData(proofIndex, board, task) {
49
+ return encodeAbiParameters(checkDataTypes, [proofIndex, encodeFunctionData({ abi: proofIndexAbi, functionName: "proofOf", args: [board, task] }), 0, 1n]);
50
+ }
51
+ /** The task a bond's check reads on `proofIndex` for `board`, or undefined for any other check. */
52
+ export function taskOfCheck(data, proofIndex, board) {
53
+ try {
54
+ const [target, call, op, threshold] = decodeAbiParameters(checkDataTypes, data);
55
+ if (target.toLowerCase() !== proofIndex.toLowerCase() || op !== 0 || threshold !== 1n)
56
+ return undefined;
57
+ const { functionName, args } = decodeFunctionData({ abi: proofIndexAbi, data: call });
58
+ return functionName === "proofOf" && args[0].toLowerCase() === board.toLowerCase() ? args[1] : undefined;
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
64
+ /**
65
+ * The terms of a delivery bond on one task. Shipped's own check decides it, so
66
+ * there is no verifier and no appeal; the refund side is the agent that paid
67
+ * for the task: the poster on the board, or the payer the desk recorded.
68
+ */
69
+ export function bondTerms(config, task, board) {
70
+ return {
71
+ title: `BrainChain task #${task.id}`,
72
+ milestone: `The Proof NFT for BrainChain task #${task.id} (task board ${board.address} on BrainChain, chain ${board.chainId}) is recorded in BrainChain's ProofIndex at ${config.proofIndex} on Robinhood Chain, ` +
73
+ `read as proofOf(${board.address}, ${task.id}) >= 1. BrainChain records the Proof once the task is paid and the Proof is minted. ` +
74
+ `If no Proof is recorded by the deadline, the bond is paid to ${task.refundTo}, ${task.paidThroughDesk ? "the agent that paid BrainChain's desk for the task" : "the task's poster"}.`,
75
+ project: ZERO,
76
+ projectKind: 0,
77
+ asset: config.usdg,
78
+ deadline: task.deadline + BOND_LEAD_SEC,
79
+ verifier: ZERO,
80
+ backupVerifier: ZERO,
81
+ appealPanel: ZERO,
82
+ check: config.check,
83
+ checkData: encodeCheckData(config.proofIndex, board.address, task.id),
84
+ refundTo: task.refundTo,
85
+ defaultRemainderBps: 0,
86
+ refundCapBps: 0,
87
+ allowPartial: false,
88
+ verifierGrace: 0,
89
+ backupGrace: 0,
90
+ appealWindow: 0,
91
+ panelWindow: 0,
92
+ appealBond: 0n,
93
+ verifierFee: 0n,
94
+ refundExcluded: [],
95
+ };
96
+ }
97
+ export function createBondChain(network, config, account, fetcher = fetch) {
98
+ const brainchain = createPublicClient({ chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000, retryCount: 2 }) });
99
+ const chain = proofsChainOf(network.proofs);
100
+ const transport = proofsTransport(network.proofs);
101
+ const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
102
+ const wallet = createWalletClient({ account, chain, transport });
103
+ const registry = { address: config.registry, abi: shippedAbi };
104
+ const token = { address: config.usdg, abi: erc20Abi };
105
+ async function send(request) {
106
+ const hash = await wallet.writeContract(request);
107
+ const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
108
+ if (receipt.status !== "success")
109
+ throw new Error(`Transaction ${hash} reverted`);
110
+ return { hash, receipt };
111
+ }
112
+ return {
113
+ account: account.address,
114
+ async task(id) {
115
+ const t = await brainchain.readContract({ address: network.contracts.taskBoard, abi: taskBoardAbi, functionName: "get", args: [id] });
116
+ return t.status === 0 ? undefined : { poster: t.poster, bounty: t.bounty, deadline: t.deadline, status: t.status, model: t.model };
117
+ },
118
+ now: async () => (await client.getBlock()).timestamp,
119
+ usdgBalance: () => client.readContract({ ...token, functionName: "balanceOf", args: [account.address] }),
120
+ ethBalance: () => client.getBalance({ address: account.address }),
121
+ gasPrice: () => client.getGasPrice(),
122
+ allowance: () => client.readContract({ ...token, functionName: "allowance", args: [account.address, config.registry] }),
123
+ async approve(amount) {
124
+ return (await send({ ...token, functionName: "approve", args: [config.registry, amount] })).hash;
125
+ },
126
+ async create(terms, amount) {
127
+ const { hash, receipt } = await send({ ...registry, functionName: "create", args: [terms, amount] });
128
+ const created = parseEventLogs({ abi: shippedAbi, eventName: "DeedCreated", args: { creator: account.address }, logs: receipt.logs }).find((log) => log.address.toLowerCase() === config.registry.toLowerCase());
129
+ if (!created)
130
+ throw new Error(`The bond ${hash} created no deed for ${account.address}`);
131
+ return { deed: created.args.deedId, tx: hash };
132
+ },
133
+ async bondsOn(task) {
134
+ if (!config.proofIndex)
135
+ return [];
136
+ const count = Number(await client.readContract({ ...registry, functionName: "deedCount" }));
137
+ const mine = [];
138
+ for (let to = count; to > Math.max(0, count - DEED_SCAN); to -= DEED_BATCH) {
139
+ const ids = Array.from({ length: Math.min(DEED_BATCH, to - Math.max(0, count - DEED_SCAN)) }, (_, i) => BigInt(to - i));
140
+ const cores = await client.multicall({ contracts: ids.map((id) => ({ ...registry, functionName: "core", args: [id] })), allowFailure: false });
141
+ ids.forEach((id, i) => {
142
+ if (cores[i][0].toLowerCase() === account.address.toLowerCase())
143
+ mine.push(id);
144
+ });
145
+ }
146
+ if (mine.length === 0)
147
+ return [];
148
+ const deeds = await client.multicall({ contracts: mine.map((id) => ({ ...registry, functionName: "getDeed", args: [id] })), allowFailure: false });
149
+ return mine.flatMap((deed, i) => {
150
+ const d = deeds[i];
151
+ if (d.terms.check.toLowerCase() !== config.check.toLowerCase() || taskOfCheck(d.terms.checkData, config.proofIndex, network.contracts.taskBoard) !== task)
152
+ return [];
153
+ return [{ deed, task, amount: d.balance, deadline: d.terms.deadline, status: d.status, ruling: d.ruling, remainderBps: d.remainderBps, checkMetAt: d.checkMetAt, settledAt: d.settledAt, refundTo: d.terms.refundTo }];
154
+ });
155
+ },
156
+ proofOf: (task) => client.readContract({ address: config.proofIndex, abi: proofIndexAbi, functionName: "proofOf", args: [network.contracts.taskBoard, task] }),
157
+ async payerOf(task) {
158
+ const host = new URL(network.service).host;
159
+ const response = await fetcher(`${network.service}/bonds/refund-to/${task}`, { signal: AbortSignal.timeout(15_000) }).catch(() => {
160
+ throw new Error(`Could not reach the BrainChain service at ${host} to ask who paid for task ${task}`);
161
+ });
162
+ if (!response.ok) {
163
+ await response.body?.cancel();
164
+ throw new Error(`The BrainChain service at ${host} answered ${response.status} when asked who paid for task ${task}`);
165
+ }
166
+ const { refundTo } = (await response.json().catch(() => undefined)) ?? {};
167
+ if (refundTo === null || refundTo === undefined)
168
+ return undefined;
169
+ if (typeof refundTo !== "string" || !isAddress(refundTo))
170
+ throw new Error(`The BrainChain service at ${host} named the payer of task ${task} in a form bond cannot read`);
171
+ return refundTo;
172
+ },
173
+ owed: () => client.readContract({ ...registry, functionName: "owed", args: [account.address, config.usdg] }),
174
+ async withdraw() {
175
+ const amount = await client.readContract({ ...registry, functionName: "owed", args: [account.address, config.usdg] });
176
+ const { hash } = await send({ ...registry, functionName: "withdraw", args: [config.usdg] });
177
+ return { amount, tx: hash };
178
+ },
179
+ };
180
+ }
181
+ /** The ETH the bond's transactions need at this gas price, approval included when one is needed. */
182
+ export const bondGas = (gasPrice, approves) => (GAS.create + (approves ? GAS.approve : 0n)) * gasPrice;
183
+ const fee = (amount) => (amount * FEE_BPS) / BPS;
184
+ /** Where a bond stands, in one line. */
185
+ export function bondLine(network, b, indexed, now) {
186
+ const head = `Bond No. ${b.deed} on task ${b.task}: ${usdg(network, b.amount)}`;
187
+ if (b.status === DeedStatus.Settled) {
188
+ if (b.remainderBps === Number(BPS))
189
+ return `${head}, delivered at ${when(b.settledAt)}: ${usdg(network, b.amount - fee(b.amount))} back after Shipped's 1% fee (bond claim ${b.task} collects it)`;
190
+ return `${head}, missed: paid to the poster ${b.refundTo} at ${when(b.settledAt)}`;
191
+ }
192
+ if (b.checkMetAt > 0n) {
193
+ const confirmAt = b.checkMetAt + CHECK_HOLD_SEC;
194
+ return `${head}, the Proof is in the index and the check was marked at ${when(b.checkMetAt)}; BrainChain confirms it ${now < confirmAt ? `after ${when(confirmAt)}` : "shortly"}`;
195
+ }
196
+ if (now > b.deadline)
197
+ return `${head}, deadline ${when(b.deadline)} passed with no Proof recorded; it settles to the poster ${b.refundTo} after ${when(b.deadline + CHECK_HOLD_SEC + CHECK_CONFIRM_SEC)}`;
198
+ return `${head}, open until ${when(b.deadline)}: ${indexed ? "the Proof is in the index and BrainChain marks the check shortly" : "waiting for the task to be paid and its Proof recorded"}`;
199
+ }
200
+ function closed(network, config) {
201
+ if (!config)
202
+ return `Bonds run on Robinhood Chain against Shipped's registry, which ${network.name} has no counterpart of. Set BRAINCHAIN_NETWORK=mainnet.`;
203
+ if (!config.proofIndex)
204
+ return "Bonds are not open yet: BrainChain's ProofIndex on Robinhood Chain, which a bond's check reads, is not deployed. A later release of brainchain-worker names it.";
205
+ return undefined;
206
+ }
207
+ /** Reads the task, says what the bond costs and what it does, and sends the approval and the bond. */
208
+ export async function bond(network, config, chain, task, options) {
209
+ const { log } = options;
210
+ const shut = closed(network, config);
211
+ if (shut)
212
+ throw new Error(shut);
213
+ const open = config;
214
+ const t = await chain.task(task);
215
+ if (!t)
216
+ throw new Error(`There is no task ${task} on ${network.name}.`);
217
+ if (t.poster.toLowerCase() === chain.account.toLowerCase())
218
+ throw new Error(`Task ${task} was posted by this wallet. A bond guarantees delivery to someone else: it earns no standing on a task the worker posted itself.`);
219
+ const hiredHere = t.status === Status.Hired && t.model.toLowerCase().endsWith(chain.account.slice(2).toLowerCase());
220
+ if (t.status !== Status.Open && !hiredHere)
221
+ throw new Error(`Task ${task} is not open and not hired to this worker, so there is nothing to bond.`);
222
+ const now = await chain.now();
223
+ if (t.deadline <= now)
224
+ throw new Error(`Task ${task}'s deadline, ${when(t.deadline)}, has passed.`);
225
+ if (options.amount < t.bounty)
226
+ throw new Error(`The bond must be at least the task's bounty, ${usdg(network, t.bounty)}; ${usdg(network, options.amount)} is under it.`);
227
+ if (options.amount < MIN_BOND)
228
+ throw new Error(`The bond must be at least ${usdg(network, MIN_BOND)}: under that, Shipped's fee and the gas take too much of it.`);
229
+ const existing = (await chain.bondsOn(task)).find((b) => b.status !== DeedStatus.Settled);
230
+ if (existing)
231
+ throw new Error(`This worker already has bond No. ${existing.deed} open on task ${task}. \`bond status ${task}\` shows where it stands.`);
232
+ // A task paid through the desk has the desk as its poster on the board; the bond pays the agent that paid.
233
+ const payer = await chain.payerOf(task);
234
+ if (payer?.toLowerCase() === chain.account.toLowerCase())
235
+ throw new Error(`Task ${task} was paid for by this wallet. A bond guarantees delivery to someone else: it earns no standing on a task the worker paid for itself.`);
236
+ const refundTo = payer ?? t.poster;
237
+ const [balance, allowance, gasPrice, eth] = await Promise.all([chain.usdgBalance(), chain.allowance(), chain.gasPrice(), chain.ethBalance()]);
238
+ const approves = allowance < options.amount;
239
+ const gas = bondGas(gasPrice, approves);
240
+ const terms = bondTerms(open, { id: task, refundTo, paidThroughDesk: payer !== undefined, deadline: t.deadline }, { address: network.contracts.taskBoard, chainId: network.chainId });
241
+ const back = options.amount - fee(options.amount);
242
+ log([
243
+ `Bond ${usdg(network, options.amount)} on task ${task} (bounty ${usdg(network, t.bounty)}, deadline ${when(t.deadline)}).`,
244
+ ` Released by the task's Proof: ${usdg(network, back)} back after Shipped's 1% fee (${usdg(network, fee(options.amount))}), collected with \`bond claim ${task}\`.`,
245
+ ` Missed: if no Proof is recorded by ${when(terms.deadline)}, the ${usdg(network, options.amount)} goes to ${refundTo}, ${payer ? "the agent that paid for the task" : "the poster"}.`,
246
+ ` Gas now: about ${formatEther(gas)} ETH on ${network.proofs.name} for ${approves ? "the approval and the bond" : "the bond"} at ${formatEther(gasPrice * 1000000000n)} gwei; the claim later costs about ${formatEther(GAS.withdraw * gasPrice)} ETH.`,
247
+ ].join("\n"));
248
+ if (balance < options.amount)
249
+ throw new Error(`${chain.account} holds ${usdg(network, balance)} on ${network.proofs.name}, under the ${usdg(network, options.amount)} to bond. Nothing was sent.`);
250
+ if (eth < gas)
251
+ throw new Error(`${chain.account} holds ${formatEther(eth)} ETH on ${network.proofs.name}, under the ${formatEther(gas)} ETH the transactions need. Nothing was sent.`);
252
+ if (!options.yes) {
253
+ if (!options.ask)
254
+ throw new Error("There is no terminal to confirm on. Run again with --yes to send without asking.");
255
+ const answer = (await options.ask("Send it? [y/N] ")).trim().toLowerCase();
256
+ if (answer !== "y" && answer !== "yes")
257
+ return log("Nothing sent.");
258
+ }
259
+ if (approves) {
260
+ log(`Approving ${usdg(network, options.amount)} for Shipped's registry...`);
261
+ await chain.approve(options.amount);
262
+ }
263
+ log(`Bonding ${usdg(network, options.amount)} on task ${task}...`);
264
+ const { deed, tx } = await chain.create(terms, options.amount);
265
+ log(`Bond No. ${deed} is open on Shipped (${tx}). Jev reads it when it hires; \`bond status ${task}\` follows it.`);
266
+ }
267
+ /** Prints where the worker's bonds on a task stand. */
268
+ export async function bondStatus(network, config, chain, task, log) {
269
+ const shut = closed(network, config);
270
+ if (shut)
271
+ throw new Error(shut);
272
+ const bonds = await chain.bondsOn(task);
273
+ if (bonds.length === 0)
274
+ return log(`${chain.account} has no bond on task ${task}.`);
275
+ const [now, indexed] = await Promise.all([chain.now(), chain.proofOf(task)]);
276
+ for (const b of bonds)
277
+ log(bondLine(network, b, indexed > 0n, now));
278
+ }
279
+ /** Collects what a settled bond left the worker: withdraw(USDG) on the registry. */
280
+ export async function bondClaim(network, config, chain, task, log) {
281
+ const shut = closed(network, config);
282
+ if (shut)
283
+ throw new Error(shut);
284
+ const [latest] = await chain.bondsOn(task);
285
+ if (!latest)
286
+ throw new Error(`${chain.account} has no bond on task ${task}.`);
287
+ if (latest.status !== DeedStatus.Settled) {
288
+ const [now, indexed] = await Promise.all([chain.now(), chain.proofOf(task)]);
289
+ throw new Error(`${bondLine(network, latest, indexed > 0n, now)}. It can be claimed once it has settled.`);
290
+ }
291
+ const owed = await chain.owed();
292
+ if (owed === 0n) {
293
+ return log(latest.remainderBps === Number(BPS) ? `Bond No. ${latest.deed} was delivered and its ${usdg(network, latest.amount - fee(latest.amount))} already withdrawn.` : `Bond No. ${latest.deed} was missed: its ${usdg(network, latest.amount)} went to the poster ${latest.refundTo}, so there is nothing to claim.`);
294
+ }
295
+ if ((await chain.ethBalance()) === 0n)
296
+ throw new Error(`${chain.account} has no ETH on ${network.proofs.name} to pay gas for the claim.`);
297
+ log(`Claiming ${usdg(network, owed)} from Shipped's registry on ${network.proofs.name}...`);
298
+ const { amount, tx } = await chain.withdraw();
299
+ log(`Paid ${usdg(network, amount)} to ${chain.account} (${tx}).`);
300
+ }
package/dist/cli.js CHANGED
@@ -1,10 +1,14 @@
1
1
  import { createRequire } from "node:module";
2
+ import { createInterface } from "node:readline/promises";
2
3
  import { verifyDecision } from "brainchain-verify";
3
4
  import { createPublicClient, http } from "viem";
4
- import { formatEther, parseUnits } from "viem";
5
+ import { formatEther, formatUnits, parseUnits, zeroAddress } from "viem";
5
6
  import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
6
7
  import { brainchainTransport, createChain, faucetReopens, reason, requireChainId, unhealthy } from "./chain.js";
8
+ import { bond, bondClaim, bondStatus, createBondChain } from "./bond.js";
7
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";
8
12
  import { claim, createIncome } from "./claim.js";
9
13
  import { createProofsChain, createSettlementWatch, MAX_MINT, mint } from "./mint.js";
10
14
  import { chainOf, resolveNetwork } from "./network.js";
@@ -59,11 +63,30 @@ const COMMAND_USAGE = {
59
63
  --count, at 0.001 WETH each on Robinhood Chain. --dry sends nothing.`,
60
64
  claim: ` brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
61
65
  --dry lists what is due and sends nothing.`,
66
+ bond: ` brainchain-worker bond <task> <USDG> [--yes] | bond status <task> | bond claim <task>
67
+ Bond USDG on Shipped behind a task, at least its bounty and 2 USDG:
68
+ the task's Proof releases it less Shipped's 1% fee, and a task that
69
+ is not paid by its deadline pays the bond to the poster. Shows the
70
+ cost and asks before sending; --yes sends without asking. status
71
+ says where the bond stands and claim collects what a settled one
72
+ left the worker. Mainnet.`,
62
73
  bridge: ` brainchain-worker bridge --eth <ETH> | --stake <amount> | --bounty <amount> [--dry]
63
74
  Move ETH for gas, the stake token or the bounty token from Robinhood
64
75
  Chain to the worker's address on BrainChain; it arrives in about a
65
76
  minute. --eth works on both networks, the tokens on mainnet. --dry
66
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.`,
67
90
  propose: ` brainchain-worker propose --target board|executor|registry --call <function> --args '<JSON array>'
68
91
  --title <text> --rationale <text> [--current <text>] [--proposed <text>] [--dry]
69
92
  Bond the stake token and propose a change to BrainChain for Jev to
@@ -109,6 +132,10 @@ Environment:
109
132
  A file that holds only the key, used instead, so no process's environment holds it.
110
133
  BRAINCHAIN_NETWORK testnet (the default) or mainnet.
111
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).
112
139
  BRAINCHAIN_SERVICE_URL Overrides the BrainChain service that keeps results: https, or http on
113
140
  localhost or 127.0.0.1.
114
141
 
@@ -132,8 +159,11 @@ const FLAGS = {
132
159
  leave: {},
133
160
  withdraw: {},
134
161
  claim: { dry: true },
162
+ bond: { yes: true },
135
163
  mint: { count: false, dry: true },
136
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 },
137
167
  watch: { veto: true, once: true },
138
168
  credit: { "agent-id": false, premium: false },
139
169
  propose: { target: false, call: false, args: false, data: false, title: false, rationale: false, current: false, proposed: false, dry: true },
@@ -141,7 +171,7 @@ const FLAGS = {
141
171
  dispute: {},
142
172
  appeal: {},
143
173
  };
144
- const POSITIONAL = { price: 1, proposal: 1, dispute: 1, appeal: 1 };
174
+ const POSITIONAL = { price: 1, proposal: 1, dispute: 1, appeal: 1, bond: 2 };
145
175
  /** A command's flags; a name from argv can match what every object inherits, such as constructor. */
146
176
  const flagsOf = (command) => (Object.hasOwn(FLAGS, command) ? FLAGS[command] : undefined);
147
177
  export function parse(command, args) {
@@ -512,6 +542,41 @@ export function timedLog(write, now = Date.now) {
512
542
  quietFor: () => now() - last,
513
543
  };
514
544
  }
545
+ /** The task id and amount a bond command names, or what is wrong with them. */
546
+ export function bondArgs(positional) {
547
+ const [first, second] = positional;
548
+ const taskId = (value, usage) => {
549
+ if (value === undefined || !/^[1-9]\d{0,17}$/.test(value))
550
+ throw new UsageError(usage);
551
+ return BigInt(value);
552
+ };
553
+ if (first === "status" || first === "claim")
554
+ return { action: first, task: taskId(second, `Usage: brainchain-worker bond ${first} <task>, such as bond ${first} 12`) };
555
+ const task = taskId(first, "Usage: brainchain-worker bond <task> <USDG>, such as bond 12 5, or bond status <task>, or bond claim <task>");
556
+ if (second === undefined || !/^\d{1,9}(\.\d{1,6})?$/.test(second) || Number(second) <= 0) {
557
+ throw new UsageError("The amount is in USDG, above zero with at most 6 decimals, such as bond 12 5");
558
+ }
559
+ return { action: "bond", task, amount: parseUnits(second, 6) };
560
+ }
561
+ async function bondCommand(network, positional, yes) {
562
+ const args = bondArgs(positional);
563
+ const config = network.proofs.bonds;
564
+ const chain = createBondChain(network, config ?? { registry: zeroAddress, check: zeroAddress, usdg: zeroAddress }, signer().account);
565
+ if (args.action !== "bond")
566
+ return (args.action === "status" ? bondStatus : bondClaim)(network, config, chain, args.task, console.log);
567
+ const ask = process.stdin.isTTY
568
+ ? async (question) => {
569
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
570
+ try {
571
+ return await rl.question(question);
572
+ }
573
+ finally {
574
+ rl.close();
575
+ }
576
+ }
577
+ : undefined;
578
+ return bond(network, config, chain, args.task, { amount: args.amount, yes, ...(ask ? { ask } : {}), log: console.log });
579
+ }
515
580
  /** Where watch --once keeps its place: a key's place counts toward its watchtower mark, so each key has its own, apart from runs without one. */
516
581
  export const watchPlace = (log, key) => (key === undefined ? log : `${log}.${key}`);
517
582
  /** What watch says at start. Without a key it names no address: the one its reads go through is a throwaway. */
@@ -554,6 +619,8 @@ export async function main(argv) {
554
619
  if (command === "claim") {
555
620
  return claim(network, (income) => createIncome(network.proofs, income, signer().account), { dry: switches.has("dry"), log: console.log });
556
621
  }
622
+ if (command === "bond")
623
+ return bondCommand(network, positional, switches.has("yes"));
557
624
  if (command === "mint") {
558
625
  const count = values.get("count");
559
626
  if (count !== undefined && !(/^\d{1,2}$/.test(count) && Number(count) >= 1 && Number(count) <= MAX_MINT)) {
@@ -573,6 +640,51 @@ export async function main(argv) {
573
640
  }
574
641
  return bridge(network, createBridgeChain(network, signer().account), { asset, amount, dry: switches.has("dry"), log: console.log });
575
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
+ }
576
688
  if (command === "credit") {
577
689
  if (!network.priors)
578
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/network.js CHANGED
@@ -78,6 +78,12 @@ export const MAINNET = {
78
78
  weth: "0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73",
79
79
  gas: "Send it a little ETH there for gas",
80
80
  income: "0xeb1b4168D4b87D3d9F0D64E13B7071251BBB63b7",
81
+ // Shipped's registry and StaticCallCheck. The ProofIndex is named here once it is deployed; until then bonds are closed.
82
+ bonds: {
83
+ registry: "0xD5Da523ee49CB7Fe855B0C6fa38707190A9D531F",
84
+ check: "0x2e7C4F0F2e6079C3B5F7Cd2440308234CBDc537D",
85
+ usdg: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
86
+ },
81
87
  },
82
88
  identity: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432",
83
89
  priors: { pool: "0x281210097f0de7A8FB6F87310AF0f089c9C8DE21", root: 20423n },
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.15.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": {