brainchain-worker 0.18.3 → 0.18.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -42
- package/dist/bond.js +53 -22
- package/dist/chain.js +58 -5
- package/dist/cli.js +84 -56
- package/dist/credit.js +4 -1
- package/dist/exit.js +82 -19
- package/dist/key.js +24 -10
- package/dist/mint.js +3 -1
- package/dist/network.js +2 -0
- package/dist/propose.js +29 -32
- package/dist/relay.js +18 -12
- package/dist/solana.js +177 -36
- package/dist/watch.js +80 -23
- package/dist/work.js +11 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import { brainchainTransport, createChain, faucetReopens, reason, requireChainId
|
|
|
8
8
|
import { bond, bondClaim, bondStatus, createBondChain } from "./bond.js";
|
|
9
9
|
import { assetOf, bridge, createBridgeChain } from "./bridge.js";
|
|
10
10
|
import { createExitChain, exit as exitToParent } from "./exit.js";
|
|
11
|
-
import { createSolanaFunding, fundFromSolana,
|
|
11
|
+
import { createSolanaFunding, fundFromSolana, solanaKeypair } from "./solana.js";
|
|
12
12
|
import { claim, createIncome } from "./claim.js";
|
|
13
13
|
import { createProofsChain, createSettlementWatch, MAX_MINT, mint } from "./mint.js";
|
|
14
14
|
import { chainOf, resolveNetwork } from "./network.js";
|
|
@@ -18,7 +18,7 @@ import { openStore, readWatched, writeWatched } from "./state.js";
|
|
|
18
18
|
import { clearKeyFromEnvironment, keyConfigured, keyText, writeKeyFile } from "./key.js";
|
|
19
19
|
import { createCreditChain, credit } from "./credit.js";
|
|
20
20
|
import { appeal, createDisputeChain, dispute } from "./dispute.js";
|
|
21
|
-
import { createReporter, createWatch, createWatchChain, requireWatcher } from "./watch.js";
|
|
21
|
+
import { createReporter, createWatch, createWatchChain, outcome, requireWatcher } from "./watch.js";
|
|
22
22
|
import { createWork, firstProgram, npxUnderSupervisor, programFound, shellCommand, stopLeftover, stopper, unseen, watchStarter } from "./work.js";
|
|
23
23
|
/** How many of the latest decisions a first watch --once checks. */
|
|
24
24
|
const FIRST_RUN_DECISIONS = 10n;
|
|
@@ -46,9 +46,13 @@ const COMMAND_USAGE = {
|
|
|
46
46
|
place of the key.`,
|
|
47
47
|
join: ` brainchain-worker join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]
|
|
48
48
|
Stake the minimum and apply to take tasks. The name is at
|
|
49
|
-
most 60 characters
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
most 60 characters and the description at most 500, counted
|
|
50
|
+
as UTF-16 units: two or more for an emoji. A worker that is
|
|
51
|
+
allowed or has an application waiting needs neither, and join
|
|
52
|
+
says so if given them: it stakes back up to the minimum.
|
|
53
|
+
--agent-id (mainnet) names the worker's ERC-8004 agent on
|
|
54
|
+
Robinhood Chain, so its BrainChain record is written to that
|
|
55
|
+
identity.`,
|
|
52
56
|
run: ` brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once] [--mint]
|
|
53
57
|
Do every task Jev hires the worker for: the command reads the
|
|
54
58
|
task on stdin and prints the deliverable on stdout, within
|
|
@@ -60,32 +64,49 @@ const COMMAND_USAGE = {
|
|
|
60
64
|
price: ` brainchain-worker price <USDG> Change your asking price per task.`,
|
|
61
65
|
mint: ` brainchain-worker mint [--count <n>] [--dry]
|
|
62
66
|
Mint a Proof for each paid task waiting for one, up to 10 or
|
|
63
|
-
--count, at 0.001 WETH each on Robinhood Chain.
|
|
67
|
+
--count, at 0.001 WETH each on Robinhood Chain. A task stays
|
|
68
|
+
listed until the service has seen its mint, which can take minutes,
|
|
69
|
+
and a mint before then pays for another piece. --dry lists them and
|
|
70
|
+
sends nothing.`,
|
|
64
71
|
claim: ` brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
|
|
65
72
|
--dry lists what is due and sends nothing.`,
|
|
66
73
|
bond: ` brainchain-worker bond <task> <USDG> [--yes] | bond status <task> | bond claim <task>
|
|
67
|
-
Bond USDG on Shipped behind a task
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
Bond USDG on Shipped behind a task Jev hired this worker for, at
|
|
75
|
+
least its bounty and 2 USDG. If the task is paid and its Proof
|
|
76
|
+
recorded by the bond's deadline, the task's plus 48 hours, the bond
|
|
77
|
+
comes back less Shipped's 1% fee; if not, it goes to the agent that
|
|
78
|
+
paid BrainChain's desk for the task, or to the poster for a task
|
|
79
|
+
posted on the board directly. Shows the cost and asks before
|
|
80
|
+
sending; --yes sends without asking. status says where the bond
|
|
81
|
+
stands and claim collects what Shipped's registry owes the worker.
|
|
82
|
+
Mainnet.`,
|
|
73
83
|
bridge: ` brainchain-worker bridge --eth <ETH> | --stake <amount> | --bounty <amount> [--dry]
|
|
74
84
|
Move ETH for gas, the stake token or the bounty token from Robinhood
|
|
75
85
|
Chain to the worker's address on BrainChain; it arrives in about a
|
|
76
|
-
minute.
|
|
77
|
-
checks the balances and sends
|
|
86
|
+
minute. It takes one of the three at a time. --eth works on both
|
|
87
|
+
networks, the tokens on mainnet. --dry checks the balances and sends
|
|
88
|
+
nothing.`,
|
|
78
89
|
exit: ` brainchain-worker exit [--amount <USDG>] [--to-solana <address>] [--dry]
|
|
79
|
-
|
|
80
|
-
bridge's seven days: the desk pays it on Robinhood
|
|
81
|
-
|
|
82
|
-
the whole balance. --dry checks and sends nothing
|
|
90
|
+
On testnet, get the worker's tUSDG on BrainChain back, usually within
|
|
91
|
+
a minute, not the bridge's seven days: the desk pays it on Robinhood
|
|
92
|
+
Chain testnet less a 0.5% fee, at least 0.01 tUSDG. Without --amount,
|
|
93
|
+
the whole balance. --dry checks and sends nothing. Fast exits are off
|
|
94
|
+
on mainnet, where exit says so and sends nothing. --to-solana names a
|
|
95
|
+
Solana wallet for the payout, which Relay can reach from mainnet only,
|
|
96
|
+
so neither network takes it.`,
|
|
83
97
|
fund: ` brainchain-worker fund --from-solana --usdc <amount> [--stake] [--gas-usdc <amount>] [--dry]
|
|
84
|
-
Fund the worker from a Solana wallet
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
Fund the worker from a Solana wallet. Relay turns --gas-usdc of its
|
|
99
|
+
USDC (2 by default, and never more than --usdc) into ETH on Robinhood
|
|
100
|
+
Chain. With --stake, the rest of --usdc becomes the stake token there;
|
|
101
|
+
without it, the rest stays on Solana. Then bridge moves on to BrainChain
|
|
102
|
+
only what this run brought, less the ETH a token deposit pays, leaving
|
|
103
|
+
a little for gas; what the wallet already held stays. A quote that would
|
|
104
|
+
cost more than 3% on the ETH or 5% on the stake token (or 0.50 USD on a
|
|
105
|
+
small move), or that asks to sign anything but this USDC's deposit into
|
|
106
|
+
Relay's depository, stops the command before anything is signed. Signed
|
|
107
|
+
with BRAINCHAIN_SOLANA_KEY or BRAINCHAIN_SOLANA_KEY_FILE, one or the
|
|
108
|
+
other; the wallet needs about 0.002 SOL for fees. --dry checks and sends
|
|
109
|
+
nothing. Mainnet.`,
|
|
89
110
|
propose: ` brainchain-worker propose --target board|executor|registry --call <function> --args '<JSON array>'
|
|
90
111
|
--title <text> --rationale <text> [--current <text>] [--proposed <text>] [--dry]
|
|
91
112
|
Bond the stake token and propose a change to BrainChain for Jev to
|
|
@@ -94,13 +115,18 @@ const COMMAND_USAGE = {
|
|
|
94
115
|
proposal: ` brainchain-worker proposal <id> Where a proposal stands: waiting, adopted and when it runs, or kept.`,
|
|
95
116
|
watch: ` brainchain-worker watch [--veto] [--once]
|
|
96
117
|
Watch the chain: judge every queued change against the constitution
|
|
97
|
-
and re-verify every new decision, reporting what disagrees.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
118
|
+
and re-verify every new decision, reporting what disagrees. A call the
|
|
119
|
+
monitor does not know counts as breaking a floor; one with no floor,
|
|
120
|
+
such as moving house treasury funds, is listed as that. A decision
|
|
121
|
+
served without its work is listed as withheld, with the hash checks
|
|
122
|
+
that did not run. A record that cannot be read holds the place until a
|
|
123
|
+
day after its decision's block time; past that it is a finding, reported
|
|
124
|
+
once, and the place moves on. With the worker's key set, each round is
|
|
125
|
+
reported to the service toward the worker's watchtower mark. --veto vetoes
|
|
126
|
+
a change that crosses a floor through the guardian gate, when the worker's
|
|
127
|
+
key is the gate's watcher. --once reads one round, checks the decisions
|
|
128
|
+
added since the last --once (the latest 10 on the first) and exits 1
|
|
129
|
+
on a finding or when a decision could not be checked or reported.`,
|
|
104
130
|
credit: ` brainchain-worker credit --agent-id <id> [--premium <bps>]
|
|
105
131
|
Ask BrainChain to back a credit line for the worker's ERC-8004 agent
|
|
106
132
|
on Priors (priors.trade): signs Priors' consent with the agent owner's
|
|
@@ -133,12 +159,13 @@ Environment:
|
|
|
133
159
|
BRAINCHAIN_RPC_URL Overrides the network's RPC.
|
|
134
160
|
BRAINCHAIN_SOLANA_KEY For fund --from-solana: the Solana wallet's secret key, base58.
|
|
135
161
|
BRAINCHAIN_SOLANA_KEY_FILE
|
|
136
|
-
Or a Solana CLI keypair file holding it.
|
|
162
|
+
Or a Solana CLI keypair file holding it, one or the other.
|
|
137
163
|
BRAINCHAIN_SOLANA_RPC Overrides the Solana RPC (https://api.mainnet-beta.solana.com).
|
|
138
164
|
BRAINCHAIN_SERVICE_URL Overrides the BrainChain service that keeps results: https, or http on
|
|
139
165
|
localhost or 127.0.0.1.
|
|
140
166
|
|
|
141
167
|
The command sees the worker's environment without any BRAINCHAIN_ variable, plus BRAINCHAIN_TASK_ID.
|
|
168
|
+
run restarts itself in place at start on Node.js 22.15 or later, so its own environment holds no key either.
|
|
142
169
|
It runs as your user, so it can reach what your user can: see "Your command" in the README.
|
|
143
170
|
Unfinished work is kept in ~/.config/brainchain-worker/<address>.<network>.json across restarts,
|
|
144
171
|
and one run at a time works for each address on each network.
|
|
@@ -260,19 +287,15 @@ async function needGas(chain, network) {
|
|
|
260
287
|
export async function join(chain, network, values) {
|
|
261
288
|
const name = values.get("name")?.trim() ?? "";
|
|
262
289
|
const about = values.get("about")?.trim() ?? "";
|
|
263
|
-
|
|
264
|
-
throw new UsageError("--name is required, up to 60 characters");
|
|
290
|
+
// What the brain keeps, in UTF-16 units as a JavaScript string counts them: one for nearly all text, two or more for an emoji.
|
|
265
291
|
if (name.length > 60)
|
|
266
|
-
throw new UsageError("--name is at most 60 characters");
|
|
292
|
+
throw new UsageError("--name is at most 60 characters, counted as UTF-16 units: one for nearly all text, two or more for an emoji");
|
|
267
293
|
// A name that hides characters or turns text around can read as another, Jev's included.
|
|
268
294
|
const nameHides = unseen(name);
|
|
269
295
|
if (nameHides)
|
|
270
296
|
throw new UsageError(`--name must be one line with no invisible or direction-changing characters, and it holds ${nameHides}`);
|
|
271
|
-
if (!about) {
|
|
272
|
-
throw new UsageError("--about is required: say what work your agent does in up to 500 characters, since Jev reads it to decide and refuses an empty application");
|
|
273
|
-
}
|
|
274
297
|
if (about.length > 500)
|
|
275
|
-
throw new UsageError("--about is at most 500 characters");
|
|
298
|
+
throw new UsageError("--about is at most 500 characters, counted as UTF-16 units: one for nearly all text, two or more for an emoji");
|
|
276
299
|
// A description may run over several lines.
|
|
277
300
|
const aboutHides = unseen(about.replace(/[\t\n]/g, " "));
|
|
278
301
|
if (aboutHides)
|
|
@@ -286,6 +309,21 @@ export async function join(chain, network, values) {
|
|
|
286
309
|
const requested = values.has("price") ? price(values.get("price")) : undefined;
|
|
287
310
|
const standing = await snapshot(chain);
|
|
288
311
|
const { allowed, w, change, pending, open, applying, toStake } = standing;
|
|
312
|
+
// Only an application carries a name and a description, so a worker already allowed or waiting needs neither.
|
|
313
|
+
if (applying) {
|
|
314
|
+
if (!name)
|
|
315
|
+
throw new UsageError("--name is required, up to 60 characters");
|
|
316
|
+
if (!about)
|
|
317
|
+
throw new UsageError("--about is required: say what work your agent does in up to 500 characters, since Jev reads it to decide and refuses an empty application");
|
|
318
|
+
if (await chain.applicationVetoed())
|
|
319
|
+
throw new Error("The guardian vetoed setWorkerAllowed on the task board, so the executor does not queue an application until the admin reopens that function. Nothing was sent.");
|
|
320
|
+
const taken = await chain.openApplication();
|
|
321
|
+
if (taken > 0n)
|
|
322
|
+
throw new Error(`Proposal ${taken} already asks to allow this worker and is still open, and the contract takes one at a time. Nothing was sent. Follow it with \`npx brainchain-worker proposal ${taken}\`.`);
|
|
323
|
+
}
|
|
324
|
+
else if (name || about) {
|
|
325
|
+
console.log("This worker is past its application, so --name and --about are not used.");
|
|
326
|
+
}
|
|
289
327
|
// The agent id travels in the application, and Jev keeps the profile it decided on.
|
|
290
328
|
if (agentId !== undefined) {
|
|
291
329
|
if (allowed || (change && pending) || open) {
|
|
@@ -657,6 +695,8 @@ export async function main(argv) {
|
|
|
657
695
|
}
|
|
658
696
|
if (!usdc)
|
|
659
697
|
throw new UsageError("--usdc is required: how much USDC on Solana to use, such as --usdc 5");
|
|
698
|
+
if (gasUsdc && !switches.has("stake") && parseUnits(gasUsdc, 6) > parseUnits(usdc, 6))
|
|
699
|
+
throw new UsageError(`--gas-usdc ${gasUsdc} is more than --usdc ${usdc}, the most this command may spend`);
|
|
660
700
|
if (network.id !== "mainnet" || !network.bridge)
|
|
661
701
|
throw new Error("fund --from-solana is mainnet only: Relay connects Solana with Robinhood Chain mainnet, not the testnets");
|
|
662
702
|
const worker = signer();
|
|
@@ -671,17 +711,9 @@ export async function main(argv) {
|
|
|
671
711
|
...(network.bridge.tokens?.stake ? { stakeToken: network.bridge.tokens.stake } : {}),
|
|
672
712
|
dry: switches.has("dry"),
|
|
673
713
|
log: console.log,
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
},
|
|
714
|
+
parentEth: () => bridgeChain.parentEth(),
|
|
715
|
+
parentStake: () => bridgeChain.tokenBalance("stake"),
|
|
716
|
+
bridgeOn: (asset, amount) => bridge(network, bridgeChain, { asset, amount: formatUnits(amount, assetOf(network, asset).decimals), dry: false, log: console.log }),
|
|
685
717
|
});
|
|
686
718
|
}
|
|
687
719
|
if (command === "credit") {
|
|
@@ -711,10 +743,7 @@ export async function main(argv) {
|
|
|
711
743
|
set: { key: "active", release: "", fromBlock: Number(network.fromBlock), board: taskBoard, log, executor },
|
|
712
744
|
briefs: new Map(),
|
|
713
745
|
}, id);
|
|
714
|
-
|
|
715
|
-
// A verified decision is reported with the record it was checked against, fetched again as the service serves it.
|
|
716
|
-
const record = findings.length === 0 && keyed && report.payloadURI ? await fetchRecord(report.payloadURI).catch(() => undefined) : undefined;
|
|
717
|
-
return { findings, ...(record ? { record } : {}) };
|
|
746
|
+
return outcome(report, keyed ? { refetch: fetchRecord } : {});
|
|
718
747
|
};
|
|
719
748
|
const chain = createWatchChain(network, account);
|
|
720
749
|
const watch = createWatch({
|
|
@@ -747,9 +776,8 @@ export async function main(argv) {
|
|
|
747
776
|
const id = positional[0];
|
|
748
777
|
if (id === undefined || !/^\d{1,18}$/.test(id) || id === "0")
|
|
749
778
|
throw new UsageError("Usage: brainchain-worker proposal <id>, such as proposal 2");
|
|
750
|
-
// Reading a proposal needs no key, so any address stands in for the signer.
|
|
751
|
-
|
|
752
|
-
return proposal(network, createProposals(network, reader), BigInt(id), console.log);
|
|
779
|
+
// Reading a proposal needs no key, so any address stands in for the signer, and a key that is set is never read.
|
|
780
|
+
return proposal(network, createProposals(network, privateKeyToAccount(generatePrivateKey())), BigInt(id), console.log);
|
|
753
781
|
}
|
|
754
782
|
const worker = signer();
|
|
755
783
|
if (command === "dispute" || command === "appeal") {
|
package/dist/credit.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createPublicClient, http, parseAbi } from "viem";
|
|
1
|
+
import { BaseError, ContractFunctionRevertedError, createPublicClient, http, parseAbi } from "viem";
|
|
2
2
|
import { reason } from "./chain.js";
|
|
3
3
|
import { parentChainOf } from "./network.js";
|
|
4
4
|
export const CONSENT_TYPES = {
|
|
@@ -47,6 +47,9 @@ export async function credit(network, chain, options) {
|
|
|
47
47
|
owner = await chain.ownerOf(agentId);
|
|
48
48
|
}
|
|
49
49
|
catch (error) {
|
|
50
|
+
// The registry reverts ownerOf for an id it never minted; anything else says nothing about the agent.
|
|
51
|
+
if (error instanceof BaseError && error.walk((e) => e instanceof ContractFunctionRevertedError))
|
|
52
|
+
throw new Error(`There is no ERC-8004 agent ${agentId} on Robinhood Chain.`);
|
|
50
53
|
throw new Error(`ERC-8004 agent ${agentId} could not be read on Robinhood Chain: ${reason(error)}`);
|
|
51
54
|
}
|
|
52
55
|
if (owner.toLowerCase() !== chain.account.toLowerCase()) {
|
package/dist/exit.js
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
// `exit`: the worker's USDG on BrainChain paid back within a minute instead of the bridge's seven days. The desk pays
|
|
2
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.
|
|
3
|
+
// Solana address after its arguments, so nothing else is signed and nothing is needed on Robinhood Chain. The token and
|
|
4
|
+
// the desk the service names are checked against this release before anything is signed.
|
|
5
|
+
import { PublicKey } from "@solana/web3.js";
|
|
4
6
|
import bs58 from "bs58";
|
|
5
|
-
import { createPublicClient, createWalletClient, encodeFunctionData, erc20Abi, formatUnits, http, parseUnits } from "viem";
|
|
6
|
-
import { RECEIPT_MS } from "./chain.js";
|
|
7
|
+
import { createPublicClient, createWalletClient, encodeFunctionData, erc20Abi, formatUnits, http, isAddress, parseUnits } from "viem";
|
|
8
|
+
import { RECEIPT_MS, reason } from "./chain.js";
|
|
7
9
|
import { chainOf } from "./network.js";
|
|
8
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Keys no one can spend USDC from. Program ids are often on the curve, so the
|
|
12
|
+
* curve alone does not catch them: the all-zero address, which the desk also
|
|
13
|
+
* reads as no destination, the token programs, USDC's mint and Relay's depository.
|
|
14
|
+
*/
|
|
15
|
+
const NOT_WALLETS = new Set([
|
|
16
|
+
"11111111111111111111111111111111",
|
|
17
|
+
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
|
|
18
|
+
"TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
|
|
19
|
+
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
|
|
20
|
+
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
|
|
21
|
+
"99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2",
|
|
22
|
+
]);
|
|
23
|
+
/** A Solana address a wallet can spend from: 32 bytes in base58, on the ed25519 curve. A program-derived address is off it, and USDC sent there is stranded. */
|
|
9
24
|
export function isSolanaAddress(value) {
|
|
10
|
-
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(value))
|
|
25
|
+
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(value) || NOT_WALLETS.has(value))
|
|
11
26
|
return false;
|
|
12
27
|
try {
|
|
13
|
-
|
|
28
|
+
const bytes = bs58.decode(value);
|
|
29
|
+
return bytes.length === 32 && PublicKey.isOnCurve(bytes);
|
|
14
30
|
}
|
|
15
31
|
catch {
|
|
16
32
|
return false;
|
|
@@ -27,13 +43,39 @@ export function exitFee(amount, terms) {
|
|
|
27
43
|
const fee = proportional > floor ? proportional : floor;
|
|
28
44
|
return fee > amount ? amount : fee;
|
|
29
45
|
}
|
|
46
|
+
const units = (value) => typeof value === "string" && /^\d{1,30}$/.test(value);
|
|
47
|
+
/** What the service answers for its exit terms, refused here when it is not the shape the rest of `exit` reads. */
|
|
30
48
|
export async function exitTerms(service, fetchImpl = fetch) {
|
|
31
|
-
const
|
|
49
|
+
const host = new URL(service).host;
|
|
50
|
+
const response = await fetchImpl(`${service}/exits`, { signal: AbortSignal.timeout(20_000) }).catch(() => {
|
|
51
|
+
throw new Error(`Could not reach the BrainChain service at ${host} for the exit terms. Nothing was sent.`);
|
|
52
|
+
});
|
|
32
53
|
if (response.status === 404)
|
|
33
54
|
throw new Error("Fast exits are off on this network");
|
|
34
55
|
if (!response.ok)
|
|
35
|
-
throw new Error(
|
|
36
|
-
|
|
56
|
+
throw new Error(`The BrainChain service at ${host} answered ${response.status} when asked for the exit terms. Nothing was sent.`);
|
|
57
|
+
const terms = (await response.json().catch(() => undefined));
|
|
58
|
+
const readable = typeof terms?.desk === "string" &&
|
|
59
|
+
isAddress(terms.desk, { strict: false }) &&
|
|
60
|
+
typeof terms.token === "string" &&
|
|
61
|
+
isAddress(terms.token, { strict: false }) &&
|
|
62
|
+
Number.isInteger(terms.feeBps) &&
|
|
63
|
+
terms.feeBps >= 0 &&
|
|
64
|
+
[terms.minFeeUnits, terms.minUnits, terms.maxUnits].every(units);
|
|
65
|
+
if (!readable)
|
|
66
|
+
throw new Error(`The BrainChain service at ${host} named the exit terms in a form exit cannot read. Nothing was sent.`);
|
|
67
|
+
return terms;
|
|
68
|
+
}
|
|
69
|
+
/** Why the terms cannot be signed for: a transfer goes to whatever token and desk they name, and only this release says which those are. */
|
|
70
|
+
export function termsProblem(network, terms) {
|
|
71
|
+
const host = new URL(network.service).host;
|
|
72
|
+
if (terms.token.toLowerCase() !== network.contracts.bountyToken.toLowerCase()) {
|
|
73
|
+
return `The BrainChain service at ${host} names the token ${terms.token} for an exit, and ${network.name}'s ${network.bountySymbol} is ${network.contracts.bountyToken}. Nothing was sent.`;
|
|
74
|
+
}
|
|
75
|
+
if (terms.desk.toLowerCase() !== network.exitDesk.toLowerCase()) {
|
|
76
|
+
return `The BrainChain service at ${host} names the desk ${terms.desk}, and this release of brainchain-worker pays only ${network.exitDesk}. Nothing was sent.`;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
37
79
|
}
|
|
38
80
|
export async function exitRecord(service, tx, fetchImpl = fetch) {
|
|
39
81
|
const response = await fetchImpl(`${service}/exits/${tx}`, { signal: AbortSignal.timeout(20_000) });
|
|
@@ -43,15 +85,22 @@ export async function exitRecord(service, tx, fetchImpl = fetch) {
|
|
|
43
85
|
throw new Error(`${service}/exits answered ${response.status}`);
|
|
44
86
|
return (await response.json()).exits[0];
|
|
45
87
|
}
|
|
46
|
-
export function createExitChain(network, account) {
|
|
47
|
-
const read = createPublicClient({ chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000 }) });
|
|
88
|
+
export function createExitChain(network, account, receiptMs = RECEIPT_MS) {
|
|
89
|
+
const read = createPublicClient({ chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000 }), pollingInterval: 1_000 });
|
|
48
90
|
const wallet = createWalletClient({ account, chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000 }) });
|
|
49
91
|
return {
|
|
50
92
|
account: account.address,
|
|
51
93
|
bountyBalance: (token) => read.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
|
|
94
|
+
async unmined() {
|
|
95
|
+
const [pending, latest] = await Promise.all([read.getTransactionCount({ address: account.address, blockTag: "pending" }), read.getTransactionCount({ address: account.address })]);
|
|
96
|
+
return pending - latest;
|
|
97
|
+
},
|
|
52
98
|
async sendToDesk(token, desk, amount, solana) {
|
|
53
99
|
const hash = await wallet.sendTransaction({ to: token, data: exitCalldata(desk, amount, solana) });
|
|
54
|
-
|
|
100
|
+
// Sent is not lost: the transfer can still land, so another run would pay the desk twice.
|
|
101
|
+
const receipt = await read.waitForTransactionReceipt({ hash, timeout: receiptMs }).catch((error) => {
|
|
102
|
+
throw new Error(`The transfer to the desk was sent (${hash}) and could not be confirmed (${reason(error)}). Follow it at ${network.service}/exits/${hash} before running exit again: another run sends another transfer.`);
|
|
103
|
+
});
|
|
55
104
|
if (receipt.status !== "success")
|
|
56
105
|
throw new Error(`the transfer to the desk reverted (${hash})`);
|
|
57
106
|
return hash;
|
|
@@ -64,11 +113,14 @@ export async function exit(network, chain, options) {
|
|
|
64
113
|
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
65
114
|
if (options.toSolana !== undefined) {
|
|
66
115
|
if (!isSolanaAddress(options.toSolana))
|
|
67
|
-
throw new Error(`"${options.toSolana}" is not a Solana
|
|
116
|
+
throw new Error(`"${options.toSolana}" is not the address of a Solana wallet, so USDC sent there could not be spent`);
|
|
68
117
|
if (network.id !== "mainnet")
|
|
69
118
|
throw new Error("--to-solana is mainnet only: Relay connects Solana with Robinhood Chain mainnet, not the testnets");
|
|
70
119
|
}
|
|
71
120
|
const terms = await exitTerms(network.service, fetchImpl);
|
|
121
|
+
const refused = termsProblem(network, terms);
|
|
122
|
+
if (refused)
|
|
123
|
+
throw new Error(refused);
|
|
72
124
|
const balance = await chain.bountyBalance(terms.token);
|
|
73
125
|
const amount = options.amount === undefined ? balance : parseUnits(options.amount, 6);
|
|
74
126
|
const min = BigInt(terms.minUnits);
|
|
@@ -82,34 +134,45 @@ export async function exit(network, chain, options) {
|
|
|
82
134
|
if (amount < min)
|
|
83
135
|
throw new Error(`An exit is at least ${show(min)}`);
|
|
84
136
|
if (amount > max)
|
|
85
|
-
throw new Error(`An exit is at most ${show(max)}
|
|
137
|
+
throw new Error(`An exit is at most ${show(max)}, and nothing was sent. Give --amount up to that, and send the rest in another exit.`);
|
|
86
138
|
const fee = exitFee(amount, terms);
|
|
87
139
|
const net = amount - fee;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
140
|
+
const paid = options.toSolana === undefined
|
|
141
|
+
? `${show(net)} arrives on the parent chain (chain ${terms.payoutChainId}) after a ${show(fee)} fee.`
|
|
142
|
+
: `${show(net)} after a ${show(fee)} fee is paid as USDC on Solana to ${options.toSolana}, less Relay's own fee. The desk prices that move once the transfer arrives; one that would cost more than 3% (or 0.50 USD) is paid in ${symbol} on the parent chain (chain ${terms.payoutChainId}) instead.`;
|
|
143
|
+
log(`Exit ${show(amount)} from ${network.name}: ${paid}`);
|
|
91
144
|
if (dry) {
|
|
92
145
|
log("Dry run, nothing sent.");
|
|
93
146
|
return;
|
|
94
147
|
}
|
|
148
|
+
const unmined = await chain.unmined();
|
|
149
|
+
if (unmined > 0)
|
|
150
|
+
throw new Error(`This wallet has a transaction on ${network.name} that is not mined yet, which may be an earlier exit, and another run would send another transfer. Nothing was sent; run exit again once it is mined.`);
|
|
95
151
|
const tx = await chain.sendToDesk(terms.token, terms.desk, amount, options.toSolana);
|
|
152
|
+
const follow = `${network.service}/exits/${tx}`;
|
|
96
153
|
log(`Sent to the desk on ${network.name} (${tx}). Waiting for the payout, usually under a minute.`);
|
|
97
154
|
const end = Date.now() + (options.waitMs ?? 1_800_000);
|
|
98
155
|
let last = "";
|
|
99
156
|
let record;
|
|
100
157
|
for (;;) {
|
|
101
158
|
record = await exitRecord(network.service, tx, fetchImpl).catch(() => undefined);
|
|
102
|
-
if (record?.state === "paid" || record?.state === "returned")
|
|
159
|
+
if (record?.state === "paid" || record?.state === "returned" || record?.state === "held")
|
|
103
160
|
break;
|
|
104
161
|
if (record?.reason && record.reason !== last)
|
|
105
162
|
log(`Waiting: ${record.reason}.`);
|
|
106
163
|
last = record?.reason ?? last;
|
|
107
164
|
if (Date.now() > end) {
|
|
108
|
-
|
|
165
|
+
const within = options.toSolana === undefined ? "The desk pays it or sends it back within a day" : "Within a day the desk pays it or sends it back, or holds it for a person if Relay has not settled the move";
|
|
166
|
+
log(`Still waiting after ${Math.round((options.waitMs ?? 1_800_000) / 60_000)} minutes. ${within}; follow it at ${follow}.`);
|
|
109
167
|
return;
|
|
110
168
|
}
|
|
111
169
|
await sleep(options.pollMs ?? 10_000);
|
|
112
170
|
}
|
|
171
|
+
if (record.state === "held") {
|
|
172
|
+
log(`The desk holds this exit for a person: ${record.reason ?? "no reason given"}. Nothing more is sent for it until it is settled; follow it at ${follow}.`);
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
113
176
|
if (record.state === "returned") {
|
|
114
177
|
log(`The desk sent it back on ${network.name}: ${record.reason ?? "no reason given"} (${record.returnTx}).`);
|
|
115
178
|
process.exitCode = 1;
|
package/dist/key.js
CHANGED
|
@@ -6,7 +6,7 @@ import { stateDir } from "./state.js";
|
|
|
6
6
|
/** Names the file a run passes its key through when it restarts itself without the key in its environment. */
|
|
7
7
|
const HANDOFF = "BRAINCHAIN_WORKER_KEY_HANDOFF";
|
|
8
8
|
const HANDOFF_NAME = /^brainchain-worker-key-[0-9a-f]{32}$/;
|
|
9
|
-
const expandHome = (path) => (path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path);
|
|
9
|
+
export const expandHome = (path) => (path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path);
|
|
10
10
|
/** Whether the environment names a key, without reading it: a key file can be a pipe, which reads only once. */
|
|
11
11
|
export const keyConfigured = (env = process.env) => Boolean(env[HANDOFF] || env.BRAINCHAIN_WORKER_KEY?.trim() || env.BRAINCHAIN_WORKER_KEY_FILE?.trim());
|
|
12
12
|
/** What is wrong with a key file's text, in words that never repeat any of it. */
|
|
@@ -85,19 +85,33 @@ function handoffDir() {
|
|
|
85
85
|
return stateDir();
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
+
/** A variable that holds a key or says where one is: the worker's and the Solana wallet's, which `run` never uses. */
|
|
89
|
+
const KEY_VARIABLE = /^BRAINCHAIN_(WORKER|SOLANA)_KEY(_FILE)?$/;
|
|
90
|
+
/** A key file that is a pipe has been read and holds nothing, so its name can stay: a restart would put a key piped in to stay out of files in one for a moment. */
|
|
91
|
+
function namesOnlyAPipe(names, env) {
|
|
92
|
+
if (names.length !== 1 || names[0] !== "BRAINCHAIN_WORKER_KEY_FILE")
|
|
93
|
+
return false;
|
|
94
|
+
try {
|
|
95
|
+
return !statSync(expandHome(env.BRAINCHAIN_WORKER_KEY_FILE.trim())).isFile();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
88
101
|
/**
|
|
89
|
-
* Restarts this process in place without
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
102
|
+
* Restarts this process in place without any key variable in its environment.
|
|
103
|
+
* Deleting them would not do: `ps eww` and /proc read the block the process
|
|
104
|
+
* started with, and a command run by `run` can reach both. The pid, the output
|
|
105
|
+
* and the supervisor's view all stay as they were. Returns when there is
|
|
106
|
+
* nothing to clear, or when it could not be cleared.
|
|
94
107
|
*/
|
|
95
108
|
export function clearKeyFromEnvironment(key, warn, env = process.env, execve = process.execve) {
|
|
96
|
-
|
|
109
|
+
const names = Object.keys(env).filter((name) => KEY_VARIABLE.test(name));
|
|
110
|
+
if (names.length === 0 || namesOnlyAPipe(names, env))
|
|
97
111
|
return;
|
|
98
112
|
if (typeof execve !== "function")
|
|
99
|
-
return warn(`warning: Node.js ${process.version} cannot clear the key from this process's environment; Node.js 22.15 or later can`);
|
|
100
|
-
const
|
|
113
|
+
return warn(`warning: Node.js ${process.version} cannot clear the key variables from this process's environment; Node.js 22.15 or later can`);
|
|
114
|
+
const rest = Object.fromEntries(Object.entries(env).filter(([name]) => !KEY_VARIABLE.test(name)));
|
|
101
115
|
let file;
|
|
102
116
|
try {
|
|
103
117
|
file = join(handoffDir(), `brainchain-worker-key-${randomBytes(16).toString("hex")}`);
|
|
@@ -107,6 +121,6 @@ export function clearKeyFromEnvironment(key, warn, env = process.env, execve = p
|
|
|
107
121
|
catch (error) {
|
|
108
122
|
if (file !== undefined)
|
|
109
123
|
rmSync(file, { force: true });
|
|
110
|
-
warn(`warning: could not clear the key from this process's environment (${error.message})`);
|
|
124
|
+
warn(`warning: could not clear the key variables from this process's environment (${error.message})`);
|
|
111
125
|
}
|
|
112
126
|
}
|
package/dist/mint.js
CHANGED
|
@@ -159,7 +159,7 @@ export async function mint(network, chain, options) {
|
|
|
159
159
|
return log(`The BrainChain service lists no paid task of ${chain.account} waiting for a Proof, so there is nothing to mint.`);
|
|
160
160
|
const batch = listed.slice(0, Math.min(options.count ?? MAX_MINT, MAX_MINT));
|
|
161
161
|
const left = listed.length - batch.length;
|
|
162
|
-
const rest = left > 0 ? ` ${left} more paid task${left === 1 ? " waits" : "s wait"} for a Proof
|
|
162
|
+
const rest = left > 0 ? ` ${left} more paid task${left === 1 ? " waits" : "s wait"} for a Proof, for a later mint.` : "";
|
|
163
163
|
const funds = await afford(proofs, chain, batch.length, { dry: options.dry, claiming: () => log("Claiming test WETH from the faucet..."), now: options.now ?? wall });
|
|
164
164
|
const each = `${formatEther(funds.price)} WETH each plus gas`;
|
|
165
165
|
if (options.dry) {
|
|
@@ -180,6 +180,8 @@ export async function mint(network, chain, options) {
|
|
|
180
180
|
}
|
|
181
181
|
if (rest)
|
|
182
182
|
log(rest.trim());
|
|
183
|
+
// The service scans in rounds and caches the list, so it still shows these tasks for a while.
|
|
184
|
+
log("`mint --dry` lists these tasks until the service has seen these mints, which can take minutes. A mint before then, here or by `run --mint`, pays for another piece.");
|
|
183
185
|
}
|
|
184
186
|
/**
|
|
185
187
|
* What `run` does once a task is delivered: it watches the task until it
|
package/dist/network.js
CHANGED
|
@@ -37,6 +37,7 @@ export const TESTNET = {
|
|
|
37
37
|
router: "0x1BBe71Dd3af85309A5c7Ab00B190F38f34Fe3569",
|
|
38
38
|
gas: "Get Robinhood Chain testnet ETH at https://faucet.testnet.chain.robinhood.com",
|
|
39
39
|
},
|
|
40
|
+
exitDesk: "0xf848984c8A0D9E49Cc9C97380C62b34C6f3b97D1",
|
|
40
41
|
contracts: {
|
|
41
42
|
taskBoard: "0x7A7cC5D35A642CCc268C7B263fF800bc7eBD52A4",
|
|
42
43
|
staking: "0x6aFC3EFB49CF9b14C2F7c6C4f6C0bb3eF0125e2b",
|
|
@@ -97,6 +98,7 @@ export const MAINNET = {
|
|
|
97
98
|
gas: "Send it a little ETH on Robinhood Chain",
|
|
98
99
|
tokens: { stake: "0xC80333bf7f4078e14Ce5b6e223aF607aDcA44418", bounty: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168" },
|
|
99
100
|
},
|
|
101
|
+
exitDesk: "0xafB0C4Ef895D4895040cdC6435B96D3EE8350c6E",
|
|
100
102
|
contracts: {
|
|
101
103
|
taskBoard: MAINNET_TASK_BOARD,
|
|
102
104
|
staking: MAINNET_WORKER_STAKING,
|