brainchain-worker 0.18.3 → 0.19.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/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
- /** A Solana address: 32 bytes in base58. */
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
- return bs58.decode(value).length === 32;
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 response = await fetchImpl(`${service}/exits`, { signal: AbortSignal.timeout(20_000) });
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(`${service}/exits answered ${response.status}`);
36
- return (await response.json());
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
- const receipt = await read.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
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 address`);
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)}; send the rest in another exit`);
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
- log(`Exit ${show(amount)} from ${network.name}: ${show(net)} arrives on the parent chain (chain ${terms.payoutChainId}) after a ${show(fee)} fee.`);
89
- if (options.toSolana !== undefined)
90
- log(`Paid as USDC on Solana to ${options.toSolana}, less Relay's own fee, unless the move would cost more than 3% (or 0.50 USD); then it is paid on the parent chain.`);
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
- 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}.`);
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 BRAINCHAIN_WORKER_KEY in its
90
- * environment. Deleting the variable would not do: `ps eww` and /proc read the
91
- * block the process started with, and a command run by `run` can reach both.
92
- * The pid, the output and the supervisor's view all stay as they were. Returns
93
- * when there is nothing to clear, or when it could not be cleared.
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
- if (env.BRAINCHAIN_WORKER_KEY === undefined)
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 { BRAINCHAIN_WORKER_KEY: _, ...rest } = env;
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
@@ -23,8 +23,8 @@ export const wethAbi = parseAbi([
23
23
  export const MAX_MINT = 10;
24
24
  /** How often a delivered task's status is read until it settles. */
25
25
  const STATUS_SEC = 300;
26
- /** Each step from grading to a ruling on appeal has a window of at most 6 hours, so a task unsettled two days after delivery is left. */
27
- const WATCH_SEC = 2 * 86_400;
26
+ /** Grading, a dispute, its ruling, the appeal window and the ruling on appeal take up to 48 hours on set 3.5.0, so a task unsettled three days after delivery is left. */
27
+ const WATCH_SEC = 3 * 86_400;
28
28
  /** A day after payment the task joins the public pool, so the worker's own attempts stop an hour short of it. */
29
29
  const OWN_SEC = 23 * 3_600;
30
30
  /** The least time between two mint attempts for one task. */
@@ -93,7 +93,8 @@ export async function pendingTasks(network, worker, fetcher = fetch) {
93
93
  if (!Array.isArray(tasks) || !tasks.every((id) => typeof id === "string" && /^\d{1,78}$/.test(id))) {
94
94
  throw new Error(`The BrainChain service at ${host} listed the tasks waiting for a Proof in a form the worker cannot read`);
95
95
  }
96
- return [...new Set(tasks)].map((id) => BigInt(id));
96
+ // Task ids restart with each board and the list spans them, so a repeated id is another task.
97
+ return tasks.map((id) => BigInt(id));
97
98
  }
98
99
  const proofCount = (n) => `${n} Proof${n === 1 ? "" : "s"}`;
99
100
  /** "task 12", "tasks 12 and 15", "tasks 12, 15 and 18". */
@@ -159,7 +160,7 @@ export async function mint(network, chain, options) {
159
160
  return log(`The BrainChain service lists no paid task of ${chain.account} waiting for a Proof, so there is nothing to mint.`);
160
161
  const batch = listed.slice(0, Math.min(options.count ?? MAX_MINT, MAX_MINT));
161
162
  const left = listed.length - batch.length;
162
- const rest = left > 0 ? ` ${left} more paid task${left === 1 ? " waits" : "s wait"} for a Proof: run mint again.` : "";
163
+ const rest = left > 0 ? ` ${left} more paid task${left === 1 ? " waits" : "s wait"} for a Proof, for a later mint.` : "";
163
164
  const funds = await afford(proofs, chain, batch.length, { dry: options.dry, claiming: () => log("Claiming test WETH from the faucet..."), now: options.now ?? wall });
164
165
  const each = `${formatEther(funds.price)} WETH each plus gas`;
165
166
  if (options.dry) {
@@ -180,6 +181,8 @@ export async function mint(network, chain, options) {
180
181
  }
181
182
  if (rest)
182
183
  log(rest.trim());
184
+ // The service scans in rounds and caches the list, so it still shows these tasks for a while.
185
+ 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
186
  }
184
187
  /**
185
188
  * What `run` does once a task is delivered: it watches the task until it
@@ -216,7 +219,7 @@ export function createSettlementWatch(options) {
216
219
  }
217
220
  if (outcome === undefined) {
218
221
  if (clock() - entry.deliveredAt >= WATCH_SEC)
219
- drop(key, "not settled two days after delivery, so the worker stops watching it");
222
+ drop(key, "not settled three days after delivery, so the worker stops watching it");
220
223
  else
221
224
  later(entry, STATUS_SEC);
222
225
  return false;
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",
@@ -49,20 +50,20 @@ export const TESTNET = {
49
50
  bountyToken: "0xC4ec72794bF3Ce9ba1DE4B38085464c2eb0cDCf2",
50
51
  },
51
52
  };
52
- // The 3.3.0 set. Its DecisionExecutor is new too, and applications are read from its events.
53
- export const MAINNET_TASK_BOARD = "0x8385bb4b8990411CcdD8e851B2676da1A4C1f3C4";
54
- export const MAINNET_WORKER_STAKING = "0x21Fe66c04da07f85D855E9920251Df7d584c3D9F";
55
- export const MAINNET_PROPOSAL_BONDS = "0x1129435A110AB3Ffd3fFEE345A48f648DdE3D52c";
56
- export const MAINNET_DECISION_EXECUTOR = "0x98BEA2E455539dB38b7337d81C673326128EA17F";
53
+ // The 3.5.0 set. Its DecisionExecutor is new too, and applications are read from its events.
54
+ export const MAINNET_TASK_BOARD = "0x44b8464342Bbfb9B565FAE494DfDefa37D33A211";
55
+ export const MAINNET_WORKER_STAKING = "0xa4351E2b84eb634E93D9082907B2d0D3Aa91B98F";
56
+ export const MAINNET_PROPOSAL_BONDS = "0x33260Cd6b4F368031F5AF2B5413d0CE6bb2aFAA4";
57
+ export const MAINNET_DECISION_EXECUTOR = "0xFD268Ae2c1Ec0Db03C3C6b17E1AEDE973729FE29";
57
58
  const BRAIN = "0x3367085Eb6C79BfB0fCa4f1433ec77bFA8CB8a37";
58
- /** BrainChain mainnet, release 3.4.0. */
59
+ /** BrainChain mainnet, release 3.5.0. */
59
60
  export const MAINNET = {
60
61
  id: "mainnet",
61
62
  name: "BrainChain",
62
63
  chainId: 274663,
63
64
  rpc: "https://rpc.usebrainchain.com",
64
65
  service: "https://brain.usebrainchain.com",
65
- fromBlock: 839n,
66
+ fromBlock: 965n,
66
67
  explorer: "https://usebrainchain.com",
67
68
  stakeSymbol: "BRAIN",
68
69
  bountySymbol: "USDG",
@@ -97,14 +98,19 @@ 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",
102
+ retired: [
103
+ { release: "3.4.0", taskBoard: "0x8385bb4b8990411CcdD8e851B2676da1A4C1f3C4", staking: "0x21Fe66c04da07f85D855E9920251Df7d584c3D9F" },
104
+ { release: "3.3.0", taskBoard: "0x72C890AA996bF7efcA88e3F5063B626560215160", staking: "0x0fb02559e704549794EdF3FBBBe91DB88bB849ee" },
105
+ ],
100
106
  contracts: {
101
107
  taskBoard: MAINNET_TASK_BOARD,
102
108
  staking: MAINNET_WORKER_STAKING,
103
109
  bonds: MAINNET_PROPOSAL_BONDS,
104
110
  executor: MAINNET_DECISION_EXECUTOR,
105
- registry: "0x870B0BFB24aF2C06180FbEe266117305e0B0E00F",
106
- log: "0xFdFEDb376c9632A4D6F15a1697E2b6853f12B4d7",
107
- gate: "0x48c2Ed99Dc786ba667eFcFF721ff4cA92a1f344A",
111
+ registry: "0xA7f8A21Cc9574DB049F525a4fd499DA49c0D51ed",
112
+ log: "0x2EF509bd9cA1e7C5FBe2C2FD7Ff25cE9F6f9cB68",
113
+ gate: "0x71B2dc3dEC1aEB000936380981DfC51E47AA4fb8",
108
114
  stakeToken: BRAIN,
109
115
  bountyToken: "0x8eBFEce96c0AeF7Fd23425D846a302a552c8c451",
110
116
  },
package/dist/propose.js CHANGED
@@ -1,6 +1,6 @@
1
- import { BaseError, createPublicClient, createWalletClient, decodeErrorResult, decodeFunctionData, encodeAbiParameters, encodeFunctionData, ExecutionRevertedError, getAddress, isAddress, isHex, keccak256, parseAbi, parseEventLogs, size, slice, } from "viem";
2
- import { erc20Errors } from "./abi.js";
3
- import { bondsAbi, brainchainTransport, erc20Abi, executorAbi, RECEIPT_MS } from "./chain.js";
1
+ import { BaseError, createPublicClient, createWalletClient, decodeErrorResult, decodeFunctionData, encodeFunctionData, ExecutionRevertedError, getAddress, isAddress, isHex, parseAbi, parseEventLogs, size, } from "viem";
2
+ import { erc20Errors, transferErrors } from "./abi.js";
3
+ import { bondsAbi, brainchainTransport, callVetoed, erc20Abi, executorAbi, openProposalOf, RECEIPT_MS } from "./chain.js";
4
4
  import { chainOf } from "./network.js";
5
5
  import { gasHelp, stake, when } from "./standing.js";
6
6
  /** Every call the executor can be asked to make, by the contract it calls. */
@@ -19,21 +19,20 @@ export const GOVERNED = {
19
19
  registry: parseAbi(["function setModel(bytes32 id, address account, uint128 price, bool active)"]),
20
20
  };
21
21
  const TARGETS = Object.keys(GOVERNED);
22
- /** What the brain reads from a proposal's URI, cut to the lengths it keeps. */
22
+ /** What the brain reads from a proposal's URI, cut to the lengths it keeps, in UTF-16 units as a JavaScript string counts them. */
23
23
  export const LIMITS = { title: 120, rationale: 1_500, current: 300, proposed: 300 };
24
+ /** The most the brain reads of a whole case as JSON; past it, the case reads as blank and Jev decides on the call alone. */
25
+ export const CASE_BYTES = 4_096;
24
26
  const proposalsAbi = parseAbi([
25
27
  "event Expired(uint256 indexed id)",
26
28
  "function decideWithin() view returns (uint64)",
27
29
  ]);
28
- const executorReadsAbi = parseAbi([
29
- "function vetoed(address target, bytes4 selector) view returns (bool)",
30
- "function vetoedCall(bytes32) view returns (bool)",
31
- "function changeThresholdBps() view returns (uint16)",
32
- ]);
30
+ const executorReadsAbi = parseAbi(["function changeThresholdBps() view returns (uint16)"]);
33
31
  // The reverts a governed call can hit, so a rehearsal names the rule it broke.
34
32
  const callErrorsAbi = [
35
- ...parseAbi(["error BadConfig()", "error BadFee()", "error ZeroAddress()", "error NotOwner()", "error NotSelf()", "error NotGuardian()"]),
33
+ ...parseAbi(["error BadConfig()", "error BadFee()", "error ZeroAddress()", "error NotOwner()", "error NotSelf()", "error NotGuardian()", "error AccountShapedId()", "error UnknownModel()"]),
36
34
  ...erc20Errors,
35
+ ...transferErrors,
37
36
  ];
38
37
  /** Mirrors ProposalBonds.State. */
39
38
  const State = { None: 0, Open: 1, Returned: 2, Burned: 3 };
@@ -169,7 +168,10 @@ export function caseURI(call, input) {
169
168
  if (body[key].length > LIMITS[key])
170
169
  throw new ProposalError(`--${key} is at most ${LIMITS[key]} characters`);
171
170
  }
172
- return `data:application/json;base64,${Buffer.from(JSON.stringify(body)).toString("base64")}`;
171
+ const json = Buffer.from(JSON.stringify(body));
172
+ if (json.byteLength > CASE_BYTES)
173
+ throw new ProposalError(`The case is ${json.byteLength} bytes as JSON, and Jev reads at most ${CASE_BYTES}, which text outside the Latin alphabet reaches before the character limits. Shorten it.`);
174
+ return `data:application/json;base64,${json.toString("base64")}`;
173
175
  }
174
176
  const LOG_RANGE = 50000n;
175
177
  export function createProposals(network, account) {
@@ -204,20 +206,8 @@ export function createProposals(network, account) {
204
206
  decideWithin: () => client.readContract({ ...proposals, functionName: "decideWithin" }),
205
207
  changeThresholdBps: async () => BigInt(await client.readContract({ address: executor, abi: executorReadsAbi, functionName: "changeThresholdBps" })),
206
208
  timelock: () => client.readContract({ address: executor, abi: executorAbi, functionName: "timelock" }),
207
- // A set 3.4.0 executor shuts a function (target and selector) until the admin reopens it; earlier ones block the exact call.
208
- vetoed: async (call) => {
209
- try {
210
- return await client.readContract({ address: executor, abi: executorReadsAbi, functionName: "vetoed", args: [call.address, slice(call.data, 0, 4)] });
211
- }
212
- catch {
213
- return client.readContract({
214
- address: executor,
215
- abi: executorReadsAbi,
216
- functionName: "vetoedCall",
217
- args: [keccak256(encodeAbiParameters([{ type: "address" }, { type: "bytes" }], [call.address, call.data]))],
218
- });
219
- }
220
- },
209
+ vetoed: (call) => callVetoed(client, executor, call.address, call.data),
210
+ openProposal: (call) => openProposalOf(client, network, call.address, call.data),
221
211
  async rehearse(call) {
222
212
  try {
223
213
  await client.call({ account: executor, to: call.address, data: call.data });
@@ -230,8 +220,13 @@ export function createProposals(network, account) {
230
220
  throw error;
231
221
  const raw = error.walk((e) => typeof e.data === "string");
232
222
  try {
233
- if (raw?.data)
234
- return `${decodeErrorResult({ abi: callErrorsAbi, data: raw.data }).errorName}, the contract's check on these values`;
223
+ if (raw?.data) {
224
+ const decoded = decodeErrorResult({ abi: callErrorsAbi, data: raw.data });
225
+ // A plain Solidity revert carries its reason as text, and its error's name says nothing.
226
+ if (decoded.errorName === "Error")
227
+ return String(decoded.args?.[0]);
228
+ return `${decoded.errorName}, the contract's check on these values`;
229
+ }
235
230
  }
