brainchain-mcp 0.6.0 → 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.0
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.0"],
30
+ "args": ["-y", "brainchain-mcp@0.7.0"],
31
31
  "env": { "BRAINCHAIN_PAYER_KEY": "${BRAINCHAIN_PAYER_KEY}" }
32
32
  }
33
33
  }
@@ -36,7 +36,7 @@ unset BRAINCHAIN_PAYER_KEY
36
36
 
37
37
  ### Cursor
38
38
 
39
- [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=brainchain&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImJyYWluY2hhaW4tbWNwQDAuNS4xIl0sImVudiI6eyJCUkFJTkNIQUlOX1BBWUVSX0tFWSI6IiR7ZW52OkJSQUlOQ0hBSU5fUEFZRVJfS0VZfSJ9fQ%3D%3D)
39
+ [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=brainchain&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImJyYWluY2hhaW4tbWNwQDAuNi4xIl0sImVudiI6eyJCUkFJTkNIQUlOX1BBWUVSX0tFWSI6IiR7ZW52OkJSQUlOQ0hBSU5fUEFZRVJfS0VZfSJ9fQ%3D%3D)
40
40
 
41
41
  Or add the server to a project's `.cursor/mcp.json` (`~/.cursor/mcp.json` for every project):
42
42
 
@@ -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.0"],
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.0"],
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.0"],
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.0"],"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.0 wallet` checks the file and prints the wallet it holds. [`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.0 approve
120
+ npx -y brainchain-mcp@0.7.0 approve
121
121
  unset BRAINCHAIN_PAYER_KEY
122
122
  ```
123
123
 
@@ -133,9 +133,11 @@ An agent with a shell on the same account can read whatever that account can, in
133
133
 
134
134
  | Tool | What it does |
135
135
  | --- | --- |
136
- | `brainchain_post_task` | Pays for a task and returns an order id, the price paid and a `statusUrl`. Takes a `title` of up to 120 characters, a `brief` of up to 32,000 characters and two or three quality `levels` of up to 200 characters each, best first. 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. |
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. `npx -y brainchain-mcp@0.6.0 wallet` checks the settings and the key, and prints the wallet that pays. `--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.0
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.0 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/daily.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // The daily limit is kept on disk, so restarting the server, or a second server
2
2
  // paying from the same wallet, does not reset what the wallet may spend today.
3
+ import { accessSync, constants, mkdirSync } from "node:fs";
3
4
  import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
5
  import { dirname } from "node:path";
5
6
  const LOCK_STALE_MS = 10_000;
@@ -10,6 +11,28 @@ const TAKEOVER_STALE_MS = 5_000;
10
11
  export function utcDay(now) {
11
12
  return new Date(now).toISOString().slice(0, 10);
12
13
  }
14
+ function stateDirMessage(dir, error) {
15
+ const { code } = error;
16
+ const which = code === "EEXIST" || code === "ENOTDIR"
17
+ ? "is not a folder"
18
+ : code === "EACCES" || code === "EPERM"
19
+ ? "this account cannot write to"
20
+ : code === "EROFS"
21
+ ? "is on a read-only disk"
22
+ : `cannot be used (${code ?? String(error)})`;
23
+ return `BRAINCHAIN_STATE_DIR is ${dir}, which ${which}, so the daily limit's record cannot be kept there. Set it to a folder this account can write, then restart the server.`;
24
+ }
25
+ /** Why the daily limit's record cannot be kept in `dir`, creating the folder if it is missing; undefined when it can. */
26
+ export function stateDirProblem(dir) {
27
+ try {
28
+ mkdirSync(dir, { recursive: true });
29
+ accessSync(dir, constants.W_OK);
30
+ return undefined;
31
+ }
32
+ catch (error) {
33
+ return stateDirMessage(dir, error);
34
+ }
35
+ }
13
36
  /** How long ago a file was last written, or 0 when it is gone. */
14
37
  const ageOf = (file) => stat(file).then((s) => Date.now() - s.mtimeMs, () => 0);
