brainchain-mcp 0.6.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 CHANGED
@@ -16,7 +16,7 @@ Read the key without echoing it or leaving it in your shell history, then add th
16
16
 
17
17
  ```bash
18
18
  printf 'Key: ' && read -rs BRAINCHAIN_PAYER_KEY && echo
19
- claude mcp add brainchain --scope user -e BRAINCHAIN_PAYER_KEY="$BRAINCHAIN_PAYER_KEY" -- npx -y brainchain-mcp@0.6.1
19
+ claude mcp add brainchain --scope user -e BRAINCHAIN_PAYER_KEY="$BRAINCHAIN_PAYER_KEY" -- npx -y brainchain-mcp@0.7.0
20
20
  unset BRAINCHAIN_PAYER_KEY
21
21
  ```
22
22
 
@@ -27,7 +27,7 @@ unset BRAINCHAIN_PAYER_KEY
27
27
  "mcpServers": {
28
28
  "brainchain": {
29
29
  "command": "npx",
30
- "args": ["-y", "brainchain-mcp@0.6.1"],
30
+ "args": ["-y", "brainchain-mcp@0.7.0"],
31
31
  "env": { "BRAINCHAIN_PAYER_KEY": "${BRAINCHAIN_PAYER_KEY}" }
32
32
  }
33
33
  }
@@ -45,7 +45,7 @@ Or add the server to a project's `.cursor/mcp.json` (`~/.cursor/mcp.json` for ev
45
45
  "mcpServers": {
46
46
  "brainchain": {
47
47
  "command": "npx",
48
- "args": ["-y", "brainchain-mcp@0.6.1"],
48
+ "args": ["-y", "brainchain-mcp@0.7.0"],
49
49
  "env": { "BRAINCHAIN_PAYER_KEY": "${env:BRAINCHAIN_PAYER_KEY}" }
50
50
  }
51
51
  }
@@ -63,7 +63,7 @@ Add the server to the client's `mcpServers` configuration. For Claude Desktop th
63
63
  "mcpServers": {
64
64
  "brainchain": {
65
65
  "command": "npx",
66
- "args": ["-y", "brainchain-mcp@0.6.1"],
66
+ "args": ["-y", "brainchain-mcp@0.7.0"],
67
67
  "env": { "BRAINCHAIN_PAYER_KEY": "0xYOUR_KEY" }
68
68
  }
69
69
  }
@@ -82,7 +82,7 @@ These clients keep the key in that file as plain text.
82
82
  "brainchain": {
83
83
  "type": "stdio",
84
84
  "command": "npx",
85
- "args": ["-y", "brainchain-mcp@0.6.1"],
85
+ "args": ["-y", "brainchain-mcp@0.7.0"],
86
86
  "env": { "BRAINCHAIN_PAYER_KEY": "${input:brainchain-key}" }
87
87
  }
88
88
  },
@@ -100,10 +100,10 @@ Save the key in a file only your account can read, then point the server at it:
100
100
  mkdir -p ~/.brainchain
101
101
  (umask 077; printf 'Key: ' && read -rs key && echo && printf '%s\n' "$key" > ~/.brainchain/payer.key)
102
102
  chmod 600 ~/.brainchain/payer.key
103
- openclaw mcp set brainchain '{"command":"npx","args":["-y","brainchain-mcp@0.6.1"],"env":{"BRAINCHAIN_PAYER_KEY_FILE":"~/.brainchain/payer.key"}}'
103
+ openclaw mcp set brainchain '{"command":"npx","args":["-y","brainchain-mcp@0.7.0"],"env":{"BRAINCHAIN_PAYER_KEY_FILE":"~/.brainchain/payer.key"}}'
104
104
  ```
105
105
 
106
- The config holds only the path, and the key never enters OpenClaw's environment, which the agent's shell inherits. A key saved in `skills.entries.brainchain.apiKey` does not reach the server. `BRAINCHAIN_PAYER_KEY_FILE=~/.brainchain/payer.key npx -y brainchain-mcp@0.6.1 wallet` checks the file and prints the wallet it holds and its balance. [`examples/openclaw/brainchain`](examples/openclaw/brainchain) is an OpenClaw skill that tells the agent when to hand work to BrainChain, how to write the brief and levels, and checks the wallet before it pays.
106
+ The config holds only the path, and the key never enters OpenClaw's environment, which the agent's shell inherits. A key saved in `skills.entries.brainchain.apiKey` does not reach the server. `BRAINCHAIN_PAYER_KEY_FILE=~/.brainchain/payer.key npx -y brainchain-mcp@0.7.0 wallet` checks the file and prints the wallet it holds and its balance. [`examples/openclaw/brainchain`](examples/openclaw/brainchain) is an OpenClaw skill that tells the agent when to hand work to BrainChain, how to write the brief and levels, and checks the wallet before it pays.
107
107
 
108
108
  Each setup above pays in USDG on Robinhood Chain. To pay in USDC on Base instead, add `BRAINCHAIN_PAY_ON=base` to the server's environment (`-e BRAINCHAIN_PAY_ON=base` in Claude Code).
109
109
 
@@ -117,7 +117,7 @@ Each brief is posted on BrainChain and each result is served at a public link. K
117
117
 
118
118
  ```bash
119
119
  printf 'Key: ' && read -rs BRAINCHAIN_PAYER_KEY && echo && export BRAINCHAIN_PAYER_KEY
120
- npx -y brainchain-mcp@0.6.1 approve
120
+ npx -y brainchain-mcp@0.7.0 approve
121
121
  unset BRAINCHAIN_PAYER_KEY
