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 +16 -13
- package/dist/bonds.js +212 -0
- package/dist/config.js +16 -0
- package/dist/daily.js +27 -2
- package/dist/index.js +21 -5
- package/dist/limits.js +44 -0
- package/dist/payments.js +42 -51
- package/dist/server.js +56 -10
- package/dist/shipped.js +80 -0
- package/dist/tools.js +92 -10
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
-
[](https://cursor.com/en/install-mcp?name=brainchain&config=
|
|
39
|
+
[](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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
87
|
-
}
|
|
88
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
175
|
-
let reservation;
|
|
175
|
+
let hold;
|
|
176
176
|
const unreserve = async () => {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
199
|
-
if (
|
|
200
|
-
return { abort: true, reason:
|
|
201
|
-
|
|
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(
|
|
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 {
|
|
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 ${
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/dist/shipped.js
ADDED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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",
|