15
38
  /**
@@ -42,7 +65,9 @@ async function clearStale(lockPath) {
42
65
  export function dailyLedger(path, limit, now = Date.now) {
43
66
  const lockPath = `${path}.lock`;
44
67
  async function locked(work) {
45
- await mkdir(dirname(path), { recursive: true });
68
+ await mkdir(dirname(path), { recursive: true }).catch((error) => {
69
+ throw new Error(stateDirMessage(dirname(path), error));
70
+ });
46
71
  const deadline = Date.now() + LOCK_WAIT_MS;
47
72
  for (;;) {
48
73
  try {
@@ -51,7 +76,7 @@ export function dailyLedger(path, limit, now = Date.now) {
51
76
  }
52
77
  catch (error) {
53
78
  if (error.code !== "EEXIST")
54
- throw error;
79
+ throw new Error(stateDirMessage(dirname(path), error));
55
80
  }
56
81
  if ((await ageOf(lockPath)) > LOCK_STALE_MS && (await clearStale(lockPath)))
57
82
  continue;
package/dist/index.js CHANGED
@@ -3,18 +3,21 @@ import { createRequire } from "node:module";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { createWalletClient, http, maxUint256 } from "viem";
5
5
  import { DECIMALS, formatUnits, resolveConfig } from "./config.js";
6
- import { approvalFailure, chainClients, createServer, erc20, payerAccount, PERMIT2 } from "./server.js";
6
+ import { stateDirProblem } from "./daily.js";
7
+ import { approvalFailure, approveCommand, chainClients, createServer, erc20, payerAccount, PERMIT2, walletFunds, walletReport } from "./server.js";
7
8
  const { version } = createRequire(import.meta.url)("../package.json");
8
9
  const USAGE = `brainchain-mcp ${version}
9
10
 
10
- 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.
11
13
 
12
14
  Usage:
13
15
  brainchain-mcp Start the server on stdio (what MCP clients run).
14
16
  brainchain-mcp approve [USDG] Let Permit2 spend the paying wallet's USDG.
15
17
  Default 100 USDG; pass a number, "max", or 0 to revoke.
16
18
  Not needed when paying in USDC on Base.
17
- brainchain-mcp wallet Check the key and settings, and print the wallet that pays.
19
+ brainchain-mcp wallet Check the key and settings, and print the wallet that pays, what it
20
+ holds and, for USDG, what Permit2 may spend.
18
21
 
19
22
  Environment:
20
23
  BRAINCHAIN_PAYER_KEY Private key of the paying wallet, 64 hex characters with or without 0x.
@@ -26,6 +29,8 @@ Environment:
26
29
  BRAINCHAIN_MAX_PRICE_UNITS Maximum paid per task, in base units (default 1000000 = 1 USDG or USDC).
27
30
  BRAINCHAIN_BUDGET_UNITS Maximum paid in total while the server runs (default 10000000 = 10 USDG or USDC).
28
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.
29
34
  BRAINCHAIN_STATE_DIR Where the daily limit's record is kept (default ~/.brainchain). Absolute or starting with ~/.
30
35
  BRAINCHAIN_DESK_URL, BRAINCHAIN_PAY_TO, BRAINCHAIN_PAYMENT_NETWORK, BRAINCHAIN_PAYMENT_ASSET, BRAINCHAIN_PAYMENT_RPC
31
36
  Advanced overrides of the network preset.
@@ -46,23 +51,34 @@ if (command === "--version" || command === "-v" || command === "version") {
46
51
  }
47
52
  if (command !== undefined && command !== "approve" && command !== "wallet")
48
53
  fail(`Unknown command ${command}.\n\n${USAGE}`);
54
+ const extra = process.argv[command === "approve" ? 4 : 3];
55
+ if (command !== undefined && extra !== undefined)
56
+ fail(`Unexpected argument ${extra}: ${command === "approve" ? "approve takes one amount" : "wallet takes none"}.\n\n${USAGE}`);
49
57
  if (command === undefined) {
50
58
  await createServer(process.env, version).connect(new StdioServerTransport());
51
59
  }
52
60
  else if (command === "wallet") {
53
61
  const { config, account } = settings();
54
- console.log(`${account.address} pays for tasks in ${config.token} on ${config.chain}.`);
62
+ const funds = await walletFunds(config, account.address).catch((error) => (error instanceof Error ? error : new Error(String(error))));
63
+ console.log(walletReport(config, account.address, funds, approveCommand(config, version)));
55
64
  }
56
65
  else {
57
66
  await approve(process.argv[3] ?? "100");
58
67
  }
59
68
  function settings() {
69
+ let config;
70
+ let account;
60
71
  try {
61
- return { config: resolveConfig(process.env), account: payerAccount(process.env) };
72
+ config = resolveConfig(process.env);
73
+ account = payerAccount(process.env);
62
74
  }
63
75
  catch (error) {
64
76
  fail(error instanceof Error ? error.message : String(error));
65
77
  }
78
+ const problem = config.daily === undefined ? undefined : stateDirProblem(config.stateDir);
79
+ if (problem)
80
+ fail(problem);
81
+ return { config, account };
66
82
  }
67
83
  async function approve(arg) {
68
84
  const { config, account } = settings();
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")
@@ -26,6 +34,8 @@ export function orderIdOf(paymentPayload) {
26
34
  }
27
35
  const sameAddress = (a, b) => a.toLowerCase() === b.toLowerCase();
28
36
  const firstLine = (error) => (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
37
+ /** Text from the desk or a library, ended as a sentence so what this server adds after it reads apart. */
38
+ const sentence = (text) => (/[.!?]$/.test(text.trim()) ? text.trim() : `${text.trim()}.`);
29
39
  /** Statuses that say "not now" rather than "no", alongside every 5xx. */