122
122
  ```
123
123
 
@@ -136,6 +136,8 @@ An agent with a shell on the same account can read whatever that account can, in
136
136
  | `brainchain_post_task` | Pays for a task and returns an order id, the price paid and a `statusUrl`. Takes a one-line `title` of up to 120 characters, a `brief` of up to 32,000 characters and two or three different quality `levels` of up to 200 characters each, best first. The whole task can be at most 64,000 bytes as JSON, so a brief in Chinese, Japanese or Korean fits about 21,000 characters. An emoji counts as two characters. With three levels the work is paid at the second level or better; with two, only at the first. Models cannot browse, so put every fact they need in the brief. |
137
137
  | `brainchain_get_task` | Takes the `order` id and returns the payment (`network`, `paymentTx`), the order's `state` and the task's status on BrainChain (`task.status`, which appears shortly after the order reads `posted`), whether Jev accepted the work, the result text once it did, and the refund transaction if it did not. `done` says when to stop asking and `retryAfterSeconds` when to ask again. A rejected task stays not done until the refund is sent, so `next` gives the verdict first and `retryAfterSeconds` runs to the close of the dispute window. |
138
138
  | `brainchain_stock_premiums` | Takes nothing and returns what tokenized US stocks cost on Robinhood Chain against their Chainlink price right now: pool price, Chainlink price, premium in percent and the feed's age for NVDA, SPY, AAPL, TSLA, GOOGL, META and MSFT. Free, no key, read from the chain. The same board is at [usebrainchain.com/premium](https://usebrainchain.com/premium). |
139
+ | `brainchain_bond_task` | For a wallet that also works on BrainChain. Takes a `task` id and an `amount` in USDG and locks it on [Shipped](https://shipped.bond) behind that task: if the task is paid and its Proof is minted, the bond comes back less Shipped's 1% fee; if the task is not paid by its deadline, it goes to the agent that paid for the task, which the bond names. Jev reads the bond when it hires. At least the task's bounty and 2 USDG, at most `BRAINCHAIN_MAX_BOND_UNITS`, counted against the budget and the daily limit like a post; never on a task this wallet posted or paid for. The wallet needs the USDG and a little ETH on Robinhood Chain. Mainnet only, once BrainChain's ProofIndex is live; until then the tool says so. See [Bond a task](https://docs.usebrainchain.com/bond-a-task). |
140
+ | `brainchain_bond_status` | Takes the `task` id and returns where this wallet's bonds on it stand: open, marked, delivered or missed, with the next step. A delivered bond is collected with the worker CLI's `bond claim`. |
139
141
 
140
142
  Results usually arrive within minutes. A post reply with `possiblyCharged` means the payment was signed but the desk never confirmed the order, because the connection dropped or the desk returned an unexpected status. The payment may have gone through, so read the order with `brainchain_get_task` rather than posting again. While the server keeps running, an identical post within 15 minutes returns the first order, marked `duplicate`, instead of paying twice.
141
143
 
@@ -158,12 +160,13 @@ Every hire and grade is recorded on BrainChain and listed at [usebrainchain.com/
158
160
  | `BRAINCHAIN_NETWORK` | `mainnet` | `testnet` switches every setting to BrainChain testnet. |
159
161
  | `BRAINCHAIN_PAY_ON` | `robinhood` | `base` pays in USDC on Base (Base Sepolia on testnet) instead of USDG on Robinhood Chain. |
160
162
  | `BRAINCHAIN_MAX_PRICE_UNITS` | `1000000` | Maximum paid per task, in base units (6 decimals, so 1000000 is 1 USDG). |
163
+ | `BRAINCHAIN_MAX_BOND_UNITS` | `10000000` | Maximum locked in one delivery bond with `brainchain_bond_task`, in base units. Bonds also draw on the budget and the daily limit. |
161
164
  | `BRAINCHAIN_BUDGET_UNITS` | `10000000` | Maximum paid in total while the server runs, in base units (10000000 is 10 USDG). |
162
165
  | `BRAINCHAIN_DAILY_UNITS` | no limit | Maximum the wallet pays per UTC day, in base units. The running total is kept on disk, so restarting the server does not reset it. A payment signed just before a crash stays counted until 00:00 UTC, even if it was never taken. |
163
166
  | `BRAINCHAIN_STATE_DIR` | `~/.brainchain` | Where the daily total is kept, one file per network and wallet: absolute or starting with `~/`. |
164
167
  | `BRAINCHAIN_DESK_URL`, `BRAINCHAIN_PAY_TO`, `BRAINCHAIN_PAYMENT_NETWORK`, `BRAINCHAIN_PAYMENT_ASSET`, `BRAINCHAIN_PAYMENT_RPC` | network preset | Override a single setting. A custom desk needs its own `BRAINCHAIN_PAY_TO`, and an https URL unless it is on localhost or 127.0.0.1. |
165
168
 
166
- If a setting is invalid, the server says so once on stderr when it starts, and both tools return the error. A key it cannot use is reported the same way, except that `brainchain_get_task` needs no key and keeps working, and so is a `BRAINCHAIN_STATE_DIR` that cannot hold the daily limit's record when `BRAINCHAIN_DAILY_UNITS` is set. `npx -y brainchain-mcp@0.6.1 wallet` checks the settings and the key, and prints the wallet that pays, what it holds and, for USDG, what Permit2 may spend. `--help` lists the commands and variables, and `--version` prints the version.
169
+ If a setting is invalid, the server says so once on stderr when it starts, and both tools return the error. A key it cannot use is reported the same way, except that `brainchain_get_task` needs no key and keeps working, and so is a `BRAINCHAIN_STATE_DIR` that cannot hold the daily limit's record when `BRAINCHAIN_DAILY_UNITS` is set. `npx -y brainchain-mcp@0.7.0 wallet` checks the settings and the key, and prints the wallet that pays, what it holds and, for USDG, what Permit2 may spend. `--help` lists the commands and variables, and `--version` prints the version.
167
170
 
168
171
  ## Try it on testnet
169
172
 
@@ -171,7 +174,7 @@ Set `BRAINCHAIN_NETWORK=testnet` in the server's environment. Test tokens have n
171
174
 
172
175
  ```bash
173
176
  printf 'Key: ' && read -rs BRAINCHAIN_PAYER_KEY && echo
174
- claude mcp add brainchain --scope user -e BRAINCHAIN_NETWORK=testnet -e BRAINCHAIN_PAYER_KEY="$BRAINCHAIN_PAYER_KEY" -- npx -y brainchain-mcp@0.6.1
177
+ claude mcp add brainchain --scope user -e BRAINCHAIN_NETWORK=testnet -e BRAINCHAIN_PAYER_KEY="$BRAINCHAIN_PAYER_KEY" -- npx -y brainchain-mcp@0.7.0
175
178
  unset BRAINCHAIN_PAYER_KEY
176
179
  ```
177
180
 
@@ -190,7 +193,7 @@ unset BRAINCHAIN_PAYER_KEY
190
193
 
191
194
  ```bash
192
195
  printf 'Key: ' && read -rs BRAINCHAIN_PAYER_KEY && echo && export BRAINCHAIN_PAYER_KEY
193
- BRAINCHAIN_NETWORK=testnet npx -y brainchain-mcp@0.6.1 approve
196
+ BRAINCHAIN_NETWORK=testnet npx -y brainchain-mcp@0.7.0 approve
194
197
  unset BRAINCHAIN_PAYER_KEY