236
231
  catch {
237
232
  // Not one of the contracts' own errors; the node's message says more.
@@ -251,7 +246,7 @@ export function createProposals(network, account) {
251
246
  },
252
247
  async get(id) {
253
248
  const p = await client.readContract({ ...proposals, functionName: "get", args: [id] });
254
- return { id, proposer: p.proposer, bond: p.bond, createdAt: p.createdAt, state: p.state };
249
+ return { id, proposer: p.proposer, bond: p.bond, createdAt: p.createdAt, state: p.state, deadline: p.deadline };
255
250
  },
256
251
  async outcome(id) {
257
252
  const [settled, expired] = await Promise.all([
@@ -276,7 +271,7 @@ const days = (seconds) => `${Number(seconds) / 86_400} days`;
276
271
  const percent = (bps) => `${Number(bps) / 100}%`;
277
272
  export async function propose(network, chain, call, uri, options) {
278
273
  const { log } = options;
279
- const [bond, balance, gas, within, threshold, timelock, vetoed, revert] = await Promise.all([
274
+ const [bond, balance, gas, within, threshold, timelock, vetoed, open, revert] = await Promise.all([
280
275
  chain.bond(),
281
276
  chain.stakeBalance(),
282
277
  chain.ethBalance(),
@@ -284,10 +279,13 @@ export async function propose(network, chain, call, uri, options) {
284
279
  chain.changeThresholdBps(),
285
280
  chain.timelock(),
286
281
  chain.vetoed(call),
282
+ chain.openProposal(call),
287
283
  chain.rehearse(call),
288
284
  ]);
289
285
  if (vetoed)
290
286
  throw new ProposalError(`The guardian vetoed ${call.text} on the ${call.target}, so the executor does not queue it until the admin reopens that function`);
287
+ if (open > 0n)
288
+ throw new ProposalError(`Proposal ${open} already asks for ${call.text} on the ${call.target} and is still open, and the contract takes one at a time. Follow it with \`npx brainchain-worker proposal ${open}\`.`);
291
289
  if (revert)
292
290
  throw new ProposalError(`${call.text} would fail if the executor made it now (${revert}), so the bond would be burned for nothing`);
293
291
  log(`Proposal on ${network.name}
@@ -315,9 +313,8 @@ export async function proposal(network, chain, id, log) {
315
313
  throw new ProposalError(`There is no proposal ${id} on ${network.name}`);
316
314
  const head = `Proposal ${id} by ${p.proposer}, bond ${stake(network, p.bond)}`;
317
315
  if (p.state === State.Open) {
318
- const [within, now] = await Promise.all([chain.decideWithin(), chain.now()]);
319
- const closes = p.createdAt + within;
320
- const after = now > closes ? `its window closed at ${when(closes)}, so anyone can return the bond with expire` : `Jev decides by ${when(closes)}`;
316
+ const now = await chain.now();
317
+ const after = now > p.deadline ? `its window closed at ${when(p.deadline)}, so anyone can return the bond by calling expire on ${network.contracts.bonds}` : `Jev decides by ${when(p.deadline)}`;
321
318
  log(`${head}\n Waiting for Jev: ${after}.`);
322
319
  return;
323
320
  }
package/dist/relay.js CHANGED
@@ -1,6 +1,6 @@
1
- // Relay (relay.link) moves value between Solana and Robinhood Chain in about a second. 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.
1
+ // Relay (relay.link) moves value between Solana and Robinhood Chain in about a second. `fund` uses it to bring USDC
2
+ // from Solana; every quote is checked against what goes in before anything is signed, so a bad price stops the move
3
+ // instead of costing the worker.
4
4
  export const RELAY_API = "https://api.relay.link";
5
5
  export const SOLANA_CHAIN_ID = 792703809;
6
6
  /** USDC on Solana. */
@@ -8,6 +8,8 @@ export const SOLANA_USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
8
8
  export const NATIVE = "0x0000000000000000000000000000000000000000";
9
9
  /** The most a move may cost, as a share of its dollar value; small moves may cost up to FIXED_LOSS_USD instead. */
10
10
  export const MAX_LOSS = 0.03;
11
+ /** The stake token's market alone costs about 3.7% at any size on top of Relay's fee, so the stake leg gets more room. */
12
+ export const MAX_STAKE_LOSS = 0.05;
11
13
  /** Relay's fee is partly fixed (about 0.2 USD), so a small move is allowed to lose up to this much in dollars. */
12
14
  export const FIXED_LOSS_USD = 0.5;
13
15
  export class RelayError extends Error {
@@ -26,33 +28,37 @@ export async function relayQuote(request, fetchImpl = fetch) {
26
28
  const requestId = body.steps.find((s) => s.requestId)?.requestId;
27
29
  if (!requestId)
28
30
  throw new RelayError("Relay's quote carries no request id to follow");
31
+ const out = body.details.currencyOut;
29
32
  return {
30
33
  requestId,
31
34
  steps: body.steps,
32
35
  inUsd: Number(body.details.currencyIn?.amountUsd ?? 0),
33
- outUsd: Number(body.details.currencyOut?.amountUsd ?? 0),
34
- outAmount: BigInt(body.details.currencyOut?.amount ?? 0),
35
- outFormatted: String(body.details.currencyOut?.amountFormatted ?? "0"),
36
+ outUsd: Number(out?.amountUsd ?? 0),
37
+ outAmount: /^\d+$/.test(String(out?.amount)) ? BigInt(out.amount) : 0n,
38
+ outFormatted: String(out?.amountFormatted ?? "0"),
39
+ recipient: body.details.recipient,
40
+ outCurrency: out?.currency?.address,
41
+ outChainId: out?.currency?.chainId,
36
42
  };
37
43
  }
38
- /** Refuses a quote that loses more than MAX_LOSS of its dollar value, or FIXED_LOSS_USD on a small move. */
39
- export function checkReturn(quote) {
44
+ /** Refuses a quote that loses more than `maxLoss` of its dollar value, or FIXED_LOSS_USD on a small move. */
45
+ export function checkReturn(quote, maxLoss = MAX_LOSS) {
40
46
  if (!(quote.inUsd > 0))
41
47
  throw new RelayError("Relay's quote has no dollar value for what goes in, so its price cannot be checked");
42
48
  const loss = quote.inUsd - quote.outUsd;
43
- const allowed = Math.max(quote.inUsd * MAX_LOSS, FIXED_LOSS_USD);
49
+ const allowed = Math.max(quote.inUsd * maxLoss, FIXED_LOSS_USD);
44
50
  if (loss > allowed) {
45
- throw new RelayError(`Relay would keep ${loss.toFixed(2)} USD of ${quote.inUsd.toFixed(2)}, more than the ${allowed.toFixed(2)} this command accepts (3%, or 0.50 USD on a small move). Nothing was sent; try again later.`);
51
+ throw new RelayError(`Relay would keep ${loss.toFixed(2)} USD of ${quote.inUsd.toFixed(2)}, more than the ${allowed.toFixed(2)} this command accepts (${Math.round(maxLoss * 100)}%, or 0.50 USD on a small move). Nothing was sent; try again later.`);
46
52
  }
47
53
  }
48
54
  export async function relayStatus(requestId, fetchImpl = fetch) {
49
- const response = await fetchImpl(`${RELAY_API}/intents/status/v2?requestId=${requestId}`, { signal: AbortSignal.timeout(20_000) });
55
+ const response = await fetchImpl(`${RELAY_API}/intents/status/v2?requestId=${encodeURIComponent(requestId)}`, { signal: AbortSignal.timeout(20_000) });
50
56
  const body = (await response.json().catch(() => ({})));
51
57
  if (body.status === "success" || body.status === "failure" || body.status === "refund")
52
58
  return body.status;
53
59
  return "pending";
54
60
  }
55
- /** Follows a move until Relay says it landed, failed or was refunded, for at most `ms`. */
61
+ /** Follows a move until Relay answers anything but pending, for at most `ms`. Its `failure` is usually not final: Relay can still retry or refund. */
56
62
  export async function waitForRelay(requestId, ms = 600_000, fetchImpl = fetch, sleep = (t) => new Promise((r) => setTimeout(r, t))) {
57
63
  const end = Date.now() + ms;
58
64
  for (;;) {