30
40
  const RETRY_LATER = new Set([408, 425, 429]);
31
41
  /** Settles as `work` does, or rejects once `signal` aborts. */
@@ -47,14 +57,8 @@ async function jsonObject(response) {
47
57
  export function createDesk(options) {
48
58
  const { config, signer, approveCommand } = options;
49
59
  const fetchImpl = options.fetch ?? fetch;
50
- let spent = 0n;
51
60
  const account = () => (typeof signer === "function" ? signer() : signer);
52
- let ledger;
53
- // Kept per network and wallet, so a testnet wallet never spends from the mainnet limit, and one
54
- // limit holds whichever chain the wallet pays on: USDG and USDC count in the same units.
55
- const daily = () => config.daily === undefined
56
- ? undefined
57
- : (ledger ??= dailyLedger(join(config.stateDir, `spend-${config.name}-${account().address.toLowerCase()}.json`), config.daily));
61
+ const limits = options.limits ?? createLimits(config, account);
58
62
  const endpoint = `${config.desk}/x402/tasks`;
59
63
  const recent = new Map();
60
64
  /** When this process paid, or may have paid, for each order; the desk can take minutes to know one. */
@@ -82,14 +86,10 @@ export function createDesk(options) {
82
86
  /** Turns an x402 client error, raised before anything was signed, into a step a person or model can act on. */
83
87
  async function explainUnsigned(error) {
84
88
  const message = firstLine(error);
85
- if (message.includes(`Payment creation aborted: ${BUDGET_REASON}`)) {
86
- 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.`;
87
- }
88
- if (message.includes(`Payment creation aborted: ${DAILY_REASON}`)) {
89
- const today = await daily().spentToday().catch(() => undefined);
90
- // One limit covers both chains, so it is named without a token.
91
- 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.`;
92
- }
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");
93
93
  if (/rejected by spendControls/.test(message)) {
94
94
  const price = await quotedPrice();
95
95
  if (price !== undefined && price > config.maxPrice) {
@@ -112,11 +112,12 @@ export function createDesk(options) {
112
112
  if (/fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN/i.test(message)) {
113
113
  return `Could not reach the desk at ${config.desk}. Nothing was charged. Check your connection or BRAINCHAIN_DESK_URL and try again.`;
114
114
  }
115
- return `The payment could not be prepared: ${message}. Nothing was charged.`;
115
+ // The x402 client puts its own words before an error raised while it prepared the payment.
116
+ return `The payment could not be prepared: ${sentence(message.replace(/^Failed to create payment payload: /, ""))} Nothing was charged.`;
116
117
  }
117
118
  /** Explains a payment the desk refused, from the wallet's balance and Permit2 allowance where possible. */
118
119
  async function explainRefusal(reason) {
119
- const fallback = typeof reason === "string" ? `The desk refused the payment: ${reason}. Nothing was charged.` : "The desk refused the payment. Nothing was charged.";
120
+ const fallback = typeof reason === "string" && reason.trim() ? `The desk refused the payment: ${sentence(reason)} Nothing was charged.` : "The desk refused the payment. Nothing was charged.";
120
121
  if (!options.funds)
121
122
  return fallback;
122
123
  try {
@@ -171,16 +172,13 @@ export function createDesk(options) {
171
172
  }
172
173
  async function pay(task) {
173
174
  let signed;
174
- let amount = 0n;
175
- let reservation;
175
+ let hold;
176
176
  const unreserve = async () => {
177
- spent -= amount;
178
- amount = 0n;
179
- if (reservation)
180
- await daily().release(reservation);
181
- reservation = undefined;
177
+ if (hold)
178
+ await limits.give(hold);
179
+ hold = undefined;
182
180
  };
183
- 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}` });
184
182
  const client = x402Client.fromConfig({
185
183
  schemes: [{ network: config.network, client: new ExactEvmScheme(account()) }],
186
184
  spendControls: { allowedAssets: [{ network: config.network, asset: config.asset, maxAmountPerPayment: config.maxPrice.toString() }] },
@@ -195,25 +193,10 @@ export function createDesk(options) {
195
193
  // signed, so parallel calls cannot overshoot either. The budget is counted before
196
194
  // the first await, so no other call can pass the check in between.
197
195
  client.onBeforePaymentCreation(async ({ selectedRequirements }) => {
198
- const price = BigInt(selectedRequirements.amount);
199
- if (spent + price > config.budget)
200
- return { abort: true, reason: BUDGET_REASON };
201
- spent += price;
202
- amount = price;
203
- let today;
204
- try {
205
- today = daily();
206
- if (today)
207
- reservation = await today.reserve(price);
208
- }
209
- catch (error) {
210
- await unreserve();
211
- throw error;
212
- }
213
- if (today && !reservation) {
214
- await unreserve();
215
- return { abort: true, reason: DAILY_REASON };
216
- }
196
+ const taken = await limits.take(BigInt(selectedRequirements.amount));
197
+ if (typeof taken === "string")
198
+ return { abort: true, reason: taken };
199
+ hold = taken;
217
200
  });
218
201
  client.onAfterPaymentCreation(async ({ paymentPayload }) => {
219
202
  signed = paymentPayload;
@@ -222,14 +205,19 @@ export function createDesk(options) {
222
205
  if (!signed)
223
206
  await unreserve();
224
207
  });
208
+ // Short enough that a possiblyCharged reply reaches a client that gives up after 60 seconds.
209
+ // The x402 client sends clones of the request, and a clone follows the request's signal
210
+ // through weak references only, so once garbage is collected a deadline set on the request
211
+ // never fires. Each request it sends carries the deadline itself, which the timer keeps alive.
212
+ const deadline = new AbortController();
213
+ const timer = setTimeout(() => deadline.abort(new DOMException("The operation was aborted due to timeout", "TimeoutError")), options.postTimeoutMs ?? 45_000);
214
+ const timed = (input, init) => fetchImpl(input, { ...init, signal: deadline.signal });
225
215
  let response;
226
216
  try {
227
- response = await wrapFetchWithPayment(fetchImpl, client)(endpoint, {
217
+ response = await wrapFetchWithPayment(timed, client)(endpoint, {
228
218
  method: "POST",
229
219
  headers: { "content-type": "application/json" },
230
220
  body: JSON.stringify(task),
231
- // Short enough that a possiblyCharged reply reaches a client that gives up after 60 seconds.
232
- signal: AbortSignal.timeout(options.postTimeoutMs ?? 45_000),
233
221
  });
234
222
  }
235
223
  catch (error) {
@@ -237,6 +225,9 @@ export function createDesk(options) {
237
225
  return possiblyCharged(receipt(orderIdOf(signed)), `The desk did not confirm the order (${firstLine(error)}).`);
238
226
  throw new Error(await explainUnsigned(error));
239
227
  }
228
+ finally {
229
+ clearTimeout(timer);
230
+ }
240
231
  const reply = (await jsonObject(response)) ?? {};
241
232
  if (response.ok) {
242
233
  if (!signed) {
@@ -253,7 +244,7 @@ export function createDesk(options) {
253
244
  await unreserve();
254
245
  if (response.status === 402)
255
246
  throw new Error(await explainRefusal(reply.error));
256
- throw new Error(typeof reply.error === "string" ? `${reply.error} Nothing was charged.` : `The desk answered ${response.status}. Nothing was charged.`);
247
+ throw new Error(typeof reply.error === "string" && reply.error.trim() ? `${sentence(reply.error)} Nothing was charged.` : `The desk answered ${response.status}. Nothing was charged.`);
257
248
  }
258
249
  async function get(order) {
259
250
  let response;
@@ -310,7 +301,7 @@ export function createDesk(options) {
310
301
  }
311
302
  return { ...view, ...guidance(view, config) };
312
303
  }
313
- return { post, get, spent: () => spent };
304
+ return { post, get, spent: () => limits.spent() };
314
305
  }
315
306
  /** What the order means for the caller, and when to look again. Every state and status pair is in payments.test.ts. */
316
307
  export function guidance(view, config, now = Date.now()) {
package/dist/server.js CHANGED
@@ -2,12 +2,15 @@ import { readFileSync, statSync } from "node:fs";
2
2
  import { isAbsolute } from "node:path";
3
3
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
- import { createPublicClient, defineChain, http, parseAbi } from "viem";
5
+ import { createPublicClient, defineChain, formatEther, http, maxUint256, parseAbi } from "viem";
6
6
  import { privateKeyToAccount } from "viem/accounts";
7
- import { expandHome, resolveConfig } from "./config.js";
7
+ import { bondsClosed, createBonds } from "./bonds.js";
8
+ import { expandHome, formatUnits, resolveConfig } from "./config.js";
9
+ import { stateDirProblem } from "./daily.js";
10
+ import { createLimits } from "./limits.js";
8
11
  import { createDesk } from "./payments.js";
9
12
  import { PREMIUMS_NAME, PREMIUMS_TOOL } from "./premiums.js";
10
- 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";
11
14
  /** The canonical Permit2 contract; x402 payments in USDG are signed Permit2 transfers. */
12
15
  export const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
13
16
  export const erc20 = parseAbi([
@@ -18,9 +21,10 @@ export const erc20 = parseAbi([
18
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. ` +
19
22
  `If a post returns possiblyCharged, do not post the task again: read the order instead. ` +
20
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. ` +
21
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.`;
22
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. ` +
23
- `${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.`;
24
28
  const messageOf = (error) => (error instanceof Error ? error.message : String(error));
25
29
  /** Read here and never echoed. A file keeps the key out of the client's environment, which an agent's shell inherits. */
26
30
  function readKeyFile(given) {
@@ -80,6 +84,38 @@ export function chainClients(config) {
80
84
  });
81
85
  return { chain, reader: createPublicClient({ chain, transport: http(config.rpc, { retryCount: 2, timeout: 15_000 }) }) };
82
86
  }
87
+ /** Reads what `wallet` reports, on the payment chain only. */
88
+ export async function walletFunds(config, address) {
89
+ const { reader } = chainClients(config);
90
+ const [balance, allowance, eth] = await Promise.all([
91
+ reader.readContract({ address: config.asset, abi: erc20, functionName: "balanceOf", args: [address] }),
92
+ config.permit2 ? reader.readContract({ address: config.asset, abi: erc20, functionName: "allowance", args: [address, PERMIT2] }) : undefined,
93
+ reader.getBalance({ address }),
94
+ ]);
95
+ return { balance, ...(allowance === undefined ? {} : { allowance }), eth };
96
+ }
97
+ /** What `wallet` says: the wallet that pays, what it holds, and what it still needs before a post can be paid. */
98
+ export function walletReport(config, address, funds, approve) {
99
+ const lines = [`${address} pays for tasks in ${config.token} on ${config.chain}.`];
100
+ if (funds instanceof Error) {
101
+ lines.push(`Its balance could not be read from ${config.chain} (${messageOf(funds).split("\n")[0]}). The key and settings are fine.`);
102
+ return lines.join("\n");
103
+ }
104
+ const { balance, allowance, eth } = funds;
105
+ const short = balance < config.maxPrice ? ` A task costs ${config.terms.cost}, so send at least ${formatUnits(config.maxPrice - balance)} ${config.token} to it on ${config.chain} before posting.` : "";
106
+ lines.push(`It holds ${formatUnits(balance)} ${config.token}.${short}`);
107
+ if (allowance !== undefined) {
108
+ if (allowance >= maxUint256 / 2n)
109
+ lines.push(`Permit2 may spend any amount of its ${config.token}.`);
110
+ else if (allowance >= config.maxPrice)
111
+ lines.push(`Permit2 may spend up to ${formatUnits(allowance)} of its ${config.token}.`);
112
+ else {
113
+ const only = allowance > 0n ? ` (only ${formatUnits(allowance)})` : "";
114
+ lines.push(`Permit2 is not approved to spend enough of its ${config.token} for a task${only}. Run \`${approve}\` once; it needs a little ETH on ${config.chain}, and the wallet holds ${formatEther(eth)} ETH there.`);
115
+ }
116
+ }
117
+ return lines.join("\n");
118
+ }
83
119
  export function approveCommand(config, version) {
84
120
  return `${config.name === "testnet" ? "BRAINCHAIN_NETWORK=testnet " : ""}npx -y brainchain-mcp@${version} approve`;
85
121
  }
@@ -94,7 +130,7 @@ function deskFor(env, version, fetchImpl) {
94
130
  const refuse = async () => {
95
131
  throw new Error(problem);
96
132
  };
97
- 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}`) };
98
134
  }
99
135
  let account;
100
136
  let keyProblem;
@@ -106,6 +142,8 @@ function deskFor(env, version, fetchImpl) {
106
142
  if (env.BRAINCHAIN_PAYER_KEY?.trim() || env.BRAINCHAIN_PAYER_KEY_FILE?.trim())
107
143
  keyProblem = messageOf(error);
108
144
  }
145
+ // Said at start, not at the first post: with a daily limit set, no payment can be signed without its record.
146
+ const problem = [keyProblem, config.daily === undefined ? undefined : stateDirProblem(config.stateDir)].filter(Boolean).join(" ");
109
147
  const payer = () => {
110
148
  try {
111
149
  return (account ??= payerAccount(env));
@@ -115,11 +153,13 @@ function deskFor(env, version, fetchImpl) {
115
153
  }
116
154
  };
117
155
  const { reader } = chainClients(config);
156
+ const limits = createLimits(config, payer);
118
157
  const desk = createDesk({
119
158
  config,
120
159
  signer: payer,
121
160
  approveCommand: approveCommand(config, version),
122
161
  fetch: fetchImpl,
162
+ limits,
123
163
  funds: async () => {
124
164
  const { address } = payer();
125
165
  const [balance, allowance] = await Promise.all([
@@ -129,16 +169,22 @@ function deskFor(env, version, fetchImpl) {
129
169
  return { balance, allowance };
130
170
  },
131
171
  });
132
- return { desk, terms: { ...config.terms, ...(account ? { wallet: account.address } : {}), ...(keyProblem ? { keyProblem } : {}) } };
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
+ };
133
179
  }
134
180
  export function createServer(env, version, options = {}) {
135
- const { desk, terms } = deskFor(env, version, options.fetch);
136
- const problem = typeof terms === "string" ? terms : terms.keyProblem;
181
+ const { desk, terms, bonds, bondTools: bondToolList } = deskFor(env, version, options.fetch);
182
+ const problem = typeof terms === "string" ? terms : terms.problem;
137
183
  if (problem)
138
184
  (options.log ?? console.error)(`brainchain-mcp has an invalid setting: ${problem}`);
139
- const tools = [postTaskTool(terms), GET_TASK_TOOL, PREMIUMS_TOOL];
185
+ const tools = [postTaskTool(terms), GET_TASK_TOOL, PREMIUMS_TOOL, ...bondToolList];
140
186
  const server = new Server({ name: "brainchain", title: "BrainChain", version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
141
187
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools }));
142
- 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));
143
189
  return server;
144
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,7 +1,48 @@
1
- import { REPOST_AFTER_MINUTES } from "./config.js";
1
+ import { formatUnits, REPOST_AFTER_MINUTES } from "./config.js";
2
+ import { canonicalStringify } from "./payments.js";
2
3
  import { PREMIUMS_NAME, readPremiums } from "./premiums.js";
4
+ import { MIN_BOND } from "./shipped.js";
3
5
  export const POST_TASK_NAME = "brainchain_post_task";
6
+ /** The desk's cap on a task as it goes on chain: title, brief, levels and pass level as JSON. */
7
+ export const MAX_TASK_BYTES = 64_000;
4
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
+ }
5
46
  const POST_TASK_INPUT = {
6
47
  type: "object",
7
48
  properties: {
@@ -10,14 +51,16 @@ const POST_TASK_INPUT = {
10
51
  type: "string",
11
52
  minLength: 1,
12
53
  maxLength: 32000,
13
- description: "What to produce, in what format, and anything the model must not do. Models cannot browse, so include every fact they need.",
54
+ description: "What to produce, in what format, and anything the model must not do. Models cannot browse, so include every fact they need. " +
55
+ "Up to 32,000 characters, an emoji counting as two, and the whole task as JSON at most 64,000 bytes: about 21,000 characters of Chinese, Japanese or Korean.",
14
56
  },
15
57
  levels: {
16
58
  type: "array",
17
59
  items: { type: "string", minLength: 1, maxLength: 200 },
18
60
  minItems: 2,
19
61
  maxItems: 3,
20
- description: "Two or three quality levels, best first, each a short description a reader could check. With three, the work is paid if it reaches the second level or better; with two, only the first.",
62
+ uniqueItems: true,
63
+ description: "Two or three different quality levels, best first, each a short description a reader could check. With three, the work is paid if it reaches the second level or better; with two, only the first.",
21
64
  },
22
65
  },
23
66
  required: ["title", "brief", "levels"],
@@ -26,13 +69,14 @@ const POST_TASK_INPUT = {
26
69
  /** Given a configuration error instead of terms, the description leads with it. */
27
70
  export function postTaskTool(terms) {
28
71
  const about = `Pay for a task on BrainChain. Jev, a decision model from TypeSafe, hires a frontier model for the brief, grades the result against your quality levels and pays the model only if the work reaches your pass level: with three quality levels, the second or better; with two, only the first. ` +
29
- `Briefs and results are public, so never include credentials or code that cannot be published. `;
72
+ `Briefs and results are public, so never include credentials or code that cannot be published. ` +
73
+ `A brief can be up to 32,000 characters, and the whole task at most 64,000 bytes as JSON. `;
30
74
  return {
31
75
  name: POST_TASK_NAME,
32
76
  title: "Post a paid task to BrainChain",
33
77
  description: typeof terms === "string"
34
78
  ? `Invalid setting: ${terms} Until then this tool only returns that error. ${about}`
35
- : (terms.keyProblem ? `Invalid setting: ${terms.keyProblem} Until the operator fixes it, this tool only returns that error. ` : "") +
79
+ : (terms.problem ? `Invalid setting: ${terms.problem} Until the operator fixes it, this tool only returns that error. ` : "") +
36
80
  about +
37
81
  `Each task costs ${terms.cost}, paid from this server's wallet${terms.wallet ? ` ${terms.wallet}` : ""} on ${terms.chain}. The result is delivered only if Jev accepts the work. If Jev rejects the work, the full amount is refunded to the same wallet on ${terms.chain}, ${terms.finalAfter} after grading, unless the model disputes the grade and a ruling pays it. If no model delivers within 24 hours, the full amount is refunded. ` +
38
82
  `Returns an order id for ${GET_TASK_NAME}; results usually arrive within minutes. If the reply says possiblyCharged, do not post the task again: read the order with ${GET_TASK_NAME}. ` +
@@ -56,14 +100,20 @@ export const GET_TASK_TOOL = {
56
100
  };
57
101
  const typeName = (value) => (value === null ? "null" : Array.isArray(value) ? "an array" : typeof value === "object" ? "an object" : `a ${typeof value}`);
58
102
  const count = (n) => n.toLocaleString("en-US");
59
- /** A string field as it is sent, trimmed; throws what is wrong with it. */
103
+ /**
104
+ * Text that shows nothing: whitespace, control and format characters, line and
105
+ * paragraph separators, and the default-ignorable and blank letters that have
106
+ * no Unicode property of their own.
107
+ */
108
+ const BLANK = /^[\s\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u034f\u115f\u1160\u17b4\u17b5\u180b-\u180f\u2800\u3164\ufe00-\ufe0f\uffa0\u{e0000}-\u{e0fff}]*$/u;
109
+ /** A string field as it is sent, trimmed; throws what is wrong with it. Lengths count UTF-16 units, as the desk does. */
60
110
  function text(name, value, max = Infinity) {
61
111
  if (value === undefined)
62
112
  throw new Error(`${name} is required.`);
63
113
  if (typeof value !== "string")
64
114
  throw new Error(`${name} must be a string, not ${typeName(value)}.`);
65
115
  const sent = value.trim();
66
- if (sent.length === 0)
116
+ if (BLANK.test(sent))
67
117
  throw new Error(`${name} is empty.`);
68
118
  if (sent.length > max)
69
119
  throw new Error(`${name} is ${count(sent.length)} characters; the limit is ${count(max)}.`);
@@ -75,9 +125,15 @@ function refuseUnknownFields(input, schema) {
75
125
  if (unknown.length > 0)
76
126
  throw new Error(`Unknown field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}. This tool takes only ${known.join(", ")}.`);
77
127
  }
78
- /** The desk's own limits, applied to what is sent, before anything is signed. Returns the trimmed brief; throws what is wrong with it. */
128
+ /**
129
+ * The desk's own limits, applied to what is sent, before anything is signed,
130
+ * plus what this tool's description promises: a title of one line and levels
131
+ * that differ. Returns the trimmed brief; throws what is wrong with it.
132
+ */
79
133
  export function checkBrief(input) {
80
134
  const title = text("title", input.title, 120);
135
+ if (/[\n\r\u2028\u2029]/.test(title))
136
+ throw new Error("title must be one line.");
81
137
  const brief = text("brief", input.brief, 32_000);
82
138
  const { levels } = input;
83
139
  if (levels === undefined)
@@ -86,7 +142,18 @@ export function checkBrief(input) {
86
142
  throw new Error(`levels must be an array of strings, not ${typeName(levels)}.`);
87
143
  if (levels.length < 2 || levels.length > 3)
88
144
  throw new Error(`levels must list 2 or 3 quality levels, best first; this lists ${levels.length}.`);
89
- return { title, brief, levels: levels.map((level, i) => text(`levels[${i}]`, level, 200)) };
145
+ const sent = levels.map((level, i) => text(`levels[${i}]`, level, 200));
146
+ sent.forEach((level, i) => {
147
+ const first = sent.indexOf(level);
148
+ if (first < i)
149
+ throw new Error(`levels[${i}] repeats levels[${first}]; each level must describe a different quality.`);
150
+ });
151
+ // Measured as the desk measures it: the trimmed task with its pass level, as canonical JSON.
152
+ const bytes = Buffer.byteLength(canonicalStringify({ title, brief, levels: sent, passIndex: Math.min(1, sent.length - 2) }));
153
+ if (bytes > MAX_TASK_BYTES) {
154
+ throw new Error(`The task is ${count(bytes)} bytes as JSON; the limit is ${count(MAX_TASK_BYTES)}. Shorten the brief.`);
155
+ }
156
+ return { title, brief, levels: sent };
90
157
  }
91
158
  function reply(value, isError = false) {
92
159
  return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], isError };
@@ -99,9 +166,24 @@ function orderReply(view) {
99
166
  result.content.push({ type: "text", text: resultText });
100
167
  return result;
101
168
  }
102
- export async function callTool(desk, name, args, premiums = readPremiums) {
169
+ export async function callTool(desk, name, args, premiums = readPremiums, bonds) {
103
170
  const input = (args ?? {});
104
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
+ }
105
187
  if (name === POST_TASK_NAME) {
106
188
  refuseUnknownFields(input, POST_TASK_INPUT);
107
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.0",
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",