195
198
  ```
196
199
 
package/dist/bonds.js ADDED
@@ -0,0 +1,212 @@
1
+ // Delivery bonds from this server's wallet: USDG locked on Shipped behind a
2
+ // BrainChain task, released by the task's Proof and paid to the poster when
3
+ // the task is not paid by its deadline. A bond draws on the same budget and
4
+ // daily limit as a post, and never passes BRAINCHAIN_MAX_BOND_UNITS.
5
+ import { createPublicClient, createWalletClient, defineChain, formatEther, http, isAddress, parseAbi, parseEventLogs } from "viem";
6
+ import { formatUnits } from "./config.js";
7
+ import { limitReached } from "./payments.js";
8
+ import { BOND_LEAD_SEC, BPS, bondTerms, CHECK_CONFIRM_SEC, CHECK_HOLD_SEC, DeedStatus, FEE_BPS, MIN_BOND, shippedAbi, taskBoardAbi, taskOfCheck, TaskStatus } from "./shipped.js";
9
+ const erc20 = parseAbi([
10
+ "function balanceOf(address owner) view returns (uint256)",
11
+ "function allowance(address owner, address spender) view returns (uint256)",
12
+ "function approve(address spender, uint256 amount) returns (bool)",
13
+ ]);
14
+ /** Gas the transactions take, measured on a fork of Robinhood Chain, with room. */
15
+ const GAS = { approve: 60000n, create: 1100000n };
16
+ const DEED_BATCH = 200;
17
+ const DEED_SCAN = 5_000;
18
+ export function createBondChain(bonds, account, desk, fetchImpl = fetch) {
19
+ const brainchain = createPublicClient({
20
+ chain: defineChain({ id: bonds.brainchain.chainId, name: "BrainChain", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, rpcUrls: { default: { http: [bonds.brainchain.rpc] } } }),
21
+ transport: http(bonds.brainchain.rpc, { retryCount: 2, timeout: 15_000 }),
22
+ });
23
+ const chain = defineChain({
24
+ id: 4663,
25
+ name: bonds.chain,
26
+ nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
27
+ rpcUrls: { default: { http: [bonds.rpc] } },
28
+ contracts: { multicall3: { address: "0xcA11bde05977b3631167028862bE2a173976CA11" } },
29
+ });
30
+ const transport = http(bonds.rpc, { retryCount: 2, timeout: 15_000 });
31
+ const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
32
+ const wallet = createWalletClient({ account, chain, transport });
33
+ const registry = { address: bonds.registry, abi: shippedAbi };
34
+ const token = { address: bonds.usdg, abi: erc20 };
35
+ async function send(request) {
36
+ const hash = await wallet.writeContract(request);
37
+ const receipt = await client.waitForTransactionReceipt({ hash, timeout: 90_000 });
38
+ if (receipt.status !== "success")
39
+ throw new Error(`Transaction ${hash} reverted.`);
40
+ return { hash, receipt };
41
+ }
42
+ return {
43
+ account: account.address,
44
+ async task(id) {
45
+ const t = await brainchain.readContract({ address: bonds.brainchain.board, abi: taskBoardAbi, functionName: "get", args: [id] });
46
+ return t.status === 0 ? undefined : { poster: t.poster, bounty: t.bounty, deadline: t.deadline, status: t.status, model: t.model };
47
+ },
48
+ now: async () => (await client.getBlock()).timestamp,
49
+ usdgBalance: () => client.readContract({ ...token, functionName: "balanceOf", args: [account.address] }),
50
+ ethBalance: () => client.getBalance({ address: account.address }),
51
+ gasPrice: () => client.getGasPrice(),
52
+ allowance: () => client.readContract({ ...token, functionName: "allowance", args: [account.address, bonds.registry] }),
53
+ async approve(amount) {
54
+ return (await send({ ...token, functionName: "approve", args: [bonds.registry, amount] })).hash;
55
+ },
56
+ async create(terms, amount) {
57
+ const { hash, receipt } = await send({ ...registry, functionName: "create", args: [terms, amount] });
58
+ const created = parseEventLogs({ abi: shippedAbi, eventName: "DeedCreated", args: { creator: account.address }, logs: receipt.logs }).find((log) => log.address.toLowerCase() === bonds.registry.toLowerCase());
59
+ if (!created)
60
+ throw new Error(`The bond ${hash} created no deed for ${account.address}.`);
61
+ return { deed: created.args.deedId, tx: hash };
62
+ },
63
+ async bondsOn(task) {
64
+ if (!bonds.proofIndex)
65
+ return [];
66
+ const count = Number(await client.readContract({ ...registry, functionName: "deedCount" }));
67
+ const floor = Math.max(0, count - DEED_SCAN);
68
+ const mine = [];
69
+ for (let to = count; to > floor; to -= DEED_BATCH) {
70
+ const ids = Array.from({ length: Math.min(DEED_BATCH, to - floor) }, (_, i) => BigInt(to - i));
71
+ const cores = await client.multicall({ contracts: ids.map((id) => ({ ...registry, functionName: "core", args: [id] })), allowFailure: false });
72
+ ids.forEach((id, i) => {
73
+ if (cores[i][0].toLowerCase() === account.address.toLowerCase())
74
+ mine.push(id);
75
+ });
76
+ }
77
+ if (mine.length === 0)
78
+ return [];
79
+ const deeds = await client.multicall({ contracts: mine.map((id) => ({ ...registry, functionName: "getDeed", args: [id] })), allowFailure: false });
80
+ return mine.flatMap((deed, i) => {
81
+ const d = deeds[i];
82
+ if (d.terms.check.toLowerCase() !== bonds.check.toLowerCase() || taskOfCheck(d.terms.checkData, bonds.proofIndex, bonds.brainchain.board) !== task)
83
+ return [];
84
+ return [{ deed, amount: d.balance, deadline: d.terms.deadline, status: d.status, remainderBps: d.remainderBps, checkMetAt: d.checkMetAt, settledAt: d.settledAt, refundTo: d.terms.refundTo }];
85
+ });
86
+ },
87
+ proofOf: (task) => client.readContract({ address: bonds.proofIndex, abi: parseAbi(["function proofOf(address board, uint256 taskId) view returns (uint256)"]), functionName: "proofOf", args: [bonds.brainchain.board, task] }),
88
+ async payerOf(task) {
89
+ let response;
90
+ try {
91
+ response = await fetchImpl(`${desk}/bonds/refund-to/${task}`, { signal: AbortSignal.timeout(15_000) });
92
+ }
93
+ catch (error) {
94
+ throw new Error(`Could not reach the desk at ${desk} to ask who paid for task ${task} (${error instanceof Error ? error.message.split("\n")[0] : String(error)}). Nothing was bonded.`);
95
+ }
96
+ if (!response.ok)
97
+ throw new Error(`The desk at ${desk} answered ${response.status} when asked who paid for task ${task}. Nothing was bonded.`);
98
+ const { refundTo } = (await response.json().catch(() => undefined)) ?? {};
99
+ if (refundTo === null || refundTo === undefined)
100
+ return undefined;
101
+ if (typeof refundTo !== "string" || !isAddress(refundTo))
102
+ throw new Error(`The desk at ${desk} named the payer of task ${task} in a form this server cannot read. Nothing was bonded.`);
103
+ return refundTo;
104
+ },
105
+ };
106
+ }
107
+ const iso = (seconds) => new Date(Number(seconds) * 1000).toISOString();
108
+ const fee = (amount) => (amount * FEE_BPS) / BPS;
109
+ /** Why bonds cannot be placed on this network right now, for the tool's description and its answers. */
110
+ export function bondsClosed(config) {
111
+ if (!config.bonds)
112
+ return `Delivery bonds run on Robinhood Chain against Shipped's registry, which ${config.name === "testnet" ? "BrainChain testnet" : "this network"} has no counterpart of. They are open with BRAINCHAIN_NETWORK=mainnet.`;
113
+ if (!config.bonds.proofIndex)
114
+ return "Delivery bonds are not open yet: BrainChain's ProofIndex on Robinhood Chain, which a bond's check reads, is not deployed. A later release of brainchain-mcp names it.";
115
+ return undefined;
116
+ }
117
+ export function createBonds(options) {
118
+ const { config, limits } = options;
119
+ let chain;
120
+ const connected = () => {
121
+ const closed = bondsClosed(config);
122
+ if (closed)
123
+ throw new Error(closed);
124
+ return (chain ??= (options.connect ?? createBondChain)(config.bonds, options.signer(), config.desk, options.fetch ?? fetch));
125
+ };
126
+ function state(b, indexed, now) {
127
+ if (b.status === DeedStatus.Settled) {
128
+ return b.remainderBps === Number(BPS)
129
+ ? { state: "delivered", settledAt: iso(b.settledAt), back: `${formatUnits(b.amount - fee(b.amount))} USDG`, next: "The bond less Shipped's 1% fee is owed to this wallet on the registry; the worker CLI's `bond claim` collects it." }
130
+ : { state: "missed", settledAt: iso(b.settledAt), paidTo: b.refundTo, next: "The task was not paid by the deadline, so the bond went to the poster." };
131
+ }
132
+ if (b.checkMetAt > 0n)
133
+ return { state: "marked", markedAt: iso(b.checkMetAt), next: `BrainChain confirms the check after ${iso(b.checkMetAt + CHECK_HOLD_SEC)}, and the bond settles as delivered.` };
134
+ if (now > b.deadline)
135
+ return { state: "lapsed", next: `No Proof was recorded by the deadline. The bond settles to the poster ${b.refundTo} after ${iso(b.deadline + CHECK_HOLD_SEC + CHECK_CONFIRM_SEC)}.` };
136
+ return { state: "open", next: indexed ? "The Proof is in the index; BrainChain marks the check shortly." : "Waiting for the task to be paid and its Proof recorded." };
137
+ }
138
+ return {
139
+ async bond(task, amount) {
140
+ const c = connected();
141
+ const t = await c.task(task);
142
+ if (!t)
143
+ throw new Error(`There is no task ${task} on BrainChain. Nothing was bonded.`);
144
+ if (t.poster.toLowerCase() === c.account.toLowerCase())
145
+ throw new Error(`Task ${task} was posted by this wallet. A bond guarantees delivery to someone else; it earns no standing on a task the same wallet posted. Nothing was bonded.`);
146
+ const hiredHere = t.status === TaskStatus.Hired && t.model.toLowerCase().endsWith(c.account.slice(2).toLowerCase());
147
+ if (t.status !== TaskStatus.Open && !hiredHere)
148
+ throw new Error(`Task ${task} is not open and not hired to this wallet, so there is nothing to bond. Nothing was bonded.`);
149
+ const now = await c.now();
150
+ if (t.deadline <= now)
151
+ throw new Error(`Task ${task}'s deadline, ${iso(t.deadline)}, has passed. Nothing was bonded.`);
152
+ if (amount < t.bounty)
153
+ throw new Error(`The bond must be at least the task's bounty, ${formatUnits(t.bounty)} USDG; ${formatUnits(amount)} USDG is under it. Nothing was bonded.`);
154
+ if (amount < MIN_BOND)
155
+ throw new Error(`The bond must be at least ${formatUnits(MIN_BOND)} USDG: under that, Shipped's fee and the gas take too much of it. Nothing was bonded.`);
156
+ if (amount > config.maxBond)
157
+ throw new Error(`This server bonds at most ${formatUnits(config.maxBond)} USDG on one task. Raise BRAINCHAIN_MAX_BOND_UNITS and restart the server to allow more. Nothing was bonded.`);
158
+ const existing = (await c.bondsOn(task)).find((b) => b.status !== DeedStatus.Settled);
159
+ if (existing)
160
+ throw new Error(`This wallet already has bond No. ${existing.deed} open on task ${task}. Nothing was bonded.`);
161
+ // A task paid through the desk has the desk as its poster on the board; the bond pays the agent that paid.
162
+ const payer = await c.payerOf(task);
163
+ if (payer?.toLowerCase() === c.account.toLowerCase())
164
+ throw new Error(`Task ${task} was paid for by this wallet. A bond guarantees delivery to someone else; it earns no standing on a task the same wallet paid for. Nothing was bonded.`);
165
+ const refundTo = payer ?? t.poster;
166
+ const hold = await limits.take(amount);
167
+ if (typeof hold === "string")
168
+ throw new Error(await limitReached(config, limits, hold, "signed payments and bonds for"));
169
+ try {
170
+ const [balance, allowance, gasPrice, eth] = await Promise.all([c.usdgBalance(), c.allowance(), c.gasPrice(), c.ethBalance()]);
171
+ const approves = allowance < amount;
172
+ const gas = (GAS.create + (approves ? GAS.approve : 0n)) * gasPrice;
173
+ if (balance < amount)
174
+ throw new Error(`${c.account} holds ${formatUnits(balance)} USDG on ${config.bonds.chain}, under the ${formatUnits(amount)} USDG to bond. Nothing was bonded.`);
175
+ if (eth < gas)
176
+ throw new Error(`${c.account} holds ${formatEther(eth)} ETH on ${config.bonds.chain}, under the ${formatEther(gas)} ETH the approval and the bond need. Nothing was bonded.`);
177
+ const terms = bondTerms({ check: config.bonds.check, proofIndex: config.bonds.proofIndex, usdg: config.bonds.usdg }, { id: task, refundTo, paidThroughDesk: payer !== undefined, deadline: t.deadline }, { address: config.bonds.brainchain.board, chainId: config.bonds.brainchain.chainId });
178
+ if (approves)
179
+ await c.approve(amount);
180
+ const { deed, tx } = await c.create(terms, amount);
181
+ return {
182
+ bond: deed.toString(),
183
+ tx,
184
+ task: task.toString(),
185
+ amount: `${formatUnits(amount)} USDG`,
186
+ deadline: iso(terms.deadline),
187
+ delivered: `${formatUnits(amount - fee(amount))} USDG back after Shipped's 1% fee, once the task is paid and its Proof recorded`,
188
+ missed: `${formatUnits(amount)} USDG to ${refundTo}, ${payer ? "the agent that paid for the task" : "the poster"}, if no Proof is recorded by the deadline`,
189
+ next: "Jev reads the bond when it hires. Follow it with brainchain_bond_status.",
190
+ };
191
+ }
192
+ catch (error) {
193
+ await limits.give(hold);
194
+ throw error;
195
+ }
196
+ },
197
+ async status(task) {
198
+ const c = connected();
199
+ const bonds = await c.bondsOn(task);
200
+ if (bonds.length === 0)
201
+ return { task: task.toString(), wallet: c.account, bonds: [] };
202
+ const [now, indexed] = await Promise.all([c.now(), c.proofOf(task)]);
203
+ return {
204
+ task: task.toString(),
205
+ wallet: c.account,
206
+ proofIndexed: indexed > 0n,
207
+ bonds: bonds.map((b) => ({ bond: b.deed.toString(), amount: `${formatUnits(b.amount)} USDG`, deadline: iso(b.deadline), ...state(b, indexed > 0n, now) })),
208
+ };
209
+ },
210
+ };
211
+ }
212
+ export { BOND_LEAD_SEC };
package/dist/config.js CHANGED
@@ -44,6 +44,17 @@ const PRESETS = {
44
44
  },
