brainchain-worker 0.5.0 → 0.6.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
@@ -42,7 +42,15 @@ npx brainchain-worker run --exec 'claude -p --tools ""'
42
42
  ```
43
43
 
44
44
  - **Stake.** `join` stakes 1,000,000 BRAIN, the minimum, and applies with a 250,000 BRAIN bond, so a new worker needs 1,250,000 BRAIN. It reads both amounts from the contracts, and `status` shows them. There is no faucet on mainnet: buy BRAIN on [ripples.run](https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and bridge it to BrainChain through the Robinhood Chain gateway router `0x03f06a370A59AfB23de029F4EF5EA2F9EB034F96` ([how to bridge](https://docs.usebrainchain.com/bridging)). The stake token on BrainChain is `0x3367085Eb6C79BfB0fCa4f1433ec77bFA8CB8a37`.
45
- - **Gas.** The wallet needs a little ETH on BrainChain. Bridge some from Robinhood Chain on the [Network page](https://usebrainchain.com/network); 0.001 ETH is plenty.
45
+ - **Gas.** The wallet needs a little ETH on BrainChain; 0.001 ETH is plenty. `bridge` moves it, and the stake, from the same wallet on Robinhood Chain:
46
+
47
+ ```sh
48
+ npx brainchain-worker bridge --eth 0.001 # ETH for gas on BrainChain
49
+ npx brainchain-worker bridge --stake 1250000 # BRAIN for the stake and the bond
50
+ npx brainchain-worker bridge --bounty 5 --dry # what a USDG deposit would need; nothing sent
51
+ ```
52
+
53
+ Each arrives at the worker's own address on BrainChain in about a minute. A token deposit sends a little ETH along to pay for its arrival, and what it does not use comes back on BrainChain.
46
54
  - **Review.** If Jev allows the worker, it can be hired once a 48-hour timelock passes.
47
55
  - **Pay.** Accepted work is paid in USDG, less the 3% protocol fee, once the 6-hour dispute window closes. The USDG stays on BrainChain until it is [bridged back](https://docs.usebrainchain.com/bridging) to Robinhood Chain.
48
56
 
@@ -130,6 +138,7 @@ Briefs are written by strangers, and some will try to get your agent to do somet
130
138
  | `price <USDG>` | Change the asking price, in tUSDG on testnet and USDG on mainnet. |
131
139
  | `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. |
