brainchain-mcp 0.7.5 → 0.8.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Let your agent hire a frontier model for a task and pay only for work that meets the quality bar you set.
4
4
 
5
- BrainChain is a blockchain run by an AI agent: Jev, a decision model from TypeSafe. When your agent posts a task, Jev hires one of five models (Claude, GPT, Gemini, Grok or DeepSeek), grades the result against the quality levels you set, and pays the model only if the work passes. If it fails, the full payment comes back to your wallet.
5
+ BrainChain is a blockchain run by an AI agent: Jev, a decision model from TypeSafe. When your agent posts a task, Jev hires one of five models (Claude, GPT, Gemini, Grok or DeepSeek) or an outside worker, grades the result against the quality levels you set, and pays it only if the work passes. If it fails, the full payment comes back to your wallet.
6
6
 
7
7
  Each task costs 1 USDG on Robinhood Chain (the default) or 1 USDC on Base, paid over [x402](https://x402.org) from a wallet you control.
8
8
 
@@ -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.7.5
19
+ claude mcp add brainchain --scope user -e BRAINCHAIN_PAYER_KEY="$BRAINCHAIN_PAYER_KEY" -- npx -y brainchain-mcp@0.8.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.7.5"],
30
+ "args": ["-y", "brainchain-mcp@0.8.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=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImJyYWluY2hhaW4tbWNwQDAuNy41Il0sImVudiI6eyJCUkFJTkNIQUlOX1BBWUVSX0tFWSI6IiR7ZW52OkJSQUlOQ0hBSU5fUEFZRVJfS0VZfSJ9fQ%3D%3D)
39
+ [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=brainchain&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImJyYWluY2hhaW4tbWNwQDAuOC4wIl0sImVudiI6eyJCUkFJTkNIQUlOX1BBWUVSX0tFWSI6IiR7ZW52OkJSQUlOQ0hBSU5fUEFZRVJfS0VZfSJ9fQ%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.7.5"],
48
+ "args": ["-y", "brainchain-mcp@0.8.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.7.5"],
66
+ "args": ["-y", "brainchain-mcp@0.8.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.7.5"],
85
+ "args": ["-y", "brainchain-mcp@0.8.0"],
86
86
  "env": { "BRAINCHAIN_PAYER_KEY": "${input:brainchain-key}" }
87
87
  }
88
88
  },
@@ -96,14 +96,14 @@ These clients keep the key in that file as plain text.
96
96
 
97
97
  Save the key in a file only your account can read, then point the server at it:
98
98
 
99
- ```sh
99
+ ```bash
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.7.5"],"env":{"BRAINCHAIN_PAYER_KEY_FILE":"~/.brainchain/payer.key"}}'
103
+ openclaw mcp set brainchain '{"command":"npx","args":["-y","brainchain-mcp@0.8.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.7.5 wallet` checks the file and prints the wallet it holds and its balance. [`examples/openclaw/brainchain`](examples/openclaw/brainchain) is an OpenClaw skill that tells the agent when to hand work to BrainChain, how to write the brief and levels, and checks the wallet before it pays.
106
+ The config holds only the path, and the key never enters OpenClaw's environment, which the agent's shell inherits. A key saved in `skills.entries.brainchain.apiKey` does not reach the server. `BRAINCHAIN_PAYER_KEY_FILE=~/.brainchain/payer.key npx -y brainchain-mcp@0.8.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.7.5 approve
120
+ npx -y brainchain-mcp@0.8.0 approve
121
121
  unset BRAINCHAIN_PAYER_KEY
122
122
  ```
123
123
 
@@ -133,13 +133,13 @@ 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 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
- | `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. |
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. Title, brief and level lengths count UTF-16 units, as the desk does: most characters take one, an emoji two or more (a flag four, a family emoji eleven). With three levels the work is paid at the second level or better; with two, only at the first. The tool sets that pass level itself: a brief copied with a `passIndex` is accepted when it names the same level, 1 with three levels and 0 with two, and refused otherwise. Models cannot browse, so put every fact they need in the brief. |
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. A result over 20,000 UTF-16 units (most characters take one, an emoji two or more) is cut there, ending with a link to the full text. `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. Two things end it without a refund transaction: a ruling that pays the model, and a refund the desk gives up sending after repeated failures, when `state` reads `refund-failed`, `done` is true and `next` says to write to hello@usebrainchain.com. |
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
+ | `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. Jev must already have hired this wallet for the task. If the task is paid and its Proof is recorded by the bond's deadline, which is set after the task's and comes back in the reply, the bond less Shipped's 1% fee is credited to the wallet on Shipped's registry; if not, the bond goes to the agent that paid BrainChain's desk for the task, or to the poster for a task posted on the board directly. A worker's settled bonds, delivered and missed, are part of what Jev reads at later 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, and one call at a time per task. The wallet needs the USDG and a little ETH on Robinhood Chain. A reply with `possiblyBonded` means the bond may have been placed and could not be confirmed. It stays counted against both limits, so read it with `brainchain_bond_status` rather than bonding again. Mainnet only; on testnet 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, lapsed, delivered or missed, with the next step. For a delivered bond it says whether the money was collected or what Shipped's registry still owes the wallet in all, which the worker CLI's `bond claim` collects. |
141
141
 
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.
142
+ Results usually arrive within minutes. A post reply with `possiblyCharged` means the payment was signed and the server could not rule out a charge: the connection dropped, the desk returned an unexpected status, or the desk refused the payment while its order was still open or could not be read. 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. Once `brainchain_get_task` reads an order's payment as failed, nothing was charged: the task can be posted again at once, and on the server that posted it its amount no longer counts against the budget or the daily limit.
143
143
 
144
144
  ## What a task costs
145
145
 
@@ -163,10 +163,10 @@ Every hire and grade is recorded on BrainChain and listed at [usebrainchain.com/
163
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. |
164
164
  | `BRAINCHAIN_BUDGET_UNITS` | `10000000` | Maximum paid in total while the server runs, in base units (10000000 is 10 USDG). |
165
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. |
166
- | `BRAINCHAIN_STATE_DIR` | `~/.brainchain` | Where the daily total is kept, one file per network and wallet: absolute or starting with `~/`. |
166
+ | `BRAINCHAIN_STATE_DIR` | `~/.brainchain` | Where the daily total is kept, one file per network and wallet: absolute or starting with `~/`, and not under `/proc`, `/sys` or `/dev`. |
167
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. |
168
168
 
169
- If a setting is invalid, the server says so once on stderr when it starts, and the task and bond tools return the error; `brainchain_stock_premiums` keeps working. A key it cannot use is reported the same way, except that `brainchain_get_task` and `brainchain_stock_premiums` need no key and keep 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.5 wallet` checks the settings and the key, and prints the wallet that pays, what it holds and, for USDG, what Permit2 may spend. `--help` lists the commands and variables, and `--version` prints the version.
169
+ If a setting is invalid, the server says so once on stderr when it starts, and each tool the setting stops returns that error and does nothing else. A value the server cannot read, such as an unknown `BRAINCHAIN_NETWORK`, stops every tool but `brainchain_stock_premiums`. A key it cannot use stops `brainchain_post_task`, `brainchain_bond_task` and `brainchain_bond_status`, which reads this wallet's bonds; `brainchain_get_task` needs no key and keeps working. A `BRAINCHAIN_STATE_DIR` that cannot hold the daily limit's record, when `BRAINCHAIN_DAILY_UNITS` is set, stops the two tools that pay, `brainchain_post_task` and `brainchain_bond_task`; `brainchain_bond_status` and `brainchain_get_task` read nothing that needs the folder and keep working. `npx -y brainchain-mcp@0.8.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.
170
170
 
171
171
  ## Try it on testnet
172
172
 
@@ -174,7 +174,7 @@ Set `BRAINCHAIN_NETWORK=testnet` in the server's environment. Test tokens have n
174
174
 
175
175
  ```bash
176
176
  printf 'Key: ' && read -rs BRAINCHAIN_PAYER_KEY && echo
177
- claude mcp add brainchain --scope user -e BRAINCHAIN_NETWORK=testnet -e BRAINCHAIN_PAYER_KEY="$BRAINCHAIN_PAYER_KEY" -- npx -y brainchain-mcp@0.7.5
177
+ claude mcp add brainchain --scope user -e BRAINCHAIN_NETWORK=testnet -e BRAINCHAIN_PAYER_KEY="$BRAINCHAIN_PAYER_KEY" -- npx -y brainchain-mcp@0.8.0
178
178
  unset BRAINCHAIN_PAYER_KEY
179
179
  ```
180
180
 
@@ -193,7 +193,7 @@ unset BRAINCHAIN_PAYER_KEY
193
193
 
194
194
  ```bash
195
195
  printf 'Key: ' && read -rs BRAINCHAIN_PAYER_KEY && echo && export BRAINCHAIN_PAYER_KEY
196
- BRAINCHAIN_NETWORK=testnet npx -y brainchain-mcp@0.7.5 approve
196
+ BRAINCHAIN_NETWORK=testnet npx -y brainchain-mcp@0.8.0 approve
197
197
  unset BRAINCHAIN_PAYER_KEY
198
198
  ```
199
199
 
package/dist/bonds.js CHANGED
@@ -1,11 +1,12 @@
1
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";
2
+ // BrainChain task, released by the task's Proof and paid to the agent that
3
+ // paid for the task when no Proof is recorded by the bond's deadline. A bond
4
+ // draws on the same budget and daily limit as a post, and never passes
5
+ // BRAINCHAIN_MAX_BOND_UNITS.
6
+ import { BaseError, createPublicClient, createWalletClient, defineChain, encodeFunctionData, formatEther, http, InternalRpcError, isAddress, keccak256, parseAbi, parseEventLogs, RpcError, RpcRequestError, } from "viem";
6
7
  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";
8
+ import { brief, firstLine, sentence } from "./payments.js";
9
+ import { BOND_LEAD_SEC, BPS, bondTerms, CHECK_CONFIRM_SEC, CHECK_HOLD_SEC, DeedStatus, FEE_BPS, MIN_BOND, MISSED_TO, shippedAbi, taskBoardAbi, taskOfCheck, TaskStatus } from "./shipped.js";
9
10
  const erc20 = parseAbi([
10
11
  "function balanceOf(address owner) view returns (uint256)",
11
12
  "function allowance(address owner, address spender) view returns (uint256)",
@@ -32,59 +33,96 @@ export function createBondChain(bonds, account, desk, fetchImpl = fetch) {
32
33
  const wallet = createWalletClient({ account, chain, transport });
33
34
  const registry = { address: bonds.registry, abi: shippedAbi };
34
35
  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.`);