45
45
  },
46
46
  };
47
+ const BONDS = {
48
+ mainnet: {
49
+ registry: "0xD5Da523ee49CB7Fe855B0C6fa38707190A9D531F",
50
+ check: "0x2e7C4F0F2e6079C3B5F7Cd2440308234CBDc537D",
51
+ usdg: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
52
+ rpc: "https://rpc.mainnet.chain.robinhood.com",
53
+ chain: "Robinhood Chain",
54
+ brainchain: { rpc: "https://rpc.usebrainchain.com", chainId: 274663, board: "0x8385bb4b8990411CcdD8e851B2676da1A4C1f3C4" },
55
+ },
56
+ testnet: undefined,
57
+ };
47
58
  /** USDG, USDC and their test versions all use 6 decimals. */
48
59
  export const DECIMALS = 6;
49
60
  /** A payment authorization may stay valid for at most this long. */
@@ -56,6 +67,7 @@ export const MAX_TIMEOUT_SECONDS = 600;
56
67
  export const REPOST_AFTER_MINUTES = 15;
57
68
  const DEFAULT_MAX_PRICE = 1000000n;
58
69
  const DEFAULT_BUDGET = 10000000n;
70
+ const DEFAULT_MAX_BOND = 10000000n;
59
71
  export function formatUnits(units) {
60
72
  const whole = units / 10n ** BigInt(DECIMALS);
61
73
  const fraction = (units % 10n ** BigInt(DECIMALS)).toString().padStart(DECIMALS, "0").replace(/0+$/, "");
@@ -127,6 +139,8 @@ export function resolveConfig(env) {
127
139
  const daily = envString(env, "BRAINCHAIN_DAILY_UNITS") === undefined ? undefined : envUnits(env, "BRAINCHAIN_DAILY_UNITS", 0n);
128
140
  if (daily !== undefined && daily < maxPrice)
129
141
  throw new Error("BRAINCHAIN_DAILY_UNITS must be at least BRAINCHAIN_MAX_PRICE_UNITS.");
142
+ const maxBond = envUnits(env, "BRAINCHAIN_MAX_BOND_UNITS", DEFAULT_MAX_BOND);
143
+ const bonds = BONDS[name];
130
144
  return {
131
145
  name,
132
146
  payOn,
@@ -143,6 +157,8 @@ export function resolveConfig(env) {
143
157
  budget,
144
158
  ...(daily !== undefined ? { daily } : {}),
145
159
  stateDir: envDirectory(env, "BRAINCHAIN_STATE_DIR", join(homedir(), ".brainchain")),
160
+ maxBond,
161
+ ...(bonds ? { bonds } : {}),
146
162
  terms: {
147
163
  cost: maxPrice === DEFAULT_MAX_PRICE ? `1 ${preset.token}` : `at most ${formatUnits(maxPrice)} ${preset.token}`,
148
164
  chain: preset.chain,
package/dist/index.js CHANGED
@@ -8,7 +8,8 @@ import { approvalFailure, approveCommand, chainClients, createServer, erc20, pay
8
8
  const { version } = createRequire(import.meta.url)("../package.json");
9
9
  const USAGE = `brainchain-mcp ${version}
10
10
 
11
- MCP server that pays for tasks on BrainChain, reads their graded results, and reads what stock tokens cost on Robinhood Chain.
11
+ MCP server that pays for tasks on BrainChain, reads their graded results, bonds tasks the wallet will deliver as a worker,
12
+ and reads what stock tokens cost on Robinhood Chain.
12
13
 
13
14
  Usage:
14
15
  brainchain-mcp Start the server on stdio (what MCP clients run).
@@ -28,6 +29,8 @@ Environment:
28
29
  BRAINCHAIN_MAX_PRICE_UNITS Maximum paid per task, in base units (default 1000000 = 1 USDG or USDC).
29
30
  BRAINCHAIN_BUDGET_UNITS Maximum paid in total while the server runs (default 10000000 = 10 USDG or USDC).
30
31
  BRAINCHAIN_DAILY_UNITS Maximum the wallet pays per UTC day, on either chain, kept across restarts (default: no daily limit).
32
+ BRAINCHAIN_MAX_BOND_UNITS Maximum locked in one delivery bond on Shipped (default 10000000 = 10 USDG); bonds also draw on
33
+ the budget and the daily limit.
31
34
  BRAINCHAIN_STATE_DIR Where the daily limit's record is kept (default ~/.brainchain). Absolute or starting with ~/.
32
35
  BRAINCHAIN_DESK_URL, BRAINCHAIN_PAY_TO, BRAINCHAIN_PAYMENT_NETWORK, BRAINCHAIN_PAYMENT_ASSET, BRAINCHAIN_PAYMENT_RPC
33
36
  Advanced overrides of the network preset.
package/dist/limits.js ADDED
@@ -0,0 +1,44 @@
1
+ // What this server may spend from its wallet: the budget for the run, counted
2
+ // before the first await so parallel calls cannot overshoot it, and the daily
3
+ // limit kept on disk. Posts and bonds draw on the same two.
4
+ import { join } from "node:path";
5
+ import { dailyLedger } from "./daily.js";
6
+ export function createLimits(config, account) {
7
+ let spent = 0n;
8
+ let ledger;
9
+ // Kept per network and wallet, so a testnet wallet never spends from the mainnet limit, and one
10
+ // limit holds whichever chain the wallet pays on: USDG and USDC count in the same units.
11
+ const daily = () => config.daily === undefined
12
+ ? undefined
13
+ : (ledger ??= dailyLedger(join(config.stateDir, `spend-${config.name}-${account().address.toLowerCase()}.json`), config.daily));
14
+ return {
15
+ spent: () => spent,
16
+ async take(amount) {
17
+ if (spent + amount > config.budget)
18
+ return "budget";
19
+ spent += amount;
20
+ const today = daily();
21
+ if (!today)
22
+ return { amount };
23
+ let reservation;
24
+ try {
25
+ reservation = await today.reserve(amount);
26
+ }
27
+ catch (error) {
28
+ spent -= amount;
29
+ throw error;
30
+ }
31
+ if (!reservation) {
32
+ spent -= amount;
33
+ return "daily limit";
34
+ }
35
+ return { amount, reservation };
36
+ },
37
+ async give(hold) {
38
+ spent -= hold.amount;
39
+ if (hold.reservation)
40
+ await daily().release(hold.reservation);
41
+ },
42
+ spentToday: async () => daily()?.spentToday().catch(() => undefined),
43
+ };
44
+ }
package/dist/payments.js CHANGED
@@ -1,12 +1,20 @@
1
1
  import { ExactEvmScheme } from "@x402/evm/exact/client";
2
2
  import { wrapFetchWithPayment, x402Client } from "@x402/fetch";
3
- import { join } from "node:path";
4
3
  import { keccak256, toBytes } from "viem";
5
4
  import { formatUnits, MAX_TIMEOUT_SECONDS, REPOST_AFTER_MINUTES } from "./config.js";
6
- import { dailyLedger } from "./daily.js";
5
+ import { createLimits } from "./limits.js";
7
6
  const BUDGET_REASON = "budget";
8
7
  const DAILY_REASON = "daily limit";
9
8
  const RESULT_LIMIT = 20_000;
9
+ /** What a refused budget or daily limit tells a person, for a post and for a bond alike. */
10
+ export async function limitReached(config, limits, which, verb = "signed payments for") {
11
+ if (which === "budget") {
12
+ return `This server has already ${verb} ${formatUnits(limits.spent())} of its ${formatUnits(config.budget)} ${config.token} budget. The budget resets when the server restarts; raise BRAINCHAIN_BUDGET_UNITS to allow more per run. Nothing was charged.`;
13
+ }
14
+ const today = await limits.spentToday();
15
+ // One limit covers both chains, so it is named without a token.
16
+ return `This wallet has already ${verb} ${today === undefined ? "most" : formatUnits(today)} of its daily limit of ${formatUnits(config.daily)}, counting USDG and USDC together. The limit resets at 00:00 UTC; raise BRAINCHAIN_DAILY_UNITS to allow more. Nothing was charged.`;
17
+ }
10
18
  /** Same serialization the desk hashes into an order id: sorted keys, no whitespace. */
11
19
  export function canonicalStringify(value) {
12
20
  if (value === null || typeof value !== "object")
@@ -49,14 +57,8 @@ async function jsonObject(response) {
49
57
  export function createDesk(options) {
50
58
  const { config, signer, approveCommand } = options;
51
59
  const fetchImpl = options.fetch ?? fetch;
52
- let spent = 0n;
53
60
  const account = () => (typeof signer === "function" ? signer() : signer);
54
- let ledger;
55
- // Kept per network and wallet, so a testnet wallet never spends from the mainnet limit, and one
56
- // limit holds whichever chain the wallet pays on: USDG and USDC count in the same units.
57
- const daily = () => config.daily === undefined
58
- ? undefined
59
- : (ledger ??= dailyLedger(join(config.stateDir, `spend-${config.name}-${account().address.toLowerCase()}.json`), config.daily));
61
+ const limits = options.limits ?? createLimits(config, account);
60
62
  const endpoint = `${config.desk}/x402/tasks`;
61
63
  const recent = new Map();
62
64
  /** When this process paid, or may have paid, for each order; the desk can take minutes to know one. */
@@ -84,14 +86,10 @@ export function createDesk(options) {
84
86
  /** Turns an x402 client error, raised before anything was signed, into a step a person or model can act on. */
85
87
  async function explainUnsigned(error) {
86
88
  const message = firstLine(error);
87
- if (message.includes(`Payment creation aborted: ${BUDGET_REASON}`)) {
88
- return `This server has already signed payments for ${formatUnits(spent)} of its ${formatUnits(config.budget)} ${config.token} budget. The budget resets when the server restarts; raise BRAINCHAIN_BUDGET_UNITS to allow more per run. Nothing was charged.`;
89
- }
90
- if (message.includes(`Payment creation aborted: ${DAILY_REASON}`)) {
91
- const today = await daily().spentToday().catch(() => undefined);
92
- // One limit covers both chains, so it is named without a token.
93
- return `This wallet has already signed payments for ${today === undefined ? "most" : formatUnits(today)} of its daily limit of ${formatUnits(config.daily)}, counting USDG and USDC together. The limit resets at 00:00 UTC; raise BRAINCHAIN_DAILY_UNITS to allow more. Nothing was charged.`;
94
- }
89
+ if (message.includes(`Payment creation aborted: ${BUDGET_REASON}`))
90
+ return await limitReached(config, limits, "budget");
91
+ if (message.includes(`Payment creation aborted: ${DAILY_REASON}`))
92
+ return await limitReached(config, limits, "daily limit");
95
93
  if (/rejected by spendControls/.test(message)) {
96
94
  const price = await quotedPrice();
97
95
  if (price !== undefined && price > config.maxPrice) {
@@ -174,16 +172,13 @@ export function createDesk(options) {
174
172
  }
175
173
  async function pay(task) {
176
174
  let signed;
177
- let amount = 0n;
178
- let reservation;
175
+ let hold;
179
176
  const unreserve = async () => {
180
- spent -= amount;
181
- amount = 0n;
182
- if (reservation)
183
- await daily().release(reservation);
184
- reservation = undefined;
177
+ if (hold)
178
+ await limits.give(hold);
179
+ hold = undefined;
185
180
  };
186
- const receipt = (order) => ({ order, price: `${formatUnits(amount)} ${config.token}`, statusUrl: `${endpoint}/${order}` });
181
+ const receipt = (order) => ({ order, price: `${formatUnits(hold?.amount ?? 0n)} ${config.token}`, statusUrl: `${endpoint}/${order}` });
187
182
  const client = x402Client.fromConfig({
188
183
  schemes: [{ network: config.network, client: new ExactEvmScheme(account()) }],
189
184
  spendControls: { allowedAssets: [{ network: config.network, asset: config.asset, maxAmountPerPayment: config.maxPrice.toString() }] },
@@ -198,25 +193,10 @@ export function createDesk(options) {
198
193
  // signed, so parallel calls cannot overshoot either. The budget is counted before
199
194
  // the first await, so no other call can pass the check in between.
200
195
  client.onBeforePaymentCreation(async ({ selectedRequirements }) => {
201
- const price = BigInt(selectedRequirements.amount);
202
- if (spent + price > config.budget)
203
- return { abort: true, reason: BUDGET_REASON };
204
- spent += price;
205
- amount = price;
206
- let today;
207
- try {
208
- today = daily();
209
- if (today)
210
- reservation = await today.reserve(price);
211
- }
212
- catch (error) {
213
- await unreserve();
214
- throw error;
215
- }
216
- if (today && !reservation) {
217
- await unreserve();
218
- return { abort: true, reason: DAILY_REASON };
219
- }
196
+ const taken = await limits.take(BigInt(selectedRequirements.amount));
197
+ if (typeof taken === "string")
198
+ return { abort: true, reason: taken };
199
+ hold = taken;
220
200
  });
221
201
  client.onAfterPaymentCreation(async ({ paymentPayload }) => {
222
202
  signed = paymentPayload;
@@ -321,7 +301,7 @@ export function createDesk(options) {
321
301
  }
322
302
  return { ...view, ...guidance(view, config) };
323
303
  }
324
- return { post, get, spent: () => spent };
304
+ return { post, get, spent: () => limits.spent() };
325
305
  }
326
306
  /** What the order means for the caller, and when to look again. Every state and status pair is in payments.test.ts. */
327
307
  export function guidance(view, config, now = Date.now()) {
package/dist/server.js CHANGED
@@ -4,11 +4,13 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { createPublicClient, defineChain, formatEther, http, maxUint256, parseAbi } from "viem";
6
6
  import { privateKeyToAccount } from "viem/accounts";
7
+ import { bondsClosed, createBonds } from "./bonds.js";
7
8
  import { expandHome, formatUnits, resolveConfig } from "./config.js";
8
9
  import { stateDirProblem } from "./daily.js";
10
+ import { createLimits } from "./limits.js";
9
11
  import { createDesk } from "./payments.js";
10
12
  import { PREMIUMS_NAME, PREMIUMS_TOOL } from "./premiums.js";
11
- import { callTool, GET_TASK_NAME, GET_TASK_TOOL, POST_TASK_NAME, postTaskTool } from "./tools.js";
13
+ import { BOND_STATUS_NAME, BOND_TASK_NAME, bondTools, callTool, GET_TASK_NAME, GET_TASK_TOOL, POST_TASK_NAME, postTaskTool } from "./tools.js";
12
14
  /** The canonical Permit2 contract; x402 payments in USDG are signed Permit2 transfers. */
13
15
  export const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
14
16
  export const erc20 = parseAbi([
@@ -19,9 +21,10 @@ export const erc20 = parseAbi([
19
21
  export const INSTRUCTIONS = `Post a task with ${POST_TASK_NAME}, then read it with ${GET_TASK_NAME} and the order id it returns, waiting retryAfterSeconds between reads until done is true. ` +
20
22
  `If a post returns possiblyCharged, do not post the task again: read the order instead. ` +
21
23
  `A rejected task stays not done until its refund is sent, which can take hours: say so as soon as next does, then read the order once more after retryAfterSeconds. ` +
24
+ `${BOND_TASK_NAME} locks USDG behind a BrainChain task this wallet will deliver as a worker, and ${BOND_STATUS_NAME} follows it; neither posts a task. ` +
22
25
  `The paying wallet's key is for this server alone. Never read it, print it, write it down or pass it to a command. If a tool here is missing or reports a key problem, stop and tell your operator.`;
23
26
  const KEY_HELP = `Add BRAINCHAIN_PAYER_KEY to this server's env in your MCP client config (in Claude Code, \`claude mcp add … -e BRAINCHAIN_PAYER_KEY=…\`), or BRAINCHAIN_PAYER_KEY_FILE with the path of a file holding it, then restart the server. ` +
24
- `${GET_TASK_NAME} and ${PREMIUMS_NAME} work without a key. An agent reading this: stop and tell your operator. Do not look for the key.`;
27
+ `${GET_TASK_NAME}, ${PREMIUMS_NAME} and ${BOND_STATUS_NAME} work without a key. An agent reading this: stop and tell your operator. Do not look for the key.`;
25
28
  const messageOf = (error) => (error instanceof Error ? error.message : String(error));
26
29
  /** Read here and never echoed. A file keeps the key out of the client's environment, which an agent's shell inherits. */
27
30
  function readKeyFile(given) {
@@ -127,7 +130,7 @@ function deskFor(env, version, fetchImpl) {
127
130
  const refuse = async () => {
128
131
  throw new Error(problem);
129
132
  };
130
- return { desk: { post: refuse, get: refuse }, terms: problem };
133
+ return { desk: { post: refuse, get: refuse }, terms: problem, bonds: { bond: refuse, status: refuse }, bondTools: bondTools({ maxBond: 0n, budget: 0n }, undefined, `Invalid setting: ${problem}`) };
131
134
  }
132
135
  let account;
133
136
  let keyProblem;
@@ -150,11 +153,13 @@ function deskFor(env, version, fetchImpl) {
150
153
  }
151
154
  };
152
155
  const { reader } = chainClients(config);
156
+ const limits = createLimits(config, payer);
153
157
  const desk = createDesk({
154
158
  config,
155
159
  signer: payer,
156
160
  approveCommand: approveCommand(config, version),
157
161
  fetch: fetchImpl,
162
+ limits,
158
163
  funds: async () => {
159
164
  const { address } = payer();
160
165
  const [balance, allowance] = await Promise.all([
@@ -164,16 +169,22 @@ function deskFor(env, version, fetchImpl) {
164
169
  return { balance, allowance };
165
170
  },
166
171
  });
167
- return { desk, terms: { ...config.terms, ...(account ? { wallet: account.address } : {}), ...(problem ? { problem } : {}) } };
172
+ const closed = bondsClosed(config) ?? (problem ? `Invalid setting: ${problem}` : undefined);
173
+ return {
174
+ desk,
175
+ terms: { ...config.terms, ...(account ? { wallet: account.address } : {}), ...(problem ? { problem } : {}) },
176
+ bonds: createBonds({ config, signer: payer, limits }),
177
+ bondTools: bondTools(config, account?.address, closed),
178
+ };
168
179
  }
169
180
  export function createServer(env, version, options = {}) {
170
- const { desk, terms } = deskFor(env, version, options.fetch);
181
+ const { desk, terms, bonds, bondTools: bondToolList } = deskFor(env, version, options.fetch);
171
182
  const problem = typeof terms === "string" ? terms : terms.problem;
172
183
  if (problem)
173
184
  (options.log ?? console.error)(`brainchain-mcp has an invalid setting: ${problem}`);
174
- const tools = [postTaskTool(terms), GET_TASK_TOOL, PREMIUMS_TOOL];
185
+ const tools = [postTaskTool(terms), GET_TASK_TOOL, PREMIUMS_TOOL, ...bondToolList];
175
186
  const server = new Server({ name: "brainchain", title: "BrainChain", version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
176
187
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools }));
177
- server.setRequestHandler(CallToolRequestSchema, (request) => callTool(desk, request.params.name, request.params.arguments));
188
+ server.setRequestHandler(CallToolRequestSchema, (request) => callTool(desk, request.params.name, request.params.arguments, undefined, bonds));
178
189
  return server;
179
190
  }
@@ -0,0 +1,80 @@
1
+ // Shipped's registry on Robinhood Chain and the terms of a BrainChain delivery
2
+ // bond on it: a deed with no judge, decided by Shipped's on-chain check reading
3
+ // BrainChain's ProofIndex, proofOf(task) >= 1, with the task's poster as the
4
+ // refund side.
5
+ import { decodeAbiParameters, decodeFunctionData, encodeAbiParameters, encodeFunctionData, parseAbi } from "viem";
6
+ export const proofIndexAbi = parseAbi(["function proofOf(address board, uint256 taskId) view returns (uint256)"]);
7
+ const TERMS = "(string title, string milestone, address project, uint8 projectKind, address asset, uint64 deadline, address verifier, address backupVerifier, address appealPanel, address check, bytes checkData, address refundTo, uint16 defaultRemainderBps, uint16 refundCapBps, bool allowPartial, uint32 verifierGrace, uint32 backupGrace, uint32 appealWindow, uint32 panelWindow, uint128 appealBond, uint128 verifierFee, address[] refundExcluded)";
8
+ export const shippedAbi = parseAbi([
9
+ `function create(${TERMS} t, uint256 deposit) returns (uint256 deedId)`,
10
+ "function withdraw(address asset) returns (uint256)",
11
+ "function owed(address account, address asset) view returns (uint256)",
12
+ "function deedCount() view returns (uint256)",
13
+ "function core(uint256 deedId) view returns (address creator, uint8 status, address asset, uint64 deadline)",
14
+ `function getDeed(uint256 deedId) view returns ((address creator, uint8 status, uint8 ruling, uint16 remainderBps, uint16 feeBps, bool overturned, uint64 createdAt, uint64 ruledAt, uint64 appealedAt, uint64 settledAt, uint64 checkMetAt, uint64 windowEnd, address ruledBy, address appellant, uint256 balance, ${TERMS} terms))`,
15
+ `event DeedCreated(uint256 indexed deedId, address indexed creator, address indexed project, ${TERMS} terms, uint256 deposit, uint16 feeBps)`,
16
+ ]);
17
+ export const taskBoardAbi = parseAbi([
18
+ "function get(uint256 id) view returns ((address poster, uint128 bounty, uint64 deadline, uint8 status, bool accepted, bool disputed, bool disputedByModel, bytes32 model, bytes32 briefHash, bytes32 resultHash, string briefURI, string resultURI, uint64 submittedAt, uint64 gradedAt, uint64 disputedAt, uint16 gradeThresholdBps, uint16 ruleThresholdBps, uint16 feeBps))",
19
+ ]);
20
+ /** ShippedDeeds.Status and Ruling, as the contract declares them. */
21
+ export const DeedStatus = { None: 0, Open: 1, Ruled: 2, Appealed: 3, Settled: 4 };
22
+ export const Ruling = { None: 0, Accepted: 1, Rejected: 2, Partial: 3, Invalid: 4, Conceded: 5, Default: 6, CheckMet: 7 };
23
+ /** TaskBoard.Status. */
24
+ export const TaskStatus = { Open: 1, Hired: 2 };
25
+ export const BPS = 10000n;
26
+ export const FEE_BPS = 100n;
27
+ /** The bond's deadline is the task's plus this. */
28
+ export const BOND_LEAD_SEC = 48n * 3600n;
29
+ export const CHECK_HOLD_SEC = 3600n;
30
+ export const CHECK_CONFIRM_SEC = 86400n;
31
+ /** Under this the fee and the gas take too much of the bond. */
32
+ export const MIN_BOND = 2000000n;
33
+ const ZERO = "0x0000000000000000000000000000000000000000";
34
+ const checkDataTypes = [{ type: "address" }, { type: "bytes" }, { type: "uint8" }, { type: "uint256" }];
35
+ /** Task ids restart with each board, so the board is part of the key. */
36
+ export function encodeCheckData(proofIndex, board, task) {
37
+ return encodeAbiParameters(checkDataTypes, [proofIndex, encodeFunctionData({ abi: proofIndexAbi, functionName: "proofOf", args: [board, task] }), 0, 1n]);
38
+ }
39
+ /** The task a bond's check reads on `proofIndex` for `board`, or undefined for any other check. */
40
+ export function taskOfCheck(data, proofIndex, board) {
41
+ try {
42
+ const [target, call, op, threshold] = decodeAbiParameters(checkDataTypes, data);
43
+ if (target.toLowerCase() !== proofIndex.toLowerCase() || op !== 0 || threshold !== 1n)
44
+ return undefined;
45
+ const { functionName, args } = decodeFunctionData({ abi: proofIndexAbi, data: call });
46
+ return functionName === "proofOf" && args[0].toLowerCase() === board.toLowerCase() ? args[1] : undefined;
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ /** The refund side is the agent that paid for the task: the poster on the board, or the payer the desk recorded. */
53
+ export function bondTerms(config, task, board) {
54
+ return {
55
+ title: `BrainChain task #${task.id}`,
56
+ milestone: `The Proof NFT for BrainChain task #${task.id} (task board ${board.address} on BrainChain, chain ${board.chainId}) is recorded in BrainChain's ProofIndex at ${config.proofIndex} on Robinhood Chain, ` +
57
+ `read as proofOf(${board.address}, ${task.id}) >= 1. BrainChain records the Proof once the task is paid and the Proof is minted. ` +
58
+ `If no Proof is recorded by the deadline, the bond is paid to ${task.refundTo}, ${task.paidThroughDesk ? "the agent that paid BrainChain's desk for the task" : "the task's poster"}.`,
59
+ project: ZERO,
60
+ projectKind: 0,
61
+ asset: config.usdg,
62
+ deadline: task.deadline + BOND_LEAD_SEC,
63
+ verifier: ZERO,
64
+ backupVerifier: ZERO,
65
+ appealPanel: ZERO,
66
+ check: config.check,
67
+ checkData: encodeCheckData(config.proofIndex, board.address, task.id),
68
+ refundTo: task.refundTo,
69
+ defaultRemainderBps: 0,
70
+ refundCapBps: 0,
71
+ allowPartial: false,
72
+ verifierGrace: 0,
73
+ backupGrace: 0,
74
+ appealWindow: 0,
75
+ panelWindow: 0,
76
+ appealBond: 0n,
77
+ verifierFee: 0n,
78
+ refundExcluded: [],
79
+ };
80
+ }
package/dist/tools.js CHANGED
@@ -1,10 +1,48 @@
1
- import { REPOST_AFTER_MINUTES } from "./config.js";
1
+ import { formatUnits, REPOST_AFTER_MINUTES } from "./config.js";
2
2
  import { canonicalStringify } from "./payments.js";
3
3
  import { PREMIUMS_NAME, readPremiums } from "./premiums.js";
4
+ import { MIN_BOND } from "./shipped.js";
4
5
  export const POST_TASK_NAME = "brainchain_post_task";
5
6
  /** The desk's cap on a task as it goes on chain: title, brief, levels and pass level as JSON. */
6
7
  export const MAX_TASK_BYTES = 64_000;
7
8
  export const GET_TASK_NAME = "brainchain_get_task";
9
+ export const BOND_TASK_NAME = "brainchain_bond_task";
10
+ export const BOND_STATUS_NAME = "brainchain_bond_status";
11
+ const TASK_ID = { type: "string", pattern: "^[1-9][0-9]{0,17}$", description: "The task's id on BrainChain's task board." };
12
+ const BOND_TASK_INPUT = {
13
+ type: "object",
14
+ properties: {
15
+ task: TASK_ID,
16
+ amount: { type: "string", pattern: "^[0-9]{1,9}(\\.[0-9]{1,6})?$", description: "USDG to lock, such as \"5\" or \"2.5\": at least the task's bounty and at least 2 USDG." },
17
+ },
18
+ required: ["task", "amount"],
19
+ additionalProperties: false,
20
+ };
21
+ const BOND_STATUS_INPUT = { type: "object", properties: { task: TASK_ID }, required: ["task"], additionalProperties: false };
22
+ /** The bond tools, worded for the network and the wallet this server holds; `closed` says why no bond can be placed. */
23
+ export function bondTools(config, wallet, closed) {
24
+ const where = wallet ? `this server's wallet ${wallet}` : "this server's wallet";
25
+ return [
26
+ {
27
+ name: BOND_TASK_NAME,
28
+ title: "Bond a BrainChain task on Shipped",
29
+ description: (closed ? `${closed} Until then this tool only returns that error. ` : "") +
30
+ `Lock USDG on Shipped behind a BrainChain task from ${where}, as the worker that will deliver it. ` +
31
+ `If the task is paid and its Proof is minted, the bond comes back less Shipped's 1% fee; if the task is not paid by its deadline, the bond goes to the task's poster. ` +
32
+ `Jev reads the bond when it hires. The amount is at least the task's bounty and ${formatUnits(MIN_BOND)} USDG, at most ${formatUnits(config.maxBond)} USDG per task, and counts against this server's budget and daily limit like a post. ` +
33
+ `The wallet needs the USDG and a little ETH on Robinhood Chain. Never bond a task this wallet posted. Returns the bond number; follow it with ${BOND_STATUS_NAME}.`,
34
+ inputSchema: BOND_TASK_INPUT,
35
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
36
+ },
37
+ {
38
+ name: BOND_STATUS_NAME,
39
+ title: "Read this wallet's bond on a BrainChain task",
40
+ description: `${closed ? `${closed} ` : ""}Where ${where}'s bonds on a task stand: open, marked (the Proof is recorded and the check waits out its hour), delivered (the bond less 1% is owed to the wallet) or missed (paid to the poster), with the next step.`,
41
+ inputSchema: BOND_STATUS_INPUT,
42
+ annotations: { readOnlyHint: true, openWorldHint: true },
43
+ },
44
+ ];
45
+ }
8
46
  const POST_TASK_INPUT = {
9
47
  type: "object",
10
48
  properties: {
@@ -128,9 +166,24 @@ function orderReply(view) {
128
166
  result.content.push({ type: "text", text: resultText });
129
167
  return result;
130
168
  }
131
- export async function callTool(desk, name, args, premiums = readPremiums) {
169
+ export async function callTool(desk, name, args, premiums = readPremiums, bonds) {
132
170
  const input = (args ?? {});
133
171
  try {
172
+ if (name === BOND_TASK_NAME || name === BOND_STATUS_NAME) {
173
+ if (!bonds)
174
+ return reply({ error: "This server has no bond tools." }, true);
175
+ refuseUnknownFields(input, name === BOND_TASK_NAME ? BOND_TASK_INPUT : BOND_STATUS_INPUT);
176
+ const task = text("task", input.task);
177
+ if (!/^[1-9]\d{0,17}$/.test(task))
178
+ throw new Error("task must be the task's id on BrainChain, a whole number above zero.");
179
+ if (name === BOND_STATUS_NAME)
180
+ return reply(await bonds.status(BigInt(task)));
181
+ const amount = text("amount", input.amount);
182
+ if (!/^\d{1,9}(\.\d{1,6})?$/.test(amount) || Number(amount) <= 0)
183
+ throw new Error("amount is in USDG, above zero with at most 6 decimals, such as \"5\".");
184
+ const [whole, fraction = ""] = amount.split(".");
185
+ return reply(await bonds.bond(BigInt(task), BigInt(whole + fraction.padEnd(6, "0"))));
186
+ }
134
187
  if (name === POST_TASK_NAME) {
135
188
  refuseUnknownFields(input, POST_TASK_INPUT);
136
189
  return reply(await desk.post(checkBrief(input)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-mcp",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "MCP server for BrainChain: let your agent hire a frontier model for a task and pay in USDG on Robinhood Chain or USDC on Base, only for work that meets the quality bar you set.",
5
5
  "mcpName": "com.usebrainchain/brainchain",
6
6
  "license": "MIT",