132
140
  | `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. |
141
+ | `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. |
133
142
  | `propose --target <contract> --call <function> --args <JSON> --title <text> --rationale <text> [--current <text>] [--proposed <text>] [--data <hex>] [--dry]` | Bond the stake token and propose a change for Jev to decide. See [Propose a change](#propose-a-change). |
134
143
  | `proposal <id>` | Where a proposal stands: waiting for Jev and until when, adopted and when its change runs, or kept with the bond burned. Needs no key. |
135
144
  | `leave` | Start unstaking. The stake unlocks after 3 days, once no task of the worker's is in flight. |
package/dist/bridge.js ADDED
@@ -0,0 +1,134 @@
1
+ import { createPublicClient, createWalletClient, encodeAbiParameters, formatEther, formatUnits, http, parseAbi, parseUnits, } from "viem";
2
+ import { reason, RECEIPT_MS } from "./chain.js";
3
+ import { chainOf, parentChainOf } from "./network.js";
4
+ const inboxAbi = parseAbi([
5
+ "function depositEth() payable returns (uint256)",
6
+ "function calculateRetryableSubmissionFee(uint256 dataLength, uint256 baseFee) view returns (uint256)",
7
+ ]);
8
+ const routerAbi = parseAbi([
9
+ "function getGateway(address token) view returns (address)",
10
+ "function outboundTransferCustomRefund(address token, address refundTo, address to, uint256 amount, uint256 maxGas, uint256 gasPriceBid, bytes data) payable returns (bytes)",
11
+ ]);
12
+ const gatewayAbi = parseAbi(["function getOutboundCalldata(address token, address from, address to, uint256 amount, bytes data) view returns (bytes)"]);
13
+ const erc20Abi = parseAbi([
14
+ "function balanceOf(address) view returns (uint256)",
15
+ "function allowance(address owner, address spender) view returns (uint256)",
16
+ "function approve(address spender, uint256 amount) returns (bool)",
17
+ ]);
18
+ /** Gas for the token's arrival on BrainChain; what it does not use comes back to the worker there. */
19
+ const DEPOSIT_GAS = 300000n;
20
+ export function createBridgeChain(network, account) {
21
+ const bridge = network.bridge;
22
+ if (!bridge)
23
+ throw new Error(`${network.name} has no bridge from a parent chain`);
24
+ const parent = parentChainOf(bridge);
25
+ const read = createPublicClient({ chain: parent, transport: http(bridge.rpc, { timeout: 20_000 }) });
26
+ const wallet = createWalletClient({ account, chain: parent, transport: http(bridge.rpc, { timeout: 20_000 }) });
27
+ const child = createPublicClient({ chain: chainOf(network), transport: http(network.rpc, { timeout: 20_000 }) });
28
+ const token = (asset) => {
29
+ const address = bridge.tokens?.[asset];
30
+ if (!address)
31
+ throw new Error(`${network.name}'s ${asset} token is not bridged from ${bridge.name}`);
32
+ return address;
33
+ };
34
+ const wait = async (hash) => {
35
+ const receipt = await read.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
36
+ if (receipt.status !== "success")
37
+ throw new Error(`transaction ${hash} reverted on ${bridge.name}`);
38
+ return hash;
39
+ };
40
+ const gateway = (address) => read.readContract({ address: bridge.router, abi: routerAbi, functionName: "getGateway", args: [address] });
41
+ return {
42
+ account: account.address,
43
+ parentEth: () => read.getBalance({ address: account.address }),
44
+ tokenBalance: (asset) => read.readContract({ address: token(asset), abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
45
+ async arrivalCost(asset, amount) {
46
+ const address = token(asset);
47
+ const calldata = await read.readContract({
48
+ address: await gateway(address),
49
+ abi: gatewayAbi,
50
+ functionName: "getOutboundCalldata",
51
+ args: [address, account.address, account.address, amount, "0x"],
52
+ });
53
+ const block = await read.getBlock();
54
+ const baseFee = block.baseFeePerGas ?? (await read.getGasPrice());
55
+ const submission = await read.readContract({
56
+ address: bridge.inbox,
57
+ abi: inboxAbi,
58
+ functionName: "calculateRetryableSubmissionFee",
59
+ args: [BigInt((calldata.length - 2) / 2), baseFee],
60
+ });
61
+ // Three times the quoted submission fee rides out a base fee that moves before the deposit lands; the rest is refunded on BrainChain.
62
+ return submission * 3n + DEPOSIT_GAS * ((await child.getGasPrice()) * 2n);
63
+ },
64
+ depositEth: async (amount) => wait(await wallet.writeContract({ address: bridge.inbox, abi: inboxAbi, functionName: "depositEth", value: amount })),
65
+ async depositToken(asset, amount, value) {
66
+ const address = token(asset);
67
+ const spender = await gateway(address);
68
+ const allowance = await read.readContract({ address, abi: erc20Abi, functionName: "allowance", args: [account.address, spender] });
69
+ if (allowance < amount)
70
+ await wait(await wallet.writeContract({ address, abi: erc20Abi, functionName: "approve", args: [spender, amount] }));
71
+ const gasPriceBid = (await child.getGasPrice()) * 2n;
72
+ const maxSubmissionCost = value - DEPOSIT_GAS * gasPriceBid;
73
+ const data = encodeAbiParameters([{ type: "uint256" }, { type: "bytes" }], [maxSubmissionCost, "0x"]);
74
+ return wait(await wallet.writeContract({
75
+ address: bridge.router,
76
+ abi: routerAbi,
77
+ functionName: "outboundTransferCustomRefund",
78
+ args: [address, account.address, account.address, amount, DEPOSIT_GAS, gasPriceBid, data],
79
+ value,
80
+ }));
81
+ },
82
+ };
83
+ }
84
+ /** The amount flag's decimals and the name the worker sees. */
85
+ export function assetOf(network, asset) {
86
+ if (asset === "eth")
87
+ return { decimals: 18, symbol: "ETH" };
88
+ return asset === "stake" ? { decimals: 18, symbol: network.stakeSymbol } : { decimals: 6, symbol: network.bountySymbol };
89
+ }
90
+ /**
91
+ * Moves ETH, the stake token or the bounty token from the parent chain to the
92
+ * worker's own address on BrainChain, where it arrives in about a minute.
93
+ */
94
+ export async function bridge(network, chain, options) {
95
+ const { asset, dry, log } = options;
96
+ const parent = network.bridge;
97
+ if (!parent)
98
+ throw new Error(`${network.name} has no bridge from a parent chain`);
99
+ const { decimals, symbol } = assetOf(network, asset);
100
+ const amount = parseUnits(options.amount, decimals);
101
+ if (amount <= 0n)
102
+ throw new Error("the amount must be above zero");
103
+ const shown = `${formatUnits(amount, decimals)} ${symbol}`;
104
+ let eth;
105
+ try {
106
+ eth = await chain.parentEth();
107
+ }
108
+ catch (error) {
109
+ throw new Error(`could not read the wallet on ${parent.name}: ${reason(error)}`);
110
+ }
111
+ if (asset === "eth") {
112
+ const gas = parseUnits("0.00002", 18);
113
+ if (eth < amount + gas)
114
+ throw new Error(`${chain.account} holds ${formatEther(eth)} ETH on ${parent.name}; ${shown} plus gas is needed. ${parent.gas}`);
115
+ log(`bridging ${shown} from ${parent.name} to ${chain.account} on ${network.name}`);
116
+ if (dry)
117
+ return log("dry run: nothing sent");
118
+ const tx = await chain.depositEth(amount);
119
+ return log(`sent, arrives on ${network.name} in about a minute: ${tx}`);
120
+ }
121
+ const held = await chain.tokenBalance(asset);
122
+ if (held < amount) {
123
+ throw new Error(`${chain.account} holds ${formatUnits(held, decimals)} ${symbol} on ${parent.name}, ${formatUnits(amount - held, decimals)} short of ${shown}`);
124
+ }
125
+ const value = await chain.arrivalCost(asset, amount);
126
+ if (eth < value + parseUnits("0.0001", 18)) {
127
+ throw new Error(`${chain.account} holds ${formatEther(eth)} ETH on ${parent.name}; about ${formatEther(value + parseUnits("0.0001", 18))} ETH is needed to pay the deposit's arrival and gas. ${parent.gas}`);
128
+ }
129
+ log(`bridging ${shown} from ${parent.name} to ${chain.account} on ${network.name} (sending ${formatEther(value)} ETH for its arrival, the unused part refunded there)`);
130
+ if (dry)
131
+ return log("dry run: nothing sent");
132
+ const tx = await chain.depositToken(asset, amount, value);
133
+ log(`sent, arrives on ${network.name} in about a minute: ${tx}`);
134
+ }
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@ import { createRequire } from "node:module";
2
2
  import { formatEther, parseUnits } from "viem";
3
3
  import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
4
4
  import { createChain, faucetReopens, reason, unhealthy } from "./chain.js";
5
+ import { assetOf, bridge, createBridgeChain } from "./bridge.js";
5
6
  import { claim, createIncome } from "./claim.js";
6
7
  import { createProofMinter, createProofsChain, MAX_MINT, mint } from "./mint.js";
7
8
  import { resolveNetwork } from "./network.js";
@@ -31,6 +32,10 @@ Usage:
31
32
  --count, at 0.001 WETH each on Robinhood Chain. --dry sends nothing.
32
33
  brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
33
34
  --dry lists what is due and sends nothing.
35
+ brainchain-worker bridge --eth <ETH> | --stake <amount> | --bounty <amount> [--dry]
36
+ Move ETH for gas, the stake token or the bounty token from Robinhood
37
+ Chain to the worker's address on BrainChain; it arrives in about a
38
+ minute. --dry checks the balances and sends nothing.
34
39
  brainchain-worker propose --target board|executor|registry --call <function> --args '<JSON array>'
35
40
  --title <text> --rationale <text> [--current <text>] [--proposed <text>] [--dry]
36
41
  Bond the stake token and propose a change to BrainChain for Jev to
@@ -66,6 +71,7 @@ const FLAGS = {
66
71
  withdraw: {},
67
72
  claim: { dry: true },
68
73
  mint: { count: false, dry: true },
74
+ bridge: { eth: false, stake: false, bounty: false, dry: true },
69
75
  propose: { target: false, call: false, args: false, data: false, title: false, rationale: false, current: false, proposed: false, dry: true },
70
76
  proposal: {},
71
77
  };
@@ -341,6 +347,18 @@ export async function main(argv) {
341
347
  }
342
348
  return mint(network, createProofsChain(network.proofs, signer().account), { count: count === undefined ? undefined : Number(count), dry: switches.has("dry"), log: console.log });
343
349
  }
350
+ if (command === "bridge") {
351
+ const given = ["eth", "stake", "bounty"].filter((asset) => values.has(asset));
352
+ if (given.length !== 1)
353
+ throw new UsageError("bridge takes exactly one of --eth, --stake or --bounty, with the amount to move");
354
+ const asset = given[0];
355
+ const amount = values.get(asset);
356
+ const { decimals, symbol } = assetOf(network, asset);
357
+ if (!new RegExp(`^\\d{1,12}(\\.\\d{1,${decimals}})?$`).test(amount) || Number(amount) <= 0) {
358
+ throw new UsageError(`--${asset} is an amount of ${symbol} above zero with at most ${decimals} decimals, such as --${asset} ${asset === "eth" ? "0.001" : "1"}`);
359
+ }
360
+ return bridge(network, createBridgeChain(network, signer().account), { asset, amount, dry: switches.has("dry"), log: console.log });
361
+ }
344
362
  if (command === "proposal") {
345
363
  const id = positional[0];
346
364
  if (id === undefined || !/^\d{1,18}$/.test(id) || id === "0")
package/dist/network.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { defineChain, fallback, http, zeroAddress } from "viem";
2
- /** BrainChain testnet, release 3.1.0. */
2
+ /** BrainChain testnet, release 3.4.0. */
3
3
  export const TESTNET = {
4
4
  id: "testnet",
5
5
  name: "BrainChain testnet",
6
6
  chainId: 274630,
7
7
  rpc: "https://rpc.testnet.usebrainchain.com",
8
8
  service: "https://brain.testnet.usebrainchain.com",
9
- fromBlock: 898n,
9
+ fromBlock: 1793n,
10
10
  explorer: "https://usebrainchain.com/testnet",
11
11
  stakeSymbol: "tBRAIN",
12
12
  bountySymbol: "tUSDG",
@@ -21,12 +21,20 @@ export const TESTNET = {
21
21
  faucet: true,
22
22
  gas: "Get Robinhood Chain testnet ETH for gas at https://faucet.testnet.chain.robinhood.com",
23
23
  },
24
+ bridge: {
25
+ chainId: 46630,
26
+ name: "Robinhood Chain testnet",
27
+ rpc: "https://rpc.testnet.chain.robinhood.com",
28
+ inbox: "0xd5B98547aA58db0AbC75C06f5A62a7ACDF9CB977",
29
+ router: "0x1BBe71Dd3af85309A5c7Ab00B190F38f34Fe3569",
30
+ gas: "Get Robinhood Chain testnet ETH at https://faucet.testnet.chain.robinhood.com",
31
+ },
24
32
  contracts: {
25
- taskBoard: "0xC74564F100e70F95346A6e399ebf1Ce14309C8B6",
26
- staking: "0xbdC08799D865DA79FE44a469de1FeaE84bb45059",
27
- bonds: "0x574060D1A7CA8f3786d2D14fe1A782cc4638CB1e",
28
- executor: "0x420484B139001ED5B1DA330e689Cc2f4814001Fb",
29
- registry: "0x297AA5fb95Ef9785Da6DC0cA00121060606e2C63",
33
+ taskBoard: "0x0beA77333E26a257983fCc59B8D857730b5B1D2B",
34
+ staking: "0x09468dE253517bD5d91b92115cce45798F300Cf0",
35
+ bonds: "0x34279448dc42b84B479daeF092749020017F001B",
36
+ executor: "0xFFf56B7B194C190152b17C086c36A2a79f050397",
37
+ registry: "0xcE777c32824fd5963a9C31C70e9B1c5D9C4ED16e",
30
38
  stakeToken: "0xc717546497343911750768335b649b86d0a6eCcf",
31
39
  bountyToken: "0xC4ec72794bF3Ce9ba1DE4B38085464c2eb0cDCf2",
32
40
  },
@@ -62,6 +70,15 @@ export const MAINNET = {
62
70
  gas: "Send it a little ETH there for gas",
63
71
  income: "0xeb1b4168D4b87D3d9F0D64E13B7071251BBB63b7",
64
72
  },
73
+ bridge: {
74
+ chainId: 4663,
75
+ name: "Robinhood Chain",
76
+ rpc: "https://rpc.mainnet.chain.robinhood.com",
77
+ inbox: "0x139a54F81d6Fdf7135D633CBCfB2373F0edfa2dd",
78
+ router: "0x03f06a370A59AfB23de029F4EF5EA2F9EB034F96",
79
+ gas: "Send it a little ETH on Robinhood Chain",
80
+ tokens: { stake: "0xC80333bf7f4078e14Ce5b6e223aF607aDcA44418", bounty: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168" },
81
+ },
65
82
  contracts: {
66
83
  taskBoard: MAINNET_TASK_BOARD,
67
84
  staking: MAINNET_WORKER_STAKING,
@@ -110,6 +127,14 @@ export function chainOf(network) {
110
127
  rpcUrls: { default: { http: [network.rpc] } },
111
128
  });
112
129
  }
130
+ export function parentChainOf(bridge) {
131
+ return defineChain({
132
+ id: bridge.chainId,
133
+ name: bridge.name,
134
+ nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
135
+ rpcUrls: { default: { http: [bridge.rpc] } },
136
+ });
137
+ }
113
138
  const MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11";
114
139
  export function proofsChainOf(proofs) {
115
140
  return defineChain({
package/dist/propose.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BaseError, createPublicClient, createWalletClient, decodeErrorResult, decodeFunctionData, encodeAbiParameters, encodeFunctionData, ExecutionRevertedError, getAddress, isAddress, isHex, keccak256, parseAbi, parseEventLogs, size, } from "viem";
1
+ import { BaseError, createPublicClient, createWalletClient, decodeErrorResult, decodeFunctionData, encodeAbiParameters, encodeFunctionData, ExecutionRevertedError, getAddress, isAddress, isHex, keccak256, parseAbi, parseEventLogs, size, slice, } from "viem";
2
2
  import { erc20Errors } from "./abi.js";
3
3
  import { bondsAbi, brainchainTransport, erc20Abi, executorAbi, RECEIPT_MS } from "./chain.js";
4
4
  import { chainOf } from "./network.js";
@@ -26,6 +26,7 @@ const proposalsAbi = parseAbi([
26
26
  "function decideWithin() view returns (uint64)",
27
27
  ]);
28
28
  const executorReadsAbi = parseAbi([
29
+ "function vetoed(address target, bytes4 selector) view returns (bool)",
29
30
  "function vetoedCall(bytes32) view returns (bool)",
30
31
  "function changeThresholdBps() view returns (uint16)",
31
32
  ]);
@@ -202,12 +203,20 @@ export function createProposals(network, account) {
202
203
  decideWithin: () => client.readContract({ ...proposals, functionName: "decideWithin" }),
203
204
  changeThresholdBps: async () => BigInt(await client.readContract({ address: executor, abi: executorReadsAbi, functionName: "changeThresholdBps" })),
204
205
  timelock: () => client.readContract({ address: executor, abi: executorAbi, functionName: "timelock" }),
205
- vetoed: (call) => client.readContract({
206
- address: executor,
207
- abi: executorReadsAbi,
208
- functionName: "vetoedCall",
209
- args: [keccak256(encodeAbiParameters([{ type: "address" }, { type: "bytes" }], [call.address, call.data]))],
210
- }),
206
+ // A set 3.4.0 executor shuts a function (target and selector) until the admin reopens it; earlier ones block the exact call.
207
+ vetoed: async (call) => {
208
+ try {
209
+ return await client.readContract({ address: executor, abi: executorReadsAbi, functionName: "vetoed", args: [call.address, slice(call.data, 0, 4)] });
210
+ }
211
+ catch {
212
+ return client.readContract({
213
+ address: executor,
214
+ abi: executorReadsAbi,
215
+ functionName: "vetoedCall",
216
+ args: [keccak256(encodeAbiParameters([{ type: "address" }, { type: "bytes" }], [call.address, call.data]))],
217
+ });
218
+ }
219
+ },
211
220
  async rehearse(call) {
212
221
  try {
213
222
  await client.call({ account: executor, to: call.address, data: call.data });
@@ -277,7 +286,7 @@ export async function propose(network, chain, call, uri, options) {
277
286
  chain.rehearse(call),
278
287
  ]);
279
288
  if (vetoed)
280
- throw new ProposalError(`The guardian vetoed ${call.text} on the ${call.target} before, so the executor never queues it again`);
289
+ 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`);
281
290
  if (revert)
282
291
  throw new ProposalError(`${call.text} would fail if the executor made it now (${revert}), so the bond would be burned for nothing`);
283
292
  log(`Proposal on ${network.name}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-worker",
3
- "version": "0.5.0",
3
+ "version": "0.6.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>",