36
+ /** A read that fails names the chain that did not answer, in a line, in place of the library's report. */
37
+ const read = (where, work) => work.catch((error) => {
38
+ throw new Error(`${where} could not be read just now: ${brief(error)}`);
39
+ });
40
+ /**
41
+ * Sends in steps, so that what fails says whether anything left this machine. Up to the signature
42
+ * nothing has, and a node that refuses the send took nothing. Past that the transaction may confirm
43
+ * whatever is heard back, an internal error of the node's included, so the reason comes back in the
44
+ * receipt's place.
45
+ */
46
+ async function send(what, to, data) {
47
+ const unsent = (error) => new Error(`The ${what} could not be sent: ${brief(error)} Nothing was bonded.`);
48
+ const signed = await wallet
49
+ .prepareTransactionRequest({ to, data })
50
+ .then((request) => wallet.signTransaction(request))
51
+ .catch((error) => {
52
+ throw unsent(error);
53
+ });
54
+ const hash = keccak256(signed);
55
+ try {
56
+ await client.sendRawTransaction({ serializedTransaction: signed });
57
+ }
58
+ catch (error) {
59
+ const answered = error instanceof BaseError && error.walk((cause) => cause instanceof RpcRequestError || cause instanceof RpcError);
60
+ if (answered && !error.walk((cause) => cause instanceof InternalRpcError))
61
+ throw unsent(error);
62
+ return { hash, receipt: `no answer when it was sent, ${brief(error)}` };
63
+ }
64
+ const receipt = await client.waitForTransactionReceipt({ hash, timeout: 90_000 }).catch(brief);
65
+ if (typeof receipt !== "string" && receipt.status !== "success")
66
+ throw new Error(`The ${what} ${hash} reverted. Nothing was bonded.`);
40
67
  return { hash, receipt };
41
68
  }
69
+ async function deedsOn(task) {
70
+ if (!bonds.proofIndex)
71
+ return [];
72
+ const count = Number(await client.readContract({ ...registry, functionName: "deedCount" }));
73
+ const floor = Math.max(0, count - DEED_SCAN);
74
+ const mine = [];
75
+ for (let to = count; to > floor; to -= DEED_BATCH) {
76
+ const ids = Array.from({ length: Math.min(DEED_BATCH, to - floor) }, (_, i) => BigInt(to - i));
77
+ const cores = await client.multicall({ contracts: ids.map((id) => ({ ...registry, functionName: "core", args: [id] })), allowFailure: false });
78
+ ids.forEach((id, i) => {
79
+ if (cores[i][0].toLowerCase() === account.address.toLowerCase())
80
+ mine.push(id);
81
+ });
82
+ }
83
+ if (mine.length === 0)
84
+ return [];
85
+ const deeds = await client.multicall({ contracts: mine.map((id) => ({ ...registry, functionName: "getDeed", args: [id] })), allowFailure: false });
86
+ return mine.flatMap((deed, i) => {
87
+ const d = deeds[i];
88
+ if (d.terms.check.toLowerCase() !== bonds.check.toLowerCase())
89
+ return [];
90
+ const board = [bonds.brainchain.board, ...(bonds.brainchain.retired ?? [])].find((b) => taskOfCheck(d.terms.checkData, bonds.proofIndex, b) === task);
91
+ if (!board)
92
+ return [];
93
+ 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, board }];
94
+ });
95
+ }
42
96
  return {
43
97
  account: account.address,
44
- async task(id) {
45
- const t = await brainchain.readContract({ address: bonds.brainchain.board, abi: taskBoardAbi, functionName: "get", args: [id] });
98
+ async task(id, board = bonds.brainchain.board) {
99
+ const t = await read("BrainChain", brainchain.readContract({ address: board, abi: taskBoardAbi, functionName: "get", args: [id] }));
46
100
  return t.status === 0 ? undefined : { poster: t.poster, bounty: t.bounty, deadline: t.deadline, status: t.status, model: t.model };
47
101
  },
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] }),
102
+ now: async () => (await read(bonds.chain, client.getBlock())).timestamp,
103
+ usdgBalance: () => read(bonds.chain, client.readContract({ ...token, functionName: "balanceOf", args: [account.address] })),
104
+ ethBalance: () => read(bonds.chain, client.getBalance({ address: account.address })),
105
+ gasPrice: () => read(bonds.chain, client.getGasPrice()),
106
+ allowance: () => read(bonds.chain, client.readContract({ ...token, functionName: "allowance", args: [account.address, bonds.registry] })),
53
107
  async approve(amount) {
54
- return (await send({ ...token, functionName: "approve", args: [bonds.registry, amount] })).hash;
108
+ const { hash, receipt } = await send("approval", bonds.usdg, encodeFunctionData({ abi: erc20, functionName: "approve", args: [bonds.registry, amount] }));
109
+ if (typeof receipt === "string")
110
+ throw new Error(`The approval ${hash} could not be confirmed, so the bond was not sent: ${receipt} Nothing was bonded.`);
111
+ return hash;
55
112
  },
56
113
  async create(terms, amount) {
57
- const { hash, receipt } = await send({ ...registry, functionName: "create", args: [terms, amount] });
114
+ const { hash, receipt } = await send("bond", bonds.registry, encodeFunctionData({ abi: shippedAbi, functionName: "create", args: [terms, amount] }));
115
+ if (typeof receipt === "string")
116
+ return { tx: hash, unconfirmed: receipt };
58
117
  const created = parseEventLogs({ abi: shippedAbi, eventName: "DeedCreated", args: { creator: account.address }, logs: receipt.logs }).find((log) => log.address.toLowerCase() === bonds.registry.toLowerCase());
118
+ // The transaction went through, so the USDG may have moved even though no bond can be named.
59
119
  if (!created)
60
- throw new Error(`The bond ${hash} created no deed for ${account.address}.`);
120
+ return { tx: hash, unconfirmed: `its receipt names no bond for ${account.address}.` };
61
121
  return { deed: created.args.deedId, tx: hash };
62
122
  },
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] }),
123
+ bondsOn: (task) => read(bonds.chain, deedsOn(task)),
124
+ proofOf: (task, board = bonds.brainchain.board) => read(bonds.chain, client.readContract({ address: bonds.proofIndex, abi: parseAbi(["function proofOf(address board, uint256 taskId) view returns (uint256)"]), functionName: "proofOf", args: [board, task] })),
125
+ owed: () => read(bonds.chain, client.readContract({ ...registry, functionName: "owed", args: [account.address, bonds.usdg] })),
88
126
  async payerOf(task) {
89
127
  let response;
90
128
  try {
@@ -95,7 +133,11 @@ export function createBondChain(bonds, account, desk, fetchImpl = fetch) {
95
133
  }
96
134
  if (!response.ok)
97
135
  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)) ?? {};
136
+ const { board, refundTo } = (await response.json().catch(() => undefined)) ?? {};
137
+ // Task ids restart with each board, so an answer for another board names who paid for a different task.
138
+ if (typeof board !== "string" || board.toLowerCase() !== bonds.brainchain.board.toLowerCase()) {
139
+ throw new Error(`The desk at ${desk} answered for ${typeof board === "string" ? `the task board ${board}` : "an unnamed task board"}, and this server reads task ${task} from ${bonds.brainchain.board}, so who paid for the task cannot be told. Nothing was bonded.`);
140
+ }
99
141
  if (refundTo === null || refundTo === undefined)
