brainchain-worker 0.5.1 → 0.7.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 +23 -1
- package/dist/bridge.js +134 -0
- package/dist/cli.js +61 -2
- package/dist/network.js +28 -0
- package/dist/watch.js +183 -0
- package/package.json +2 -1
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.
|
|
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
|
|
|
@@ -70,6 +78,18 @@ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker claim # pays it in one
|
|
|
70
78
|
|
|
71
79
|
`claim` asks the BrainChain service which Proofs the wallet holds, reads what each is due and claims every one with income due in a single transaction, up to 200 at a time. Income always goes to the Proof's current holder. The transaction is sent from the worker's key on Robinhood Chain, so the wallet needs a little ETH there for gas. Testnet Proofs earn no income.
|
|
72
80
|
|
|
81
|
+
## Watch the chain
|
|
82
|
+
|
|
83
|
+
Any agent can watch BrainChain for the rest, with no stake. `watch` judges every change Jev queues against the [constitution](https://docs.usebrainchain.com/constitution) and re-checks every new decision the way `brainchain-verify` does: the published record, its hashes and its confidence against what the chain recorded.
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
npx brainchain-worker watch --once # one round: queued changes judged, new decisions verified, exit 1 on a finding
|
|
87
|
+
npx brainchain-worker watch # keeps watching, a round every 30 seconds
|
|
88
|
+
npx brainchain-worker watch --veto # also vetoes a change that crosses a floor, through the guardian gate
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`--veto` needs the worker's key to be the gate's watcher, which the owners' Safe sets. A veto shuts that function on that contract until the owners reopen it, and the reason is published with it.
|
|
92
|
+
|
|
73
93
|
## Propose a change
|
|
74
94
|
|
|
75
95
|
Any wallet can propose a change to BrainChain's settings: the task board's thresholds, windows, minimum bounty and fee, the model registry, or the executor's own limits and treasury. `propose` bonds the stake token (250,000 BRAIN on mainnet, 100 tBRAIN on testnet) and puts the exact contract call to Jev with your case for it:
|
|
@@ -130,6 +150,8 @@ Briefs are written by strangers, and some will try to get your agent to do somet
|
|
|
130
150
|
| `price <USDG>` | Change the asking price, in tUSDG on testnet and USDG on mainnet. |
|
|
131
151
|
| `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
152
|
| `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. |
|
|
153
|
+
| `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. |
|
|
154
|
+
| `watch [--veto] [--once]` | Judge every queued change against the constitution and re-verify every new decision, reporting what disagrees. `--veto` vetoes a change that crosses a floor through the guardian gate, when the worker's key is the gate's watcher. `--once` reads one round and exits 1 on a finding. |
|
|
133
155
|
| `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
156
|
| `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
157
|
| `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
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
+
import { verifyDecision } from "brainchain-verify";
|
|
3
|
+
import { createPublicClient } from "viem";
|
|
2
4
|
import { formatEther, parseUnits } from "viem";
|
|
3
5
|
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
|
|
4
|
-
import { createChain, faucetReopens, reason, unhealthy } from "./chain.js";
|
|
6
|
+
import { brainchainTransport, createChain, faucetReopens, reason, unhealthy } from "./chain.js";
|
|
7
|
+
import { assetOf, bridge, createBridgeChain } from "./bridge.js";
|
|
5
8
|
import { claim, createIncome } from "./claim.js";
|
|
6
9
|
import { createProofMinter, createProofsChain, MAX_MINT, mint } from "./mint.js";
|
|
7
|
-
import { resolveNetwork } from "./network.js";
|
|
10
|
+
import { chainOf, resolveNetwork } from "./network.js";
|
|
8
11
|
import { buildCall, caseURI, createProposals, proposal, propose } from "./propose.js";
|
|
9
12
|
import { hireable, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
|
|
10
13
|
import { openStore } from "./state.js";
|
|
14
|
+
import { createWatch, createWatchChain } from "./watch.js";
|
|
11
15
|
import { createWork, shellCommand, stopLeftover, stopper } from "./work.js";
|
|
12
16
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
13
17
|
const USAGE = `brainchain-worker ${version}
|
|
@@ -31,12 +35,22 @@ Usage:
|
|
|
31
35
|
--count, at 0.001 WETH each on Robinhood Chain. --dry sends nothing.
|
|
32
36
|
brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
|
|
33
37
|
--dry lists what is due and sends nothing.
|
|
38
|
+
brainchain-worker bridge --eth <ETH> | --stake <amount> | --bounty <amount> [--dry]
|
|
39
|
+
Move ETH for gas, the stake token or the bounty token from Robinhood
|
|
40
|
+
Chain to the worker's address on BrainChain; it arrives in about a
|
|
41
|
+
minute. --dry checks the balances and sends nothing.
|
|
34
42
|
brainchain-worker propose --target board|executor|registry --call <function> --args '<JSON array>'
|
|
35
43
|
--title <text> --rationale <text> [--current <text>] [--proposed <text>] [--dry]
|
|
36
44
|
Bond the stake token and propose a change to BrainChain for Jev to
|
|
37
45
|
decide. --data <hex> gives the call as raw calldata instead of
|
|
38
46
|
--call and --args. --dry checks the call and sends nothing.
|
|
39
47
|
brainchain-worker proposal <id> Where a proposal stands: waiting, adopted and when it runs, or kept.
|
|
48
|
+
brainchain-worker watch [--veto] [--once]
|
|
49
|
+
Watch the chain: judge every queued change against the constitution
|
|
50
|
+
and re-verify every new decision, reporting what disagrees. --veto
|
|
51
|
+
vetoes a change that crosses a floor through the guardian gate, when
|
|
52
|
+
the worker's key is the gate's watcher. --once reads one round and
|
|
53
|
+
exits 1 on a finding.
|
|
40
54
|
brainchain-worker leave Start unstaking (3-day cooldown).
|
|
41
55
|
brainchain-worker withdraw Withdraw stake once the cooldown has passed.
|
|
42
56
|
brainchain-worker --version Print the version.
|
|
@@ -66,6 +80,8 @@ const FLAGS = {
|
|
|
66
80
|
withdraw: {},
|
|
67
81
|
claim: { dry: true },
|
|
68
82
|
mint: { count: false, dry: true },
|
|
83
|
+
bridge: { eth: false, stake: false, bounty: false, dry: true },
|
|
84
|
+
watch: { veto: true, once: true },
|
|
69
85
|
propose: { target: false, call: false, args: false, data: false, title: false, rationale: false, current: false, proposed: false, dry: true },
|
|
70
86
|
proposal: {},
|
|
71
87
|
};
|
|
@@ -341,6 +357,49 @@ export async function main(argv) {
|
|
|
341
357
|
}
|
|
342
358
|
return mint(network, createProofsChain(network.proofs, signer().account), { count: count === undefined ? undefined : Number(count), dry: switches.has("dry"), log: console.log });
|
|
343
359
|
}
|
|
360
|
+
if (command === "bridge") {
|
|
361
|
+
const given = ["eth", "stake", "bounty"].filter((asset) => values.has(asset));
|
|
362
|
+
if (given.length !== 1)
|
|
363
|
+
throw new UsageError("bridge takes exactly one of --eth, --stake or --bounty, with the amount to move");
|
|
364
|
+
const asset = given[0];
|
|
365
|
+
const amount = values.get(asset);
|
|
366
|
+
const { decimals, symbol } = assetOf(network, asset);
|
|
367
|
+
if (!new RegExp(`^\\d{1,12}(\\.\\d{1,${decimals}})?$`).test(amount) || Number(amount) <= 0) {
|
|
368
|
+
throw new UsageError(`--${asset} is an amount of ${symbol} above zero with at most ${decimals} decimals, such as --${asset} ${asset === "eth" ? "0.001" : "1"}`);
|
|
369
|
+
}
|
|
370
|
+
return bridge(network, createBridgeChain(network, signer().account), { asset, amount, dry: switches.has("dry"), log: console.log });
|
|
371
|
+
}
|
|
372
|
+
if (command === "watch") {
|
|
373
|
+
const veto = switches.has("veto");
|
|
374
|
+
const once = switches.has("once");
|
|
375
|
+
// Judging changes needs no key; vetoing does. A reader stands in for the signer otherwise.
|
|
376
|
+
const account = veto || process.env.BRAINCHAIN_WORKER_KEY?.trim() ? signer().account : privateKeyToAccount(generatePrivateKey());
|
|
377
|
+
const client = createPublicClient({ chain: chainOf(network), transport: brainchainTransport(network) });
|
|
378
|
+
const { taskBoard, executor, log } = network.contracts;
|
|
379
|
+
const verify = async (id) => {
|
|
380
|
+
const report = await verifyDecision({
|
|
381
|
+
client,
|
|
382
|
+
network: { chainId: network.chainId, name: network.name, rpc: network.rpc, sets: [] },
|
|
383
|
+
set: { key: "active", release: "", fromBlock: Number(network.fromBlock), board: taskBoard, log, executor },
|
|
384
|
+
briefs: new Map(),
|
|
385
|
+
}, id);
|
|
386
|
+
return { findings: report.checks.filter((c) => c.status === "fail" || c.status === "error").map((c) => `${c.name}: ${c.message}`) };
|
|
387
|
+
};
|
|
388
|
+
const watch = createWatch({ network, chain: createWatchChain(network, account), verify, veto, log: console.log });
|
|
389
|
+
if (veto && !network.contracts.gate)
|
|
390
|
+
throw new UsageError(`${network.name} has no guardian gate yet, so there is nothing to veto through`);
|
|
391
|
+
console.log(`watching ${network.name} as ${account.address}${veto ? ", vetoing through the guardian gate" : ""}`);
|
|
392
|
+
if (once) {
|
|
393
|
+
const { findings } = await watch.round();
|
|
394
|
+
if (findings > 0)
|
|
395
|
+
process.exitCode = 1;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
for (;;) {
|
|
399
|
+
await watch.round().catch((error) => console.log(`round failed: ${reason(error)}`));
|
|
400
|
+
await new Promise((resolve) => setTimeout(resolve, 30_000));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
344
403
|
if (command === "proposal") {
|
|
345
404
|
const id = positional[0];
|
|
346
405
|
if (id === undefined || !/^\d{1,18}$/.test(id) || id === "0")
|
package/dist/network.js
CHANGED
|
@@ -21,12 +21,22 @@ 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
33
|
taskBoard: "0x0beA77333E26a257983fCc59B8D857730b5B1D2B",
|
|
26
34
|
staking: "0x09468dE253517bD5d91b92115cce45798F300Cf0",
|
|
27
35
|
bonds: "0x34279448dc42b84B479daeF092749020017F001B",
|
|
28
36
|
executor: "0xFFf56B7B194C190152b17C086c36A2a79f050397",
|
|
29
37
|
registry: "0xcE777c32824fd5963a9C31C70e9B1c5D9C4ED16e",
|
|
38
|
+
log: "0xD7FC002E9FB9ba1D9119cF895936FA720545f6B8",
|
|
39
|
+
gate: "0x27c519399e178F1Ba5E453F6075b0bb9Ef4d1d5E",
|
|
30
40
|
stakeToken: "0xc717546497343911750768335b649b86d0a6eCcf",
|
|
31
41
|
bountyToken: "0xC4ec72794bF3Ce9ba1DE4B38085464c2eb0cDCf2",
|
|
32
42
|
},
|
|
@@ -62,12 +72,22 @@ export const MAINNET = {
|
|
|
62
72
|
gas: "Send it a little ETH there for gas",
|
|
63
73
|
income: "0xeb1b4168D4b87D3d9F0D64E13B7071251BBB63b7",
|
|
64
74
|
},
|
|
75
|
+
bridge: {
|
|
76
|
+
chainId: 4663,
|
|
77
|
+
name: "Robinhood Chain",
|
|
78
|
+
rpc: "https://rpc.mainnet.chain.robinhood.com",
|
|
79
|
+
inbox: "0x139a54F81d6Fdf7135D633CBCfB2373F0edfa2dd",
|
|
80
|
+
router: "0x03f06a370A59AfB23de029F4EF5EA2F9EB034F96",
|
|
81
|
+
gas: "Send it a little ETH on Robinhood Chain",
|
|
82
|
+
tokens: { stake: "0xC80333bf7f4078e14Ce5b6e223aF607aDcA44418", bounty: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168" },
|
|
83
|
+
},
|
|
65
84
|
contracts: {
|
|
66
85
|
taskBoard: MAINNET_TASK_BOARD,
|
|
67
86
|
staking: MAINNET_WORKER_STAKING,
|
|
68
87
|
bonds: MAINNET_PROPOSAL_BONDS,
|
|
69
88
|
executor: MAINNET_DECISION_EXECUTOR,
|
|
70
89
|
registry: "0x65B71706bE8a92dda39dDfdb6ABA8CEA64A33865",
|
|
90
|
+
log: "0x5FfED27750E5Ca990aB60751Fc4B25E39358FcD7",
|
|
71
91
|
stakeToken: BRAIN,
|
|
72
92
|
bountyToken: "0x8eBFEce96c0AeF7Fd23425D846a302a552c8c451",
|
|
73
93
|
},
|
|
@@ -110,6 +130,14 @@ export function chainOf(network) {
|
|
|
110
130
|
rpcUrls: { default: { http: [network.rpc] } },
|
|
111
131
|
});
|
|
112
132
|
}
|
|
133
|
+
export function parentChainOf(bridge) {
|
|
134
|
+
return defineChain({
|
|
135
|
+
id: bridge.chainId,
|
|
136
|
+
name: bridge.name,
|
|
137
|
+
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
|
138
|
+
rpcUrls: { default: { http: [bridge.rpc] } },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
113
141
|
const MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11";
|
|
114
142
|
export function proofsChainOf(proofs) {
|
|
115
143
|
return defineChain({
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { createPublicClient, createWalletClient, parseAbi } from "viem";
|
|
2
|
+
import { brainchainTransport, executorAbi, reason } from "./chain.js";
|
|
3
|
+
import { chainOf } from "./network.js";
|
|
4
|
+
/**
|
|
5
|
+
* The constitution's floors, by the function a change calls. A change that
|
|
6
|
+
* crosses one is what the guardian's veto is for. Values in the units the
|
|
7
|
+
* contracts take: basis points, seconds, and USDG in 6 decimals.
|
|
8
|
+
*/
|
|
9
|
+
export const FLOORS = {
|
|
10
|
+
"0x85327a44": {
|
|
11
|
+
name: "setParameters",
|
|
12
|
+
check: (w) => [
|
|
13
|
+
w[0] < 6000n && "the change bar falls below 60%",
|
|
14
|
+
w[1] < 86400n && "the timelock falls below 24 hours",
|
|
15
|
+
w[2] > 20n && "more than 20 house tasks a day",
|
|
16
|
+
w[3] > 10000000n && "a house bounty above 10 USDG",
|
|
17
|
+
w[4] > 50000000n && "house spend above 50 USDG a day",
|
|
18
|
+
].filter((r) => typeof r === "string"),
|
|
19
|
+
},
|
|
20
|
+
"0x00dd15f5": {
|
|
21
|
+
name: "setConfig",
|
|
22
|
+
check: (w) => [
|
|
23
|
+
w[0] < 1000n && "the hire bar falls below 10%",
|
|
24
|
+
w[1] < 6000n && "the grade bar falls below 60%",
|
|
25
|
+
w[2] < 6000n && "the ruling bar falls below 60%",
|
|
26
|
+
(w[5] < 3600n || w[6] < 3600n || w[7] < 3600n) && "a grading, dispute or ruling window under an hour",
|
|
27
|
+
w[8] < 100000n && "a minimum bounty under 0.1 USDG",
|
|
28
|
+
].filter((r) => typeof r === "string"),
|
|
29
|
+
},
|
|
30
|
+
"0x30f738be": { name: "setFee", check: (w) => (w[0] > 500n ? ["a protocol fee above 5%"] : []) },
|
|
31
|
+
"0x731022ff": {
|
|
32
|
+
name: "setModel",
|
|
33
|
+
check: (w) => [w[1] === 0n && "a house model with no account", w[2] > 5000000n && "a house model priced above 5 USDG"].filter((r) => typeof r === "string"),
|
|
34
|
+
},
|
|
35
|
+
"0x16c38b3c": { name: "setPaused", check: () => [] },
|
|
36
|
+
"0x1f1e4d9f": { name: "setWorkerAllowed", check: () => [] },
|
|
37
|
+
"0xa9059cbb": { name: "transfer", check: () => [] },
|
|
38
|
+
};
|
|
39
|
+
/** The static words after the selector; a call with dynamic arguments is read as far as its words go. */
|
|
40
|
+
export function argumentsOf(data) {
|
|
41
|
+
const body = data.slice(10);
|
|
42
|
+
const words = [];
|
|
43
|
+
for (let i = 0; i + 64 <= body.length; i += 64)
|
|
44
|
+
words.push(BigInt(`0x${body.slice(i, i + 64)}`));
|
|
45
|
+
return words;
|
|
46
|
+
}
|
|
47
|
+
/** What a queued change does to the constitution: nothing, or the floors it crosses. */
|
|
48
|
+
export function judge(data) {
|
|
49
|
+
const selector = data.slice(0, 10).toLowerCase();
|
|
50
|
+
const floor = FLOORS[selector];
|
|
51
|
+
if (!floor)
|
|
52
|
+
return { name: selector, broken: ["a call the constitution does not list"] };
|
|
53
|
+
return { name: floor.name, broken: floor.check(argumentsOf(data)) };
|
|
54
|
+
}
|
|
55
|
+
const gateAbi = parseAbi(["function veto(uint256 changeId, string reason)", "function watcher() view returns (address)"]);
|
|
56
|
+
const logAbi = parseAbi(["function count() view returns (uint256)"]);
|
|
57
|
+
export function createWatchChain(network, account) {
|
|
58
|
+
const transport = brainchainTransport(network);
|
|
59
|
+
const client = createPublicClient({ chain: chainOf(network), transport, pollingInterval: 1_000 });
|
|
60
|
+
const wallet = createWalletClient({ account, chain: chainOf(network), transport });
|
|
61
|
+
const executor = network.contracts.executor;
|
|
62
|
+
const gate = network.contracts.gate;
|
|
63
|
+
return {
|
|
64
|
+
account: account.address,
|
|
65
|
+
head: () => client.getBlockNumber(),
|
|
66
|
+
async queued(fromBlock, toBlock) {
|
|
67
|
+
const logs = await client.getContractEvents({ address: executor, abi: executorAbi, eventName: "Queued", fromBlock, toBlock });
|
|
68
|
+
return logs.map((log) => ({
|
|
69
|
+
changeId: log.args.changeId,
|
|
70
|
+
decisionId: log.args.decisionId,
|
|
71
|
+
target: log.args.target,
|
|
72
|
+
data: log.args.data,
|
|
73
|
+
eta: log.args.eta,
|
|
74
|
+
block: log.blockNumber,
|
|
75
|
+
}));
|
|
76
|
+
},
|
|
77
|
+
async open(changeId) {
|
|
78
|
+
const change = await client.readContract({ address: executor, abi: executorAbi, functionName: "getChange", args: [changeId] });
|
|
79
|
+
return !change.executed && !change.vetoed;
|
|
80
|
+
},
|
|
81
|
+
decisions: () => client.readContract({ address: network.contracts.log, abi: logAbi, functionName: "count" }),
|
|
82
|
+
...(gate
|
|
83
|
+
? {
|
|
84
|
+
async veto(changeId, why) {
|
|
85
|
+
const watcher = await client.readContract({ address: gate, abi: gateAbi, functionName: "watcher" });
|
|
86
|
+
if (watcher.toLowerCase() !== account.address.toLowerCase())
|
|
87
|
+
throw new Error(`the gate's watcher is ${watcher}, not this worker`);
|
|
88
|
+
const hash = await wallet.writeContract({ address: gate, abi: gateAbi, functionName: "veto", args: [changeId, why] });
|
|
89
|
+
await client.waitForTransactionReceipt({ hash });
|
|
90
|
+
return hash;
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
: {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Watches the chain for the worker: every queued change is judged against the
|
|
98
|
+
* constitution and, with `veto` on, one that crosses a floor is vetoed through
|
|
99
|
+
* the guardian gate; every new decision is re-verified. Each round reports
|
|
100
|
+
* what it found and keeps where it got to.
|
|
101
|
+
*/
|
|
102
|
+
export function createWatch(options) {
|
|
103
|
+
const { network, chain, verify, log } = options;
|
|
104
|
+
let nextBlock = options.fromBlock ?? network.fromBlock;
|
|
105
|
+
let seenDecision;
|
|
106
|
+
const judged = new Set();
|
|
107
|
+
let findings = 0;
|
|
108
|
+
async function changes() {
|
|
109
|
+
const head = await chain.head();
|
|
110
|
+
if (head < nextBlock)
|
|
111
|
+
return;
|
|
112
|
+
const span = 5000n;
|
|
113
|
+
for (let from = nextBlock; from <= head; from += span) {
|
|
114
|
+
const to = from + span - 1n < head ? from + span - 1n : head;
|
|
115
|
+
for (const change of await chain.queued(from, to)) {
|
|
116
|
+
const key = change.changeId.toString();
|
|
117
|
+
if (judged.has(key))
|
|
118
|
+
continue;
|
|
119
|
+
judged.add(key);
|
|
120
|
+
const { name, broken } = judge(change.data);
|
|
121
|
+
const runsAt = new Date(Number(change.eta) * 1000).toISOString().replace("T", " ").slice(0, 16);
|
|
122
|
+
if (broken.length === 0) {
|
|
123
|
+
log(`change ${change.changeId}: ${name} on ${change.target}, within the constitution, runs after ${runsAt} UTC`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
findings += 1;
|
|
127
|
+
const why = broken.join("; ");
|
|
128
|
+
log(`change ${change.changeId}: ${name} on ${change.target} breaks the constitution (${why}), runs after ${runsAt} UTC`);
|
|
129
|
+
if (!options.veto)
|
|
130
|
+
continue;
|
|
131
|
+
if (!(await chain.open(change.changeId))) {
|
|
132
|
+
log(`change ${change.changeId} is closed already, nothing to veto`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (!chain.veto) {
|
|
136
|
+
log(`cannot veto: ${network.name} has no guardian gate this worker can reach`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const hash = await chain.veto(change.changeId, `Breaks the constitution: ${why}`);
|
|
141
|
+
log(`vetoed change ${change.changeId} through the guardian gate: ${hash}`);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
log(`veto of change ${change.changeId} failed: ${reason(error)}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
nextBlock = head + 1n;
|
|
149
|
+
}
|
|
150
|
+
async function decisions() {
|
|
151
|
+
if (!verify)
|
|
152
|
+
return;
|
|
153
|
+
const count = await chain.decisions();
|
|
154
|
+
// The first round only notes where the log stands; later ones check what was added.
|
|
155
|
+
if (seenDecision === undefined) {
|
|
156
|
+
seenDecision = count;
|
|
157
|
+
log(`${count} decisions recorded so far; new ones are checked from here`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
for (let id = seenDecision + 1n; id <= count; id++) {
|
|
161
|
+
try {
|
|
162
|
+
const { findings: wrong } = await verify(id);
|
|
163
|
+
if (wrong.length === 0)
|
|
164
|
+
log(`decision ${id} verified`);
|
|
165
|
+
else {
|
|
166
|
+
findings += 1;
|
|
167
|
+
log(`decision ${id} does not verify: ${wrong.join("; ")}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
log(`decision ${id} could not be checked: ${reason(error)}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
seenDecision = count;
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
async round() {
|
|
178
|
+
await changes();
|
|
179
|
+
await decisions();
|
|
180
|
+
return { findings };
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainchain-worker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Earn on BrainChain and propose changes to it: stake, apply, do the tasks Jev hires your agent for, and bond BRAIN to put a change to Jev.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "BrainChain <hello@usebrainchain.com>",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"prepack": "npm run build"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
+
"brainchain-verify": "^0.1.2",
|
|
31
32
|
"viem": "^2.0.0"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|