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 +20 -20
- package/dist/bonds.js +213 -108
- package/dist/config.js +3 -2
- package/dist/index.js +1 -1
- package/dist/payments.js +102 -21
- package/dist/premiums.js +1 -1
- package/dist/server.js +29 -8
- package/dist/shipped.js +7 -5
- package/dist/tools.js +58 -24
- package/package.json +1 -1
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
|
|
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.
|
|
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.
|
|
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
|
-
[](https://cursor.com/en/install-mcp?name=brainchain&config=
|
|
39
|
+
[](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.
|
|
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.
|
|
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.
|
|
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
|
-
```
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
3
|
-
// the task is
|
|
4
|
-
// daily limit as a post, and never passes
|
|
5
|
-
|
|
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 {
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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:
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
//
|
|
240
|
-
//
|
|
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 =
|
|
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")}
|
|
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 ${
|
|
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,
|
|
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 ${
|
|
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}
|
|
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}
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
/**
|
|
23
|
-
|
|
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
|
|
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
|
|
32
|
-
`
|
|
33
|
-
`The
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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)}
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|