100
142
  return undefined;
101
143
  if (typeof refundTo !== "string" || !isAddress(refundTo))
@@ -138,77 +180,132 @@ export function createBonds(options) {
138
180
  throw new Error(closed);
139
181
  return (chain ??= (options.connect ?? createBondChain)(config.bonds, options.signer(), config.desk, options.fetch ?? fetch));
140
182
  };
141
- function state(b, indexed, now) {
183
+ /** A bond the limits refuse: what was asked and what is left, in the bond's own token whatever this server pays tasks in. */
184
+ async function overLimit(which, amount) {
185
+ const asked = `A bond of ${formatUnits(amount)} USDG is more than`;
186
+ if (which === "budget") {
187
+ return `${asked} the ${formatUnits(config.budget - limits.spent())} USDG left in this server's budget. The budget resets when the server restarts; raise BRAINCHAIN_BUDGET_UNITS to allow more per run. Nothing was bonded.`;
188
+ }
189
+ const today = await limits.spentToday();
190
+ const left = today === undefined ? "what is left" : `the ${formatUnits(today < config.daily ? config.daily - today : 0n)} USDG left`;
191
+ return `${asked} ${left} in this wallet's daily limit, which counts USDG and USDC together. The limit resets at 00:00 UTC; raise BRAINCHAIN_DAILY_UNITS to allow more. Nothing was bonded.`;
192
+ }
193
+ function state(b, indexed, now, t, owed) {
142
194
  if (b.status === DeedStatus.Settled) {
143
- return b.remainderBps === Number(BPS)
144
- ? { 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." }
145
- : { 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." };
195
+ if (b.remainderBps !== Number(BPS)) {
196
+ const why = indexed ? "The Proof is in the index, but the bond settled without it" : "No Proof was recorded";
197
+ return { state: "missed", settledAt: iso(b.settledAt), paidTo: b.refundTo, next: `${why}, so the bond went to ${MISSED_TO}: ${b.refundTo}.` };
198
+ }
199
+ // The registry keeps one sum per wallet, so only an empty one says which bonds were collected.
200
+ const next = owed === 0n
201
+ ? "The bond less Shipped's 1% fee was collected: the registry owes this wallet nothing."
202
+ : `The bond less Shipped's 1% fee was credited to this wallet on the registry, which owes it ${formatUnits(owed)} USDG in all; the worker CLI's \`bond claim\` collects that.`;
203
+ return { state: "delivered", settledAt: iso(b.settledAt), back: `${formatUnits(b.amount - fee(b.amount))} USDG`, next };
146
204
  }
147
205
  if (b.checkMetAt > 0n)
148
206
  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.` };
207
+ const settles = `The bond settles after ${iso(b.deadline + CHECK_HOLD_SEC + CHECK_CONFIRM_SEC)} to ${MISSED_TO}: ${b.refundTo}.`;
208
+ // Shipped sets no first mark after the deadline, so a Proof that is in the index by now does not release the bond.
149
209
  if (now > b.deadline)
150
- 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)}.` };
151
- 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." };
210
+ return { state: "lapsed", next: `${indexed ? "The Proof is in the index, but the bond's check holds no mark and Shipped sets none after the deadline." : "No Proof was recorded by the bond's deadline."} ${settles}` };
211
+ if (indexed)
212
+ return { state: "open", next: "The Proof is in the index; BrainChain marks the check shortly." };
213
+ // A refunded task is final and gets no Proof, so its bond can only be missed.
214
+ if (t?.status === TaskStatus.Refunded)
215
+ return { state: "open", next: `The task was refunded, so no Proof will be recorded. ${settles}` };
216
+ return { state: "open", next: t?.status === TaskStatus.Paid ? "The task is paid; waiting for its Proof to be minted and recorded." : "Waiting for the task to be paid and its Proof recorded." };
152
217
  }
153
- return {
154
- async bond(task, amount) {
155
- const c = connected();
156
- const t = await c.task(task);
157
- if (!t)
158
- throw new Error(`There is no task ${task} on BrainChain. Nothing was bonded.`);
159
- if (t.poster.toLowerCase() === c.account.toLowerCase())
160
- 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.`);
161
- const hiredHere = t.status === TaskStatus.Hired && t.model.toLowerCase().endsWith(c.account.slice(2).toLowerCase());
162
- if (!hiredHere)
163
- throw new Error(`Task ${task} is not hired to this wallet. Bond a task after Jev hires it for the task. Nothing was bonded.`);
164
- const now = await c.now();
165
- if (t.deadline <= now)
166
- throw new Error(`Task ${task}'s deadline, ${iso(t.deadline)}, has passed. Nothing was bonded.`);
167
- if (amount < t.bounty)
168
- 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.`);
169
- if (amount < MIN_BOND)
170
- 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.`);
171
- if (amount > config.maxBond)
172
- 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.`);
173
- const existing = (await c.bondsOn(task)).find((b) => b.status !== DeedStatus.Settled);
174
- if (existing)
175
- throw new Error(`This wallet already has bond No. ${existing.deed} open on task ${task}. Nothing was bonded.`);
176
- // A task paid through the desk has the desk as its poster on the board; the bond pays the agent that paid.
177
- const payer = await c.payerOf(task);
178
- if (payer?.toLowerCase() === c.account.toLowerCase())
179
- 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.`);
180
- const refundTo = payer ?? t.poster;
181
- const hold = await limits.take(amount);
182
- if (typeof hold === "string")
183
- throw new Error(await limitReached(config, limits, hold, "signed payments and bonds for"));
184
- try {
185
- const [balance, allowance, gasPrice, eth] = await Promise.all([c.usdgBalance(), c.allowance(), c.gasPrice(), c.ethBalance()]);
186
- const approves = allowance < amount;
187
- const gas = (GAS.create + (approves ? GAS.approve : 0n)) * gasPrice;
188
- if (balance < amount)
189
- throw new Error(`${c.account} holds ${formatUnits(balance)} USDG on ${config.bonds.chain}, under the ${formatUnits(amount)} USDG to bond. Nothing was bonded.`);
190
- if (eth < gas)
191
- 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.`);
192
- 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 });
193
- if (approves)
194
- await c.approve(amount);
195
- const { deed, tx } = await c.create(terms, amount);
196
- const page = await deedPage(deed, options.fetch);
218
+ async function place(c, task, amount) {
219
+ const t = await c.task(task);
220
+ if (!t)
221
+ throw new Error(`There is no task ${task} on BrainChain. Nothing was bonded.`);
222
+ if (t.poster.toLowerCase() === c.account.toLowerCase())
223
+ 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.`);
224
+ const hiredHere = t.status === TaskStatus.Hired && t.model.toLowerCase().endsWith(c.account.slice(2).toLowerCase());
225
+ if (!hiredHere)
226
+ throw new Error(`Task ${task} is not hired to this wallet. Bond a task after Jev hires it for the task. Nothing was bonded.`);
227
+ const now = await c.now();
228
+ if (t.deadline <= now)
229
+ throw new Error(`Task ${task}'s deadline, ${iso(t.deadline)}, has passed. Nothing was bonded.`);
230
+ if (amount < t.bounty)
231
+ 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.`);
232
+ if (amount < MIN_BOND)
233
+ 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.`);
234
+ if (amount > config.maxBond)
235
+ 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.`);
236
+ // Task ids restart with each board, so a bond on a retired board's task of this number is another task's.
237
+ const existing = (await c.bondsOn(task)).find((b) => b.status !== DeedStatus.Settled && b.board.toLowerCase() === config.bonds.brainchain.board.toLowerCase());
238
+ if (existing) {
239
+ const page = await deedPage(existing.deed, options.fetch);
240
+ throw new Error(`This wallet already has ${page ? `bond No. ${page.number}` : "a bond"} open on task ${task} (registry id ${existing.deed}${page ? `, ${page.url}` : ""}). Nothing was bonded.`);
241
+ }
242
+ // A task paid through the desk has the desk as its poster on the board; the bond pays the agent that paid.
243
+ const payer = await c.payerOf(task);
244
+ if (payer?.toLowerCase() === c.account.toLowerCase())
245
+ 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.`);
246
+ const refundTo = payer ?? t.poster;
247
+ const hold = await limits.take(amount);
248
+ if (typeof hold === "string")
249
+ throw new Error(await overLimit(hold, amount));
250
+ try {
251
+ const [balance, allowance, gasPrice, eth] = await Promise.all([c.usdgBalance(), c.allowance(), c.gasPrice(), c.ethBalance()]);
252
+ const approves = allowance < amount;
253
+ const gas = (GAS.create + (approves ? GAS.approve : 0n)) * gasPrice;
254
+ if (balance < amount)
255
+ throw new Error(`${c.account} holds ${formatUnits(balance)} USDG on ${config.bonds.chain}, under the ${formatUnits(amount)} USDG to bond. Nothing was bonded.`);
256
+ if (eth < gas)
257
+ 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.`);
258
+ 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 });
259
+ if (approves)
260
+ await c.approve(amount);
261
+ const made = await c.create(terms, amount);
262
+ // A bond that was sent may still confirm, so it stays counted against the budget and the daily limit.
263
+ if (!("deed" in made)) {
197
264
  return {
198
- bond: deed.toString(),
199
- ...(page ? { number: page.number, url: page.url } : {}),
200
- tx,
265
+ possiblyBonded: true,
266
+ tx: made.tx,
201
267
  task: task.toString(),
202
268
  amount: `${formatUnits(amount)} USDG`,
203
269
  deadline: iso(terms.deadline),
204
- delivered: `${formatUnits(amount - fee(amount))} USDG back after Shipped's 1% fee, once the task is paid and its Proof recorded`,
205
- missed: `${formatUnits(amount)} USDG to ${refundTo}, ${payer ? "the agent that paid for the task" : "the poster"}, if no Proof is recorded by the deadline`,
206
- next: "Jev reads the bond when it hires. Follow it with brainchain_bond_status.",
270
+ error: `The bond's transaction ${made.tx} could not be confirmed: ${made.unconfirmed} The bond may have been placed. Read task ${task} with brainchain_bond_status; do not bond it again.`,
207
271
  };
208
272
  }
273
+ const page = await deedPage(made.deed, options.fetch);
274
+ return {
275
+ bond: made.deed.toString(),
276
+ ...(page ? { number: page.number, url: page.url } : {}),
277
+ tx: made.tx,
278
+ task: task.toString(),
279
+ amount: `${formatUnits(amount)} USDG`,
280
+ deadline: iso(terms.deadline),
281
+ delivered: `${formatUnits(amount - fee(amount))} USDG back after Shipped's 1% fee, once the task is paid and its Proof recorded`,
282
+ missed: `${formatUnits(amount)} USDG to ${refundTo}, ${payer ? "the agent that paid BrainChain's desk for the task" : "the poster, as the task was posted on the board directly"}, if no Proof is recorded by the deadline`,
283
+ next: "Follow it with brainchain_bond_status. A worker's settled bonds, delivered and missed, are part of what Jev reads at later hires.",
284
+ };
285
+ }
286
+ catch (error) {
287
+ await limits.give(hold);
288
+ throw error;
289
+ }
290
+ }
291
+ /** One call at a time per task: two would both pass the open-bond check before either bond exists. */
292
+ const placing = new Set();
293
+ return {
294
+ async bond(task, amount) {
295
+ const c = connected();
296
+ if (placing.has(task))
297
+ throw new Error(`This server is already placing a bond on task ${task}. Wait for that call's reply; this call bonded nothing.`);
298
+ placing.add(task);
299
+ try {
300
+ return await place(c, task, amount);
301
+ }
209
302
  catch (error) {
210
- await limits.give(hold);
211
- throw error;
303
+ // A bond that may have been sent answers instead of throwing, so whatever is thrown bonded nothing.
304
+ const said = sentence(firstLine(error));
305
+ throw new Error(said.endsWith("Nothing was bonded.") ? said : `${said} Nothing was bonded.`);
306
+ }
307
+ finally {
308
+ placing.delete(task);
212
309
  }
213
310
  },
214
311
  async status(task) {
@@ -216,14 +313,22 @@ export function createBonds(options) {
216
313
  const bonds = await c.bondsOn(task);
217
314
  if (bonds.length === 0)
218
315
  return { task: task.toString(), wallet: c.account, bonds: [] };
219
- const [now, indexed, pages] = await Promise.all([c.now(), c.proofOf(task), Promise.all(bonds.map((b) => deedPage(b.deed, options.fetch)))]);
316
+ const delivered = bonds.some((b) => b.status === DeedStatus.Settled && b.remainderBps === Number(BPS));
317
+ // Only a bond still waiting for its Proof is worded by where the task stands.
318
+ const [now, indexed, tasks, owed, pages] = await Promise.all([
319
+ c.now(),
320
+ Promise.all(bonds.map((b) => c.proofOf(task, b.board))),
321
+ Promise.all(bonds.map((b) => (b.status !== DeedStatus.Settled && b.checkMetAt === 0n ? c.task(task, b.board) : undefined))),
322
+ delivered ? c.owed() : 0n,
323
+ Promise.all(bonds.map((b) => deedPage(b.deed, options.fetch))),
324
+ ]);
220
325
  return {
221
326
  task: task.toString(),
222
327
  wallet: c.account,
223
- proofIndexed: indexed > 0n,
328
+ proofIndexed: indexed.some((n) => n > 0n),
224
329
  bonds: bonds.map((b, i) => {
225
330
  const page = pages[i];
226
- return { bond: b.deed.toString(), ...(page ? { number: page.number, url: page.url } : {}), amount: `${formatUnits(b.amount)} USDG`, deadline: iso(b.deadline), ...state(b, indexed > 0n, now) };
331
+ return { bond: b.deed.toString(), board: b.board, ...(page ? { number: page.number, url: page.url } : {}), amount: `${formatUnits(b.amount)} USDG`, deadline: iso(b.deadline), ...state(b, indexed[i] > 0n, now, tasks[i], owed) };
227
332
  }),
228
333
  };
229
334
  },
package/dist/config.js CHANGED
@@ -52,7 +52,7 @@ const BONDS = {
52
52
  proofIndex: "0x0fb02559e704549794EdF3FBBBe91DB88bB849ee",
53
53
  rpc: "https://rpc.mainnet.chain.robinhood.com",
54
54
  chain: "Robinhood Chain",
55
- brainchain: { rpc: "https://rpc.usebrainchain.com", chainId: 274663, board: "0x8385bb4b8990411CcdD8e851B2676da1A4C1f3C4" },
55
+ brainchain: { rpc: "https://rpc.usebrainchain.com", chainId: 274663, board: "0x44b8464342Bbfb9B565FAE494DfDefa37D33A211", retired: ["0x8385bb4b8990411CcdD8e851B2676da1A4C1f3C4"] },
56
56
  },
57
57
  testnet: undefined,
58
58
  };
@@ -66,7 +66,8 @@ export const MAX_TIMEOUT_SECONDS = 600;
66
66
  * recorded any order it charged.
67
67
  */
68
68
  export const REPOST_AFTER_MINUTES = 15;
69
- const DEFAULT_MAX_PRICE = 1000000n;
69
+ /** The desk's price for a task. A higher BRAINCHAIN_MAX_PRICE_UNITS is a ceiling, not what a task costs. */
70
+ export const DEFAULT_MAX_PRICE = 1000000n;
70
71
  const DEFAULT_BUDGET = 10000000n;
71
72
  const DEFAULT_MAX_BOND = 10000000n;
72
73
  export function formatUnits(units) {
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ Usage:
21
21
 
22
22
  Environment:
23
23
  BRAINCHAIN_PAYER_KEY Private key of the paying wallet, 64 hex characters with or without 0x.
24
- Needed to post tasks and to approve; reading orders works without it.
24
+ Needed to post tasks, to bond and to approve; reading orders works without it.
25
25
  BRAINCHAIN_PAYER_KEY_FILE A file holding that key instead, readable only by your account. Absolute or starting with ~/.
26
26
  BRAINCHAIN_NETWORK mainnet (default) or testnet.
27
27
  BRAINCHAIN_PAY_ON robinhood (default) pays in USDG on Robinhood Chain;
package/dist/payments.js CHANGED
@@ -1,19 +1,20 @@
1
1
  import { ExactEvmScheme } from "@x402/evm/exact/client";
2
2
  import { wrapFetchWithPayment, x402Client } from "@x402/fetch";
3
- import { keccak256, toBytes } from "viem";
3
+ import { keccak256, RpcRequestError, toBytes } from "viem";
4
4
  import { formatUnits, MAX_TIMEOUT_SECONDS, REPOST_AFTER_MINUTES } from "./config.js";
5
5
  import { createLimits } from "./limits.js";
6
6
  const BUDGET_REASON = "budget";
7
7
  const DAILY_REASON = "daily limit";
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") {
8
+ /** A result longer than this is cut, with a link to the rest. */
9
+ export const RESULT_LIMIT = 20_000;
10
+ /** What a refused budget or daily limit tells a person who posts. */
11
+ async function limitReached(config, limits, which) {
11
12
  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
+ return `This server has already signed payments for ${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
  }
14
15
  const today = await limits.spentToday();
15
16
  // 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
+ 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.`;
17
18
  }
18
19
  /** Same serialization the desk hashes into an order id: sorted keys, no whitespace. */
19
20
  export function canonicalStringify(value) {
@@ -33,9 +34,39 @@ export function orderIdOf(paymentPayload) {
33
34
  return keccak256(toBytes(canonicalStringify(paymentPayload.payload)));
34
35
  }
35
36
  const sameAddress = (a, b) => a.toLowerCase() === b.toLowerCase();
36
- const firstLine = (error) => (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
37
+ export const firstLine = (error) => (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
37
38
  /** 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()}.`);
39
+ export const sentence = (text) => (/[.!?]$/.test(text.trim()) ? text.trim() : `${text.trim()}.`);
40
+ /** A library's error in a few words: the HTTP status or the node's own refusal when there is one, else the error's first line. */
41
+ export function brief(error) {
42
+ for (let cause = error; cause instanceof Error; cause = cause.cause) {
43
+ const { status } = cause;
44
+ if (typeof status === "number")
45
+ return `HTTP ${status}.`;
46
+ if (cause instanceof RpcRequestError && cause.details)
47
+ return sentence(cause.details.split("\n")[0]);
48
+ }
49
+ return sentence(firstLine(error));
50
+ }
51
+ const CHAINS = new Map([
52
+ ["4663", "Robinhood Chain"],
53
+ ["8453", "Base"],
54
+ ["46630", "Robinhood Chain testnet"],
55
+ ["84532", "Base Sepolia"],
56
+ ]);
57
+ const SOLANA = new Map([
58
+ ["5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "Solana"],
59
+ ["EtWTRABZaYq6iMfeYKouRu166VU2xqa1", "Solana devnet"],
60
+ ]);
61
+ /**
62
+ * Where an order was paid, from its own network: the server reading it may pay on another chain.
63
+ * An order on `acp:<chain id>` was paid into an escrow there, which the desk never holds.
64
+ */
65
+ export function paidOn(network) {
66
+ const [kind, id = ""] = typeof network === "string" ? network.split(":") : [];
67
+ const chain = kind === "solana" ? SOLANA.get(id) : kind === "eip155" || kind === "acp" ? CHAINS.get(id) : undefined;
68
+ return { chain: chain ?? "the chain it was paid on", escrow: kind === "acp" };
69
+ }
39
70
  /** Statuses that say "not now" rather than "no", alongside every 5xx. */
40
71
  const RETRY_LATER = new Set([408, 425, 429]);
41
72
  /** Settles as `work` does, or rejects once `signal` aborts. */
@@ -61,8 +92,10 @@ export function createDesk(options) {
61
92
  const limits = options.limits ?? createLimits(config, account);
62
93
  const endpoint = `${config.desk}/x402/tasks`;
63
94
  const recent = new Map();
64
- /** When this process paid, or may have paid, for each order; the desk can take minutes to know one. */
65
- const paidAt = new Map();
95
+ /** When this process paid, or may have paid, for each order, and how to forget its post; the desk can take minutes to know one. */
96
+ const paid = new Map();
97
+ /** What each of those orders still holds against the budget and the daily limit. */
98
+ const held = new Map();
66
99
  /** The desk's price, read from a fresh 402 challenge. */
67
100
  async function quotedPrice(signal = AbortSignal.timeout(15_000)) {
68
101
  try {
@@ -145,6 +178,34 @@ export function createDesk(options) {
145
178
  error: `${cause} The payment may have gone through. Read this order with brainchain_get_task; do not post the task again.`,
146
179
  };
147
180
  }
181
+ /**
182
+ * The desk answers 402 for a payment it refuses, and for a settlement it could not
183
+ * confirm, which it keeps as an order and checks against the chain. Says why such a
184
+ * payment may still be charged, or undefined when the desk has no order or gave it up.
185
+ */
186
+ async function stillOpen(order) {
187
+ let response;
188
+ try {
189
+ response = await fetchImpl(`${endpoint}/${order}`, { signal: AbortSignal.timeout(10_000) });
190
+ }
191
+ catch {
192
+ return "the order could not be read";
193
+ }
194
+ if (response.status === 404)
195
+ return undefined;
196
+ const view = response.ok ? await jsonObject(response) : undefined;
197
+ if (typeof view?.state !== "string")
198
+ return "the order could not be read";
199
+ return view.state === "failed" ? undefined : `it holds the order as ${view.state}`;
200
+ }
201
+ /** A payment the desk gave up was never charged, so its task can be posted again at once and its amount spent on another. */
202
+ async function release(order) {
203
+ paid.get(order)?.forget();
204
+ const hold = held.get(order);
205
+ held.delete(order);
206
+ if (hold)
207
+ await limits.give(hold);
208
+ }
148
209
  /** An agent that retries a post it gave up waiting on gets the first order back instead of paying twice. */
149
210
  function post(task) {
150
211
  const now = Date.now();
@@ -164,7 +225,7 @@ export function createDesk(options) {
164
225
  entry.reply.then((reply) => {
165
226
  const { order } = reply;
166
227
  if (typeof order === "string")
167
- paidAt.set(order.toLowerCase(), now);
228
+ paid.set(order.toLowerCase(), { at: now, forget });
168
229
  else
169
230
  forget();
170
231
  }, forget);
@@ -178,7 +239,11 @@ export function createDesk(options) {
178
239
  await limits.give(hold);
179
240
  hold = undefined;
180
241
  };
181
- const receipt = (order) => ({ order, price: `${formatUnits(hold?.amount ?? 0n)} ${config.token}`, statusUrl: `${endpoint}/${order}` });
242
+ const receipt = (order) => {
243
+ if (hold)
244
+ held.set(order.toLowerCase(), hold);
245
+ return { order, price: `${formatUnits(hold?.amount ?? 0n)} ${config.token}`, statusUrl: `${endpoint}/${order}` };
246
+ };
182
247
  const client = x402Client.fromConfig({
183
248
  schemes: [{ network: config.network, client: new ExactEvmScheme(account()) }],
184
249
  spendControls: { allowedAssets: [{ network: config.network, asset: config.asset, maxAmountPerPayment: config.maxPrice.toString() }] },
@@ -236,11 +301,18 @@ export function createDesk(options) {
236
301
  return { ...reply, ...receipt(typeof reply.order === "string" ? reply.order : orderIdOf(signed)) };
237
302
  }
238
303
  // The desk answers 400 (a brief it refuses, including one too large to post)
239
- // and 402 (a payment it refuses) before settling. Any other answer to a
240
- // signed payment may have charged it.
304
+ // before settling. Any other answer to a signed payment may have charged it,
305
+ // a 402 included unless the desk has no order for it or gave the payment up.
241
306
  if (signed && response.status !== 400 && response.status !== 402) {
242
307
  return possiblyCharged(receipt(typeof reply.order === "string" ? reply.order : orderIdOf(signed)), `The desk answered ${response.status}.`);
243
308
  }
309
+ if (signed && response.status === 402) {
310
+ const order = orderIdOf(signed);
311
+ const open = await stillOpen(order);
312
+ const reason = typeof reply.error === "string" && reply.error.trim() ? ` (${reply.error.trim().replace(/[.!?]+$/, "")})` : "";
313
+ if (open)
314
+ return possiblyCharged(receipt(order), `The desk answered 402${reason}, and ${open}.`);
315
+ }
244
316
  await unreserve();
245
317
  if (response.status === 402)
246
318
  throw new Error(await explainRefusal(reply.error));
@@ -256,8 +328,9 @@ export function createDesk(options) {
256
328
  }
257
329
  const view = await jsonObject(response);
258
330
  if (response.status === 404) {
259
- const at = paidAt.get(order);
331
+ const at = paid.get(order)?.at;
260
332
  if (at === undefined || Date.now() - at >= REPOST_AFTER_MINUTES * 60_000) {
333
+ await release(order);
261
334
  return {
262
335
  order,
263
336
  error: "The desk has no order with this id.",
@@ -281,6 +354,8 @@ export function createDesk(options) {
281
354
  // An unreadable 2xx comes from a proxy or a dropped connection; ask again.
282
355
  if (!view)
283
356
  return { order, error: "The desk's reply could not be read.", done: false, next: "Read the order again shortly.", retryAfterSeconds: 30 };
357
+ if (view.state === "failed")
358
+ await release(order);
284
359
  // Results are served by the desk itself; anything else is left as a link.
285
360
  if (typeof view.result === "string" && view.result.startsWith(`${config.desk}/records/`)) {
286
361
  try {
@@ -290,7 +365,7 @@ export function createDesk(options) {
290
365
  view.resultText =
291
366
  full.length <= RESULT_LIMIT
292
367
  ? full
293
- : `${full.slice(0, RESULT_LIMIT)}\n\n[Cut at ${RESULT_LIMIT.toLocaleString("en-US")} of ${full.length.toLocaleString("en-US")} characters. The full result is at ${view.result}.]`;
368
+ : `${full.slice(0, RESULT_LIMIT)}\n\n[Cut at ${RESULT_LIMIT.toLocaleString("en-US")} of ${full.length.toLocaleString("en-US")} UTF-16 units. The full result is at ${view.result}.]`;
294
369
  }
295
370
  else
296
371
  view.resultError = `The result could not be fetched (HTTP ${text.status}); it is at ${view.result}.`;
@@ -307,6 +382,10 @@ export function createDesk(options) {
307
382
  export function guidance(view, config, now = Date.now()) {
308
383
  const task = (view.task ?? {});
309
384
  const wait = (retryAfterSeconds, next) => ({ done: false, next, retryAfterSeconds });
385
+ const { chain, escrow } = paidOn(view.network);
386
+ // The desk sends no refund for an escrowed order: the escrow gives the money back, with no transaction of the desk's to wait for.
387
+ const escrowReturns = `escrow on ${chain} that holds the payment returns the full amount`;
388
+ const refunding = escrow ? `The ${escrowReturns}.` : `The refund is being sent on ${chain}.`;
310
389
  // Without a task the desk could not post the order, so no work ever existed.
311
390
  const why = typeof view.refundReason === "string" ? `${view.refundReason} ` : task.status === undefined ? "The task could not be posted. " : "";
312
391
  const due = typeof task.due?.by === "string" ? task.due.by : undefined;
@@ -326,15 +405,17 @@ export function guidance(view, config, now = Date.now()) {
326
405
  const left = Number.isFinite(closes) ? Math.ceil((closes - now) / 1000) : undefined;
327
406
  // The desk sends the refund within a minute or two of the window closing.
328
407
  if (left !== undefined && left <= 0)
329
- return wait(60, `${start} The dispute window has closed, so the full amount is refunded on ${config.chain} shortly.`);
408
+ return wait(60, `${start} The dispute window has closed, so ${escrow ? `the ${escrowReturns}` : `the full amount is refunded on ${chain} shortly`}.`);
330
409
  const when = left === undefined ? `, ${config.terms.finalAfter} after grading` : ` at ${due}`;
331
- return wait(left === undefined ? 900 : left + 60, `${start} The full amount is refunded on ${config.chain} when the dispute window closes${when}, unless the model disputes the grade and a ruling pays it. Report this now, then read the order again after that time for the refund transaction.`);
410
+ return wait(left === undefined ? 900 : left + 60, escrow
411
+ ? `${start} The ${escrowReturns}, unless the model disputes the grade before the dispute window closes${when}${left === undefined ? "," : ""} and a ruling pays it. Report this now, then read the order again after that time.`
412
+ : `${start} The full amount is refunded on ${chain} when the dispute window closes${when}, unless the model disputes the grade and a ruling pays it. Report this now, then read the order again after that time for the refund transaction.`);
332
413
  };
333
414
  switch (view.state) {
334
415
  case "paid-out":
335
416
  return paid();
336
417
  case "refunded":
337
- return { done: true, next: `${why}The full amount was refunded on ${config.chain}${typeof view.refundTx === "string" ? ` in ${view.refundTx}` : ""}.` };
418
+ return { done: true, next: escrow ? `${why}The ${escrowReturns}.` : `${why}The full amount was refunded on ${chain}${typeof view.refundTx === "string" ? ` in ${view.refundTx}` : ""}.` };
338
419
  case "refund-failed":
339
420
  return { done: true, next: "The refund could not be sent automatically. Write to hello@usebrainchain.com with this order id." };
340
421
  case "failed":
@@ -346,14 +427,14 @@ export function guidance(view, config, now = Date.now()) {
346
427
  case "unconfirmed":
347
428
  return wait(30, "The payment may have gone through; BrainChain is checking it against the chain. Do not post the task again.");
348
429
  case "refunding":
349
- return wait(60, `${why}The refund is being sent on ${config.chain}.`);
430
+ return wait(60, `${why}${refunding}`);
350
431
  }
351
432
  switch (task.status) {
352
433
  case "paid":
353
434
  return paid();
354
435
  case "refunded":
355
436
  // The board has returned the money to the desk; the payer's refund transaction comes next.
356
- return wait(60, `${why}The refund is being sent on ${config.chain}.`);
437
+ return wait(60, `${why}${refunding}`);
357
438
  // A dispute, a ruling under its appeal window and an appeal can each still change a grade, accepted or not.
358
439
  case "disputed":
359
440
  return wait(600, `The grade is disputed; Jev rules on it${by}. If no ruling comes, the grade stands.${result}`);
package/dist/premiums.js CHANGED
@@ -21,7 +21,7 @@ export const PREMIUMS_TOOL = {
21
21
  title: "Stock premiums on Robinhood Chain",
22
22
  description: "What tokenized US stocks cost on Robinhood Chain against their Chainlink price right now: pool price, Chainlink price, " +
23
23
  "the premium in percent and the feed's age, for NVDA, SPY, AAPL, TSLA, GOOGL, META and MSFT. Read from the chain, free, no key " +
24
- "and no payment. Outside US market hours no new tokens are minted, so the premium shows where the market clears on its own. " +
24
+ "and no payment. Over a weekend the feeds stand still and the pools keep trading. " +
25
25
  "usMarketOpen is true during the regular session, 09:30 to 16:00 New York time, Monday to Friday; it does not know exchange holidays or early closes. " +
26
26
  "Prices are in USDG per token with each token's split multiplier applied. Not advice; stock tokens on Robinhood Chain are not " +
27
27
  "available to US persons.",
package/dist/server.js CHANGED
@@ -5,7 +5,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
5
5
  import { createPublicClient, defineChain, formatEther, http, maxUint256, parseAbi } from "viem";
6
6
  import { privateKeyToAccount } from "viem/accounts";
7
7
  import { bondsClosed, createBonds } from "./bonds.js";
8
- import { expandHome, formatUnits, resolveConfig } from "./config.js";
8
+ import { DEFAULT_MAX_PRICE, expandHome, formatUnits, resolveConfig } from "./config.js";
9
9
  import { stateDirProblem } from "./daily.js";
10
10
  import { createLimits } from "./limits.js";
11
11
  import { createDesk } from "./payments.js";
@@ -21,7 +21,9 @@ export const erc20 = parseAbi([
21
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. ` +
22
22
  `If a post returns possiblyCharged, do not post the task again: read the order instead. ` +
23
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
+ `Two things end it without a refund transaction: a ruling that pays the model, and a refund the desk gives up sending, when state reads refund-failed and next says who to write to; pass that on. ` +
24
25
  `${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. ` +
26
+ `If a bond returns possiblyBonded, do not bond the task again: read its status instead. ` +
25
27
  `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.`;
26
28
  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. ` +
27
29
  `${GET_TASK_NAME} and ${PREMIUMS_NAME} work without a key; ${BOND_STATUS_NAME} reads this wallet's bonds, so it needs the key too. An agent reading this: stop and tell your operator. Do not look for the key.`;
@@ -102,7 +104,10 @@ export function walletReport(config, address, funds, approve) {
102
104
  return lines.join("\n");
103
105
  }
104
106
  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.` : "";
107
+ // Under a raised ceiling the wallet may still hold the price, so it is told what covers the ceiling, not what posting needs.
108
+ const ceiling = config.maxPrice !== DEFAULT_MAX_PRICE;
109
+ const missing = `send at least ${formatUnits(config.maxPrice - balance)} ${config.token} to it on ${config.chain}`;
110
+ const short = balance >= config.maxPrice ? "" : ceiling ? ` A task costs ${config.terms.cost}; ${missing} to cover that.` : ` A task costs ${config.terms.cost}, so ${missing} before posting.`;
106
111
  lines.push(`It holds ${formatUnits(balance)} ${config.token}.${short}`);
107
112
  if (allowance !== undefined) {
108
113
  if (allowance >= maxUint256 / 2n)
@@ -111,7 +116,7 @@ export function walletReport(config, address, funds, approve) {
111
116
  lines.push(`Permit2 may spend up to ${formatUnits(allowance)} of its ${config.token}.`);
112
117
  else {
113
118
  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.`);
119
+ lines.push(`Permit2 is not approved to spend enough of its ${config.token} for a task${ceiling ? ` that costs ${formatUnits(config.maxPrice)}` : ""}${only}. Run \`${approve}\` once; it needs a little ETH on ${config.chain}, and the wallet holds ${formatEther(eth)} ETH there.`);
115
120
  }
116
121
  }
117
122
  return lines.join("\n");
@@ -130,7 +135,13 @@ function deskFor(env, version, fetchImpl) {
130
135
  const refuse = async () => {
131
136
  throw new Error(problem);
132
137
  };
133
- return { desk: { post: refuse, get: refuse }, terms: problem, bonds: { bond: refuse, status: refuse }, bondTools: bondTools({ maxBond: 0n, budget: 0n }, undefined, `Invalid setting: ${problem}`) };
138
+ return {
139
+ desk: { post: refuse, get: refuse },
140
+ terms: problem,
141
+ bonds: { bond: refuse, status: refuse },
142
+ bondTools: bondTools({}, undefined, { bond: `Invalid setting: ${problem}`, status: `Invalid setting: ${problem}` }),
143
+ refused: new Map([POST_TASK_NAME, GET_TASK_NAME, BOND_TASK_NAME, BOND_STATUS_NAME].map((name) => [name, problem])),
144
+ };
134
145
  }
135
146
  let account;
136
147
  let keyProblem;
@@ -169,22 +180,32 @@ function deskFor(env, version, fetchImpl) {
169
180
  return { balance, allowance };
170
181
  },
171
182
  });
172
- const closed = bondsClosed(config) ?? (problem ? `Invalid setting: ${problem}` : undefined);
183
+ // What the descriptions promise for a setting that cannot be used: a tool the setting stops answers with it
184
+ // and touches nothing else. A refused state folder could still take a write, or hang on one, so it stops
185
+ // the two tools that pay. Reading a bond needs the key and no record, so only a key problem stops that.
186
+ const closed = bondsClosed(config);
187
+ const refused = new Map();
188
+ if (problem)
189
+ for (const name of [POST_TASK_NAME, BOND_TASK_NAME])
190
+ refused.set(name, keyProblem ? `${problem} ${KEY_HELP}` : problem);
191
+ if (keyProblem)
192
+ refused.set(BOND_STATUS_NAME, `${keyProblem} ${KEY_HELP}`);
173
193
  return {
174
194
  desk,
175
195
  terms: { ...config.terms, ...(account ? { wallet: account.address } : {}), ...(problem ? { problem } : {}) },
176
196
  bonds: createBonds({ config, signer: payer, limits }),
177
- bondTools: bondTools(config, account?.address, closed),
197
+ bondTools: bondTools(config, account?.address, { bond: problem ? `Invalid setting: ${problem}` : closed, status: keyProblem ? `Invalid setting: ${keyProblem}` : closed }),
198
+ refused,
178
199
  };
179
200
  }
180
201
  export function createServer(env, version, options = {}) {
181
- const { desk, terms, bonds, bondTools: bondToolList } = deskFor(env, version, options.fetch);
202
+ const { desk, terms, bonds, bondTools: bondToolList, refused } = deskFor(env, version, options.fetch);
182
203
  const problem = typeof terms === "string" ? terms : terms.problem;
183
204
  if (problem)
184
205
  (options.log ?? console.error)(`brainchain-mcp has an invalid setting: ${problem}`);
185
206
  const tools = [postTaskTool(terms), GET_TASK_TOOL, PREMIUMS_TOOL, ...bondToolList];
186
207
  const server = new Server({ name: "brainchain", title: "BrainChain", version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
187
208
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools }));
188
- server.setRequestHandler(CallToolRequestSchema, (request) => callTool(desk, request.params.name, request.params.arguments, undefined, bonds));
209
+ server.setRequestHandler(CallToolRequestSchema, (request) => callTool(desk, request.params.name, request.params.arguments, undefined, bonds, refused));
189
210
  return server;
190
211
  }
package/dist/shipped.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Shipped's registry on Robinhood Chain and the terms of a BrainChain delivery
2
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.
3
+ // BrainChain's ProofIndex, proofOf(task) >= 1, with the agent that paid for the
4
+ // task as the refund side.
5
5
  import { decodeAbiParameters, decodeFunctionData, encodeAbiParameters, encodeFunctionData, parseAbi } from "viem";
6
6
  export const proofIndexAbi = parseAbi(["function proofOf(address board, uint256 taskId) view returns (uint256)"]);
7
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)";
@@ -22,15 +22,17 @@ export const taskBoardAbi = parseAbi([
22
22
  export const DeedStatus = { None: 0, Open: 1, Ruled: 2, Appealed: 3, Settled: 4 };
23
23
  export const Ruling = { None: 0, Accepted: 1, Rejected: 2, Partial: 3, Invalid: 4, Conceded: 5, Default: 6, CheckMet: 7 };
24
24
  /** TaskBoard.Status. */
25
- export const TaskStatus = { Open: 1, Hired: 2 };
25
+ export const TaskStatus = { Open: 1, Hired: 2, Paid: 6, Refunded: 7 };
26
26
  export const BPS = 10000n;
27
27
  export const FEE_BPS = 100n;
28
- /** The bond's deadline is the task's plus this. */
29
- export const BOND_LEAD_SEC = 48n * 3600n;
28
+ /** The bond's deadline is the task's plus this: 48 hours for a grade, a dispute and an appealed ruling on set 3.5.0, then the worker's day to mint. */
29
+ export const BOND_LEAD_SEC = 72n * 3600n;
30
30
  export const CHECK_HOLD_SEC = 3600n;
31
31
  export const CHECK_CONFIRM_SEC = 86400n;
32
32
  /** Under this the fee and the gas take too much of the bond. */
33
33
  export const MIN_BOND = 2000000n;
34
+ /** Who a missed bond pays, in the same words wherever a tool says it. A bond names the address, not which of the two it is. */
35
+ export const MISSED_TO = "the agent that paid BrainChain's desk for the task, or to the poster for a task posted on the board directly";
34
36
  const ZERO = "0x0000000000000000000000000000000000000000";
35
37
  const checkDataTypes = [{ type: "address" }, { type: "bytes" }, { type: "uint8" }, { type: "uint256" }];
36
38
  /** Task ids restart with each board, so the board is part of the key. */
package/dist/tools.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import { formatUnits, REPOST_AFTER_MINUTES } from "./config.js";
2
- import { canonicalStringify } from "./payments.js";
3
- import { PREMIUMS_NAME, readPremiums } from "./premiums.js";
4
- import { MIN_BOND } from "./shipped.js";
2
+ import { brief, canonicalStringify, RESULT_LIMIT } from "./payments.js";
3
+ import { PREMIUMS_NAME, PREMIUMS_TOOL, readPremiums } from "./premiums.js";
4
+ import { MIN_BOND, MISSED_TO } from "./shipped.js";
5
5
  export const POST_TASK_NAME = "brainchain_post_task";
6
6
  /** The desk's cap on a task as it goes on chain: title, brief, levels and pass level as JSON. */
7
7
  export const MAX_TASK_BYTES = 64_000;
8
8
  export const GET_TASK_NAME = "brainchain_get_task";
9
9
  export const BOND_TASK_NAME = "brainchain_bond_task";
10
10
  export const BOND_STATUS_NAME = "brainchain_bond_status";
11
+ /** What a length in UTF-16 units, which the desk counts in, means to someone writing a brief. */
12
+ const UNITS = "most characters take one, an emoji two or more";
11
13
  const TASK_ID = { type: "string", pattern: "^[1-9][0-9]{0,17}$", description: "The task's id on BrainChain's task board." };
12
14
  const BOND_TASK_INPUT = {
13
15
  type: "object",
@@ -19,25 +21,37 @@ const BOND_TASK_INPUT = {
19
21
  additionalProperties: false,
20
22
  };
21
23
  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
+ /**
25
+ * The bond tools, worded for the network and the wallet this server holds. `closed` says, per tool, why it
26
+ * cannot run: reading a bond needs less than placing one, so the two can differ. Without settings to read,
27
+ * the cap is named by its variable.
28
+ */
29
+ export function bondTools(config, wallet, closed = {}) {
24
30
  const where = wallet ? `this server's wallet ${wallet}` : "this server's wallet";
31
+ const lead = (why) => (why ? `${why} Until then this tool only returns that error. ` : "");
32
+ const cap = config.maxBond === undefined ? "BRAINCHAIN_MAX_BOND_UNITS" : `${formatUnits(config.maxBond)} USDG`;
25
33
  return [
26
34
  {
27
35
  name: BOND_TASK_NAME,
28
36
  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}.`,
37
+ description: lead(closed.bond) +
38
+ `Lock USDG on Shipped behind a BrainChain task from ${where}, as the worker that will deliver it. Jev must already have hired this wallet for the task. ` +
39
+ `If the task is paid and its Proof is recorded by the bond's deadline, which is set after the task's and comes back in the reply, the bond less Shipped's 1% fee is credited to the wallet on Shipped's registry, where the worker CLI's \`bond claim\` collects it; if not, the bond goes to ${MISSED_TO}. ` +
40
+ `A worker's settled bonds, delivered and missed, are part of what Jev reads at later hires. ` +
41
+ `The amount is at least the task's bounty and ${formatUnits(MIN_BOND)} USDG, at most ${cap} per task, and counts against this server's budget and daily limit like a post. ` +
42
+ `The wallet needs the USDG and a little ETH on Robinhood Chain. Never bond a task this wallet posted or paid for. Returns the bond, its transaction and its deadline; follow it with ${BOND_STATUS_NAME}. ` +
43
+ `If the reply says possiblyBonded, the bond may have been placed and could not be confirmed: read the task with ${BOND_STATUS_NAME} and do not bond it again.`,
34
44
  inputSchema: BOND_TASK_INPUT,
35
45
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
36
46
  },
37
47
  {
38
48
  name: BOND_STATUS_NAME,
39
49
  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.`,
50
+ description: lead(closed.status) +
51
+ `Where ${where}'s bonds on a task stand: open, marked (the Proof is recorded and the check waits out its hour), ` +
52
+ `lapsed (the bond's deadline passed with its check unmarked, so the bond waits to settle as missed), ` +
53
+ `delivered (the bond less Shipped's 1% fee was credited to the wallet on Shipped's registry; the reply says whether it was collected or what the registry still owes the wallet) ` +
54
+ `or missed (paid to ${MISSED_TO}), with the next step.`,
41
55
  inputSchema: BOND_STATUS_INPUT,
42
56
  annotations: { readOnlyHint: true, openWorldHint: true },
43
57
  },
@@ -46,13 +60,13 @@ export function bondTools(config, wallet, closed) {
46
60
  const POST_TASK_INPUT = {
47
61
  type: "object",
48
62
  properties: {
49
- title: { type: "string", minLength: 1, maxLength: 120, description: "One line naming the deliverable." },
63
+ title: { type: "string", minLength: 1, maxLength: 120, description: "One line naming the deliverable, up to 120 UTF-16 units." },
50
64
  brief: {
51
65
  type: "string",
52
66
  minLength: 1,
53
67
  maxLength: 32000,
54
68
  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.",
69
+ `Up to 32,000 UTF-16 units, as the desk counts: ${UNITS} (a flag four, a family emoji eleven). The whole task as JSON is at most 64,000 bytes: about 21,000 characters of Chinese, Japanese or Korean.`,
56
70
  },
57
71
  levels: {
58
72
  type: "array",
@@ -60,7 +74,12 @@ const POST_TASK_INPUT = {
60
74
  minItems: 2,
61
75
  maxItems: 3,
62
76
  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.",
77
+ description: "Two or three different quality levels, best first, each a short description a reader could check, up to 200 UTF-16 units. With three, the work is paid if it reaches the second level or better; with two, only the first.",
78
+ },
79
+ passIndex: {
80
+ type: "integer",
81
+ enum: [0, 1],
82
+ description: "Leave this out: the tool sets the pass level itself. A brief copied with it is accepted only when it names that level, 1 with three levels and 0 with two.",
64
83
  },
65
84
  },
66
85
  required: ["title", "brief", "levels"],
@@ -68,9 +87,9 @@ const POST_TASK_INPUT = {
68
87
  };
69
88
  /** Given a configuration error instead of terms, the description leads with it. */
70
89
  export function postTaskTool(terms) {
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. ` +
90
+ const about = `Pay for a task on BrainChain. Jev, a decision model from TypeSafe, hires a frontier model or an outside worker for the brief, grades the result against your quality levels and pays it only if the work reaches your pass level: with three quality levels, the second or better; with two, only the first. ` +
72
91
  `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. `;
92
+ `A brief can be up to 32,000 UTF-16 units (${UNITS}), and the whole task at most 64,000 bytes as JSON. `;
74
93
  return {
75
94
  name: POST_TASK_NAME,
76
95
  title: "Post a paid task to BrainChain",
@@ -80,7 +99,7 @@ export function postTaskTool(terms) {
80
99
  about +
81
100
  `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. ` +
82
101
  `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}. ` +
83
- `While this server keeps running, an identical post within ${REPOST_AFTER_MINUTES} minutes returns the first post's order, marked duplicate, instead of paying again.`,
102
+ `While this server keeps running, an identical post within ${REPOST_AFTER_MINUTES} minutes returns the first post's order, marked duplicate, instead of paying again, unless ${GET_TASK_NAME} has read that order's payment as failed.`,
84
103
  inputSchema: POST_TASK_INPUT,
85
104
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
86
105
  };
@@ -89,7 +108,8 @@ export const GET_TASK_TOOL = {
89
108
  name: GET_TASK_NAME,
90
109
  title: "Read a BrainChain order",
91
110
  description: "Read an order from brainchain_post_task: the payment, the task's status on BrainChain, Jev's grade, the result text once the work is accepted, and the refund transaction if it is not. " +
92
- "Call again after `retryAfterSeconds` until `done` is true. `next` says what the order's state means and what to do. A rejected task stays not done until the refund is sent, when the dispute window closes.",
111
+ `A result over ${RESULT_LIMIT.toLocaleString("en-US")} UTF-16 units (${UNITS}) is cut there, ending with a link to the full text. ` +
112
+ "Call again after `retryAfterSeconds` until `done` is true. `next` says what the order's state means and what to do. A rejected task stays not done until the refund is sent, when the dispute window closes. Two things end it without a refund transaction: a ruling that pays the model, and a refund the desk gives up sending, when `state` reads `refund-failed` and `next` says who to write to.",
93
113
  inputSchema: {
94
114
  type: "object",
95
115
  properties: { order: { type: "string", pattern: "^0x[0-9a-fA-F]{64}$", description: "Order id returned by brainchain_post_task." } },
@@ -116,14 +136,14 @@ function text(name, value, max = Infinity) {
116
136
  if (BLANK.test(sent))
117
137
  throw new Error(`${name} is empty.`);
118
138
  if (sent.length > max)
119
- throw new Error(`${name} is ${count(sent.length)} characters; the limit is ${count(max)}.`);
139
+ throw new Error(`${name} is ${count(sent.length)} UTF-16 units; the limit is ${count(max)}. Most characters take one, an emoji two or more.`);
120
140
  return sent;
121
141
  }
122
142
  function refuseUnknownFields(input, schema) {
123
143
  const known = Object.keys(schema.properties ?? {});
124
144
  const unknown = Object.keys(input).filter((key) => !known.includes(key));
125
145
  if (unknown.length > 0)
126
- throw new Error(`Unknown field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}. This tool takes only ${known.join(", ")}.`);
146
+ throw new Error(`Unknown field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}. This tool takes ${known.length > 0 ? `only ${known.join(", ")}` : "nothing"}.`);
127
147
  }
128
148
  /**
129
149
  * The desk's own limits, applied to what is sent, before anything is signed,
@@ -148,8 +168,13 @@ export function checkBrief(input) {
148
168
  if (first < i)
149
169
  throw new Error(`levels[${i}] repeats levels[${first}]; each level must describe a different quality.`);
150
170
  });
171
+ // The desk sets the pass level itself: the lowest level never passes. A copied brief that names the same level is the same brief.
172
+ const passIndex = Math.min(1, sent.length - 2);
173
+ if (input.passIndex !== undefined && input.passIndex !== passIndex) {
174
+ throw new Error(`passIndex must be ${passIndex} here: with ${sent.length === 3 ? "three levels this tool pays work that reaches the second level or better" : "two levels this tool pays only work that reaches the first"}. Leave passIndex out.`);
175
+ }
151
176
  // 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) }));
177
+ const bytes = Buffer.byteLength(canonicalStringify({ title, brief, levels: sent, passIndex }));
153
178
  if (bytes > MAX_TASK_BYTES) {
154
179
  throw new Error(`The task is ${count(bytes)} bytes as JSON; the limit is ${count(MAX_TASK_BYTES)}. Shorten the brief.`);
155
180
  }
@@ -166,8 +191,12 @@ function orderReply(view) {
166
191
  result.content.push({ type: "text", text: resultText });
167
192
  return result;
168
193
  }
169
- export async function callTool(desk, name, args, premiums = readPremiums, bonds) {
194
+ /** `refused` names the tools that a setting keeps from running, each with the error it answers whatever it is sent. */
195
+ export async function callTool(desk, name, args, premiums = readPremiums, bonds, refused = new Map()) {
170
196
  const input = (args ?? {});
197
+ const refusal = refused.get(name);
198
+ if (refusal !== undefined)
199
+ return reply({ error: refusal }, true);
171
200
  try {
172
201
  if (name === BOND_TASK_NAME || name === BOND_STATUS_NAME) {
173
202
  if (!bonds)
@@ -195,8 +224,13 @@ export async function callTool(desk, name, args, premiums = readPremiums, bonds)
195
224
  throw new Error("order must be the 0x-prefixed, 64-character hex id that brainchain_post_task returned.");
196
225
  return orderReply(await desk.get(order));
197
226
  }
198
- if (name === PREMIUMS_NAME)
199
- return reply(await premiums());
227
+ if (name === PREMIUMS_NAME) {
228
+ refuseUnknownFields(input, PREMIUMS_TOOL.inputSchema);
229
+ const board = await premiums().catch((error) => {
230
+ throw new Error(`Robinhood Chain could not be read just now: ${brief(error)} Try again in a minute.`);
231
+ });
232
+ return reply(board);
233
+ }
200
234
  return reply({ error: `Unknown tool ${name}.` }, true);
201
235
  }
202
236
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-mcp",
3
- "version": "0.7.5",
3
+ "version": "0.8.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",