@blockula/mcp 0.5.1 → 1.0.2
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 +60 -19
- package/dist/accounts.d.ts +44 -0
- package/dist/accounts.js +118 -0
- package/dist/budget.d.ts +104 -0
- package/dist/budget.js +169 -0
- package/dist/index.js +548 -207
- package/dist/locks.d.ts +61 -0
- package/dist/locks.js +222 -0
- package/dist/network.d.ts +82 -13
- package/dist/network.js +170 -38
- package/dist/spend.d.ts +16 -6
- package/dist/spend.js +60 -26
- package/dist/untrusted.d.ts +56 -3
- package/dist/untrusted.js +160 -10
- package/npm-shrinkwrap.json +22 -22
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
🧛 MCP server that lets any coding agent use **Blockula**, a post-quantum blockchain. Agents can hold a wallet, send FANG, claim `.boo` names, stake, vote on proposals, look things up in plain English, and start or summon chains.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
claude mcp add blockula -- npx -y @blockula/mcp
|
|
7
|
-
claude mcp add blockula -- npx -y @blockula/mcp
|
|
6
|
+
claude mcp add blockula -- npx -y @blockula/mcp # a `blockula dev` chain on this machine
|
|
7
|
+
claude mcp add blockula -e BLOCKULA_RPC=https://<your node> -- npx -y @blockula/mcp # a chain you launched (`blockula summon`)
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
With no configuration it connects to
|
|
10
|
+
With no configuration it connects to a `blockula dev` node on this machine (`http://localhost:7070`). The public testnet (`blockula-testnet-2`) was retired in November 2026 and has no public node, so nothing public is built in: for any other chain, set `BLOCKULA_RPC` to a node of it. Call `status` to see which network and chain you're on.
|
|
11
11
|
|
|
12
12
|
To run an exact release, name it: `npx -y @blockula/mcp@0.5.0` (the first release on npm, for the protocol v1 testnet `blockula-testnet-2`). Each release pins its whole dependency tree (exact versions plus a published `npm-shrinkwrap.json`), so what runs next to the agent's key is what CI tested.
|
|
13
13
|
|
|
@@ -15,10 +15,10 @@ To run an exact release, name it: `npx -y @blockula/mcp@0.5.0` (the first releas
|
|
|
15
15
|
|
|
16
16
|
The network is chosen by the user, once, in the MCP's configuration, and nothing changes it while the server runs: it never switches to whatever answers on `localhost:7070`, and no tool can point it somewhere else.
|
|
17
17
|
|
|
18
|
-
- `--network` (or `BLOCKULA_NETWORK`): `
|
|
19
|
-
- `BLOCKULA_RPC` picks the node: required for `
|
|
20
|
-
-
|
|
21
|
-
- A node reporting another chain id, genesis hash or address prefix is refused, before anything is signed, at startup or mid-session. If a network was relaunched on purpose under the same chain id, the user removes its entry from `known-chains.json` or sets `BLOCKULA_GENESIS_HASH`. (A relaunch takes a new chain id, as the testnet's did: an entry remembered for the old `blockula-testnet` isn't held against `blockula-testnet-2`.)
|
|
18
|
+
- `--network` (or `BLOCKULA_NETWORK`): `local` (default, `http://localhost:7070`), `testnet` or `custom`.
|
|
19
|
+
- `BLOCKULA_RPC` picks the node: required for `testnet` (its public node was retired: without one the MCP refuses to start and says so) and `custom`. Plain `http://` only on this machine or a private network, and no query or fragment. A node written two ways (`Node.Example`, `:443`, a trailing `/.`) is one node: it's never its own witness.
|
|
20
|
+
- The testnet pins its chain id (`blockula-testnet-2`, with the `tboo` address prefix), and every network pins a genesis hash where one is known (`KNOWN_GENESIS` in `src/network.ts`: a release pins a launched chain's). `BLOCKULA_CHAIN_ID` and `BLOCKULA_GENESIS_HASH` pin them for any node. Whatever isn't pinned is pinned from the first status the node reports and remembered in `~/.blockula/known-chains.json` (except for local dev chains, which `--reset` replaces).
|
|
21
|
+
- A node reporting another chain id, genesis hash or address prefix is refused, before anything is signed, at startup or mid-session. So is one whose chain id isn't 1 to 64 letters, digits, `-`, `_` or `.` (the chain id names files and ledger entries here) or whose genesis hash isn't 64 hex characters. If a network was relaunched on purpose under the same chain id, the user removes its entry from `known-chains.json` or sets `BLOCKULA_GENESIS_HASH`. (A relaunch takes a new chain id, as the testnet's did: an entry remembered for the old `blockula-testnet` isn't held against `blockula-testnet-2`.) A node that says it's on `blockula-1` is refused too, unless the user set `BLOCKULA_CHAIN_ID=blockula-1`: it's never pinned on first use, so a configuration that didn't name that chain can't land there.
|
|
22
22
|
|
|
23
23
|
`start_dev_node` starts a local chain, but the MCP only uses it when it was configured with `--network local`.
|
|
24
24
|
|
|
@@ -32,25 +32,64 @@ The network is chosen by the user, once, in the MCP's configuration, and nothing
|
|
|
32
32
|
| Staking | `stake`, `unstake`, `withdraw_stake`, `my_stake` |
|
|
33
33
|
| Governance | `list_proposals`, `vote` |
|
|
34
34
|
| Hunts and contributions | `list_hunts`, `link_github`, `fund_hunt`, `contribution_pool`, `submit_contribution`, `my_contributions` |
|
|
35
|
+
| Programs (read only) | `program_show`, `program_view`, `program_code` (and `lookup` of a program address or code hash) |
|
|
35
36
|
| Running chains | `install_node`, `start_dev_node`, `summon_chain` |
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
Tools carry a title and annotations: the reads are `readOnlyHint`, and everything that signs is `destructiveHint` and `openWorldHint`. To skip the prompt for reads only, allow them by name in Claude Code's `.claude/settings.json`, and leave the tools that move FANG to ask every time:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"permissions": {
|
|
43
|
+
"allow": [
|
|
44
|
+
"mcp__blockula__status", "mcp__blockula__tx_status", "mcp__blockula__wallet_info",
|
|
45
|
+
"mcp__blockula__check_name", "mcp__blockula__lookup", "mcp__blockula__latest_blocks",
|
|
46
|
+
"mcp__blockula__my_stake", "mcp__blockula__list_proposals", "mcp__blockula__list_hunts",
|
|
47
|
+
"mcp__blockula__contribution_pool", "mcp__blockula__my_contributions",
|
|
48
|
+
"mcp__blockula__program_show", "mcp__blockula__program_view", "mcp__blockula__program_code"
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Never allow `mcp__blockula__*`: that lets every send through without asking.
|
|
55
|
+
|
|
56
|
+
**Getting paid for code:** a Hunt's creator awards its bounty after the PR merges, to the account linked to the PR author's GitHub (`link_github`, which needs an owner key: through a session key, the user links GitHub in their wallet). Once a PR to a pool repo merges, `submit_contribution` claims it for the user's account. It checks the GitHub link first and refuses one that doesn't verify (`force: true` overrides), since validators would refute the claim. The bond is returned unless validators refute the claim or the jury marks it spam. If you act through a session key, the user first registers it as a Familiar. Validators check GitHub, a human jury scores it, and rewards vest to the user. `my_contributions` tracks it. The bond counts toward `BLOCKULA_MAX_SEND` / `BLOCKULA_MAX_DAILY`. FANG, Hunts and Soul have no value, on any chain.
|
|
38
57
|
|
|
39
58
|
**Spending a user's money safely:**
|
|
40
|
-
1. `create_wallet`, then `use_account` with the user's account address (`boo1…`: the web wallet's Devices & agents → Give an agent a budget shows it, with the name as a label). A name works on
|
|
41
|
-
2. The user pastes it in the Blockula web wallet
|
|
59
|
+
1. `create_wallet`, then `use_account` with the user's account address (`boo1…`: the web wallet's Devices & agents → Give an agent a budget shows it, with the name as a label). A name works on any chain but `blockula-1`; there an unproven name answers `needs_confirmation` with the address it resolved to, for the user to compare with the one their wallet shows. Until the user has authorized the key it answers with a `grant_code`: this key's signed consent to joining that one account (no secret in it). Hand it to the user. A key id alone can't be added (AddKey needs the key's consent, so nobody can pad the key's account list).
|
|
60
|
+
2. The user pastes it in the Blockula web wallet (Devices & agents → Give an agent a budget) and picks a limit and an expiry. The chain enforces both.
|
|
42
61
|
3. `use_account` with the same address again, then `send`.
|
|
43
62
|
|
|
63
|
+
`use_account` returns a `check_code` (the wallet shows the same code for the key) and, on a chain with a public wallet built in, the wallet's address (none is, since the public testnet was retired). An address with no account gets no grant code: it can't add a key until it has FANG. The account the agent acts for is kept per chain: a testnet account doesn't follow the agent to another network.
|
|
64
|
+
|
|
65
|
+
**What can be spent.** `wallet_info` (and `use_account`) say what the account can move now (`transferable`), what's `locked` and until when, the session key's allowance this window and when it resets, the caps, and `spendable_now`: the smallest of them, with the one that binds. What the chain would refuse by the node's own answers is refused before anything is signed, sent or counted: a session key signing anything but a transfer (or a Familiar's claim), more than its allowance left, a payment past what the account can move, or a key that isn't on the account. A lying node can only cause such a refusal, never a spend.
|
|
66
|
+
|
|
67
|
+
**What a payment costs.** Paying an address with no account yet burns the account creation fee (0.1 FANG today) on top. `send` reports it as `account_opening_fee_burned`, and `total_cost` is the amount, the fee charged and that burn.
|
|
68
|
+
|
|
44
69
|
Session keys can only send transfers: staking, voting and name renewals need an owner key (the agent's own account, or one the user made it an owner of). Staking and voting lock FANG for weeks, so ask the user first.
|
|
45
70
|
|
|
46
|
-
**Spending caps.** `BLOCKULA_MAX_SEND` bounds what one call moves and `BLOCKULA_MAX_DAILY` what leaves the account per UTC day through this MCP. Every FANG-moving tool counts: `send` (with the fee and, for a new account, the opening fee), `stake`, `fund_hunt`, `register_name` and `renew_name` (their price, computed from the chain's parameters and never below the node's quote; a node that doesn't report one is refused), `submit_contribution` (the bond), and every tx's fee. A spend is reserved before the tx is sent, under the tx's (account, nonce) slot. Only the unused part of a final tx's max fee comes back. Everything else stays counted: a tx that failed in a block (its fee was charged), one the node dropped unrun, one that is pending or unknown, and even one the node refused, since a lying node can answer "refused" and relay the signed tx anyway. A refused tx stops being counted twice when the MCP signs its next tx with the same nonce (at most one tx per slot can execute). A tx that isn't final within `BLOCKULA_TX_WAIT_MS` comes back as `"status": "pending"` (or `"dropped"`, `"unknown"`) with its `tx_id`: it was submitted and still counts, so check `tx_status` instead of sending it again. `tx_status` keeps the distinction: `"failed"` ran in a block (final, its fee was charged), `"dropped"` was dropped from that node's mempool unrun (nothing charged, but another node may still include it until its `valid_until`, which comes from what this MCP signed: `agent-txs.json`), and a tx this MCP signed that the node can't show (pruned, never held) is read against the account's nonce: `"nonce_used"` (a tx with that nonce ran, this one or one that took its nonce: it can never execute again, and may already have gone through), `"expired"` (the nonce was still unused past `valid_until`: it never ran; proven with `BLOCKULA_WITNESSES`, else the node's word), otherwise `"unknown"` (in flight until `valid_until`). With witnesses only the nonce is proven: which tx used it is the node's word. A tx signed on another network (every network shares `agent-txs.json`, and each record names its chain) is `"other_chain"`: ask that network. By default `BLOCKULA_MAX_DAILY` is `BLOCKULA_MAX_SEND` plus 1 FANG for fees, so one full-size call fits in a day. The chain itself enforces a session key's limit; these caps are the MCP's own (`wallet_info` shows today's total).
|
|
71
|
+
**Spending caps.** `BLOCKULA_MAX_SEND` bounds what one call moves and `BLOCKULA_MAX_DAILY` what leaves the account per UTC day on each chain through this MCP. Every FANG-moving tool counts: `send` (with the fee and, for a new account, the opening fee), `stake`, `fund_hunt`, `register_name` and `renew_name` (their price, computed from the chain's parameters and never below the node's quote; a node that doesn't report one is refused), `submit_contribution` (the bond), and every tx's fee. A spend is reserved before the tx is sent, under the tx's (chain, account, nonce) slot. Only the unused part of a final tx's max fee comes back. Everything else stays counted: a tx that failed in a block (its fee was charged), one the node dropped unrun, one that is pending or unknown, and even one the node refused, since a lying node can answer "refused" and relay the signed tx anyway. A refused tx stops being counted twice when the MCP signs its next tx with the same nonce (at most one tx per slot can execute). The ledger keeps a total per chain (`agent-spend.json`, still readable by 0.5.1: its `spent` is every chain's total, and spend it can't place on a chain counts toward each). Building, signing and submitting take one tx at a time per account, in this process and across sessions sharing `BLOCKULA_HOME`, so parallel calls don't collide on a nonce. A tx that isn't final within `BLOCKULA_TX_WAIT_MS` comes back as `"status": "pending"` (or `"dropped"`, `"unknown"`) with its `tx_id`: it was submitted and still counts, so check `tx_status` instead of sending it again. `tx_status` keeps the distinction: `"failed"` ran in a block (final, its fee was charged), `"dropped"` was dropped from that node's mempool unrun (nothing charged, but another node may still include it until its `valid_until`, which comes from what this MCP signed: `agent-txs.json`), and a tx this MCP signed that the node can't show (pruned, never held) is read against the account's nonce: `"nonce_used"` (a tx with that nonce ran, this one or one that took its nonce: it can never execute again, and may already have gone through), `"expired"` (the nonce was still unused past `valid_until`: it never ran; proven with `BLOCKULA_WITNESSES`, else the node's word), otherwise `"unknown"` (in flight until `valid_until`). With witnesses only the nonce is proven: which tx used it is the node's word. A tx signed on another network (every network shares `agent-txs.json`, and each record names its chain) is `"other_chain"`: ask that network. By default `BLOCKULA_MAX_DAILY` is `BLOCKULA_MAX_SEND` plus 1 FANG for fees, so one full-size call fits in a day. The chain itself enforces a session key's limit; these caps are the MCP's own (`wallet_info` shows today's total).
|
|
47
72
|
|
|
48
|
-
**
|
|
73
|
+
**What the caps protect.** The MCP's caps protect against mistakes. (The daily one is per chain: a key used on two chains has that budget on each.) They don't protect against a compromised agent: the agent's own shell can read its key file and rewrite the ledger. What protects the user's FANG from a compromised agent is the session key's limit, which the chain enforces. So spend through a session key (`use_account`), with a small limit and a near expiry, and keep the agent's own account nearly empty.
|
|
74
|
+
|
|
75
|
+
**Names in signatures.** `send`, `stake`, `unstake` and `use_account` take `.boo` names. With witnesses (`BLOCKULA_WITNESSES`: comma-separated URLs of other nodes of the same chain, held to the same pins; `status` lists them), each name is proven against them before it's signed, so a lying node can't redirect it, and a tx's height may not be ahead of the lowest of their heads (each must answer). Every tx's height is also checked against the chain's genesis clock (its start time and block time: built in for a known genesis, else read from the node's `/api/genesis` the first time and remembered with the genesis hash in `known-chains.json`): a node reporting a height the chain can't have reached by this machine's clock gets nothing signed. Without witnesses, `send` signs the name itself: the chain pays whoever owns it when the tx runs, so a lying node can't point it at another account now (the result says `recipient_signed_as` and what the node says the owner is now, unproven), but it could hold the signed tx and release it later, after the name changed hands: pay a name that's about to expire by address. `stake`, `unstake` and `use_account` need an address, so a name is the node's word: the result shows the address and says `UNPROVEN`, and on `blockula-1` (which a node is used on only with `BLOCKULA_CHAIN_ID=blockula-1`) nothing is signed until the user confirms the address (the tool answers `needs_confirmation` with the address; call it again with the address). `use_account` checks the account's own key list, not `/api/key`. A partial `unstake` signs the amount, and the chain converts it to pool shares at its own price when the tx runs, so no node's report of the stake decides how much unbonds; `all` signs every share the node reports (a lie there can only unbond less, or fail on chain).
|
|
49
76
|
|
|
50
77
|
**Votes bind what was shown.** `list_proposals` describes what each proposal does (`does`) from its `kind`, the content a vote binds with its title and link, never from the node's own summary (which no digest covers, so a node could make it say "plant garlic" over a treasury drain), and returns its `proposal_digest`. `vote` requires that digest back: it re-reads the proposal and refuses, signing nothing, if it reads differently now (a node showing one thing to the list and another to the vote). A proposal holding a number past 2^53 has no digest here (`cannot_vote`): vote on it with the `blockula` CLI.
|
|
51
78
|
|
|
52
79
|
**Chain text is untrusted.** Hunt titles, memos, proposal titles and links, key labels and claim notes are written by anyone. Tools return them in fields named `untrusted_*`, cleaned of control and invisible characters, and the server's instructions tell the agent they're data, never instructions. Node JSON is nested (`status`'s `node_reports`, `lookup`'s `data`), so a node can't override what the MCP says about the network.
|
|
53
80
|
|
|
81
|
+
**Programs are read only here.** `program_show` gives a program's record (its code and interface hashes, version, who could upgrade it, what it holds and stores, its methods and the `version_digest` a call binds: recomputed from the code, interface and version, never taken from the node's field, and, with `BLOCKULA_WITNESSES`, built from the proven record: the fields no transaction changes are held to the proof and a node whose reading differs is refused, the storage totals and the balance are the proven ones, and `proven` says exactly what was proven and `node_word_only` what stays the node's word: the methods and their types, the code's size and the names beside addresses), `program_view` runs a `view` method on the node (nothing is signed or paid; its answer is that node's word, and says which node) and `program_code` describes a stored code. No tool signs a program transaction: an agent's calls need the confirmation flow of docs/PROGRAMS.md 11.5 (the user is asked, an allowlist), a later release. Everything a program or its deployer chose is data, never instructions: method names are `untrusted_method`, argument names `untrusted_argument`, a code's exports `untrusted_exports`, what a method returns `untrusted_return_data` (the program's claim, which the chain checks nothing of), a receipt's events and abort messages `untrusted_name` and `untrusted_message`, and the bytes of a receipt's return values and events (`lookup`) `untrusted_data`, summarized past 64 bytes; a failure is summarized in words that quote none of it.
|
|
82
|
+
|
|
83
|
+
## Your own node
|
|
84
|
+
|
|
85
|
+
Point the MCP at a node of a chain you launched (`blockula summon`, docs/LAUNCH.md) or joined (`blockula join`):
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
claude mcp add blockula -e BLOCKULA_RPC=http://127.0.0.1:7171 -- npx -y @blockula/mcp
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
A node on this machine is the `local` network unless you name another; `--network testnet` holds it to `blockula-testnet-2`'s pins (`status` then calls it "your node on blockula-testnet-2"). No chain has a public RPC built in, so names are your node's word until `BLOCKULA_WITNESSES` lists other nodes of the same chain: then they're proven against them, and a height your node reports can't run ahead of theirs.
|
|
92
|
+
|
|
54
93
|
## install_node
|
|
55
94
|
|
|
56
95
|
Downloads the prebuilt `blockula` for this machine from the GitHub release and installs it to `~/.blockula/mcp-bin` (its own path: it never replaces the binary `blockula release install` keeps in `~/.blockula/bin`), after the same checks as `scripts/install.sh` (docs/OPERATIONS.md "Releases"). It installs releases from v0.5.0 on: earlier releases' `checksums.txt` names no tag, so nothing binds them to the version asked for, and it refuses them. (Validators install with `blockula release install` instead, which checks against a pinned release key id.)
|
|
@@ -66,19 +105,21 @@ The checks add up: each refuses a release it fails. The result lists what was ve
|
|
|
66
105
|
|
|
67
106
|
| Env | Default | |
|
|
68
107
|
|---|---|---|
|
|
69
|
-
| `BLOCKULA_NETWORK` | `
|
|
70
|
-
| `BLOCKULA_RPC` |
|
|
71
|
-
| `BLOCKULA_CHAIN_ID` / `BLOCKULA_GENESIS_HASH` | the network's pins | the chain the node must be on |
|
|
72
|
-
| `BLOCKULA_HOME` | `~/.blockula` | the agent key
|
|
108
|
+
| `BLOCKULA_NETWORK` | `local` | `local`, `testnet` or `custom` (same as `--network`) |
|
|
109
|
+
| `BLOCKULA_RPC` | `http://localhost:7070` for `local`; required for `testnet` and `custom` | node URL (no query or fragment) |
|
|
110
|
+
| `BLOCKULA_CHAIN_ID` / `BLOCKULA_GENESIS_HASH` | the network's pins | the chain the node must be on. A node that says it's on `blockula-1` is used only with `BLOCKULA_CHAIN_ID=blockula-1` |
|
|
111
|
+
| `BLOCKULA_HOME` | `~/.blockula` | what the MCP keeps, each file mode 600 but the log: `agent-wallet.json` (the agent key and the account it acts for on each chain), `agent-spend.json` (the spending ledger), `agent-txs.json` (the txs it signed, for `tx_status`), `known-chains.json`, `nonces/` (per account: the last nonce signed, and a `.lock` file while a tx is built and sent, which the session holding it keeps touching; one left by a dead session, or untouched for two minutes, is taken over, or delete it), `grant-<chain id>-<address>.txt` (the grant code `use_account` hands out, one per account that hasn't authorized the key yet), and `dev-node.log` (`start_dev_node`'s log) |
|
|
73
112
|
| `BLOCKULA_MAX_SEND` | `100` | max FANG one call moves: sent, staked, escrowed, paid for a name, bonded (and per vote) |
|
|
74
|
-
| `BLOCKULA_MAX_DAILY` | `BLOCKULA_MAX_SEND` + 1 | max FANG leaving the account per UTC day across all calls, fees and pending, dropped or refused txs included (persisted in `BLOCKULA_HOME`) |
|
|
75
|
-
| `BLOCKULA_WITNESSES` | none | other nodes of the same chain (comma-separated https URLs): names that get signed are proven against them |
|
|
113
|
+
| `BLOCKULA_MAX_DAILY` | `BLOCKULA_MAX_SEND` + 1 | max FANG leaving the account per UTC day on this chain across all calls, fees and pending, dropped or refused txs included (persisted in `BLOCKULA_HOME`) |
|
|
114
|
+
| `BLOCKULA_WITNESSES` | none | other nodes of the same chain (comma-separated https URLs): names that get signed are proven against them. `none` opts out |
|
|
76
115
|
| `BLOCKULA_TIMEOUT_MS` | `15000` | how long one request to the node may take (at most 2147483647) |
|
|
77
116
|
| `BLOCKULA_TX_WAIT_MS` | `15000` | how long a tx tool waits for finality before answering `pending` |
|
|
78
117
|
| `BLOCKULA_BIN` | of `~/.blockula/bin/blockula` (`blockula release install`, checked against the pinned key) and `~/.blockula/mcp-bin/blockula` (`install_node`), the one whose `--version` is this MCP's, the first if both or neither are; else `blockula` | node binary used by `start_dev_node` / `summon_chain` (they say which ran, and when it isn't this MCP's version) |
|
|
79
118
|
| `BLOCKULA_REQUIRE_VERIFY` | unset | `1`: `install_node` refuses a release it can only checksum, and with a release key id requires provenance too |
|
|
80
119
|
| `BLOCKULA_RELEASE_KEY_ID` | built in after the key ceremony | release signing key id to trust (must agree with the built-in one and the pin) |
|
|
81
120
|
|
|
82
|
-
|
|
121
|
+
A bad value in any of them (a cap that isn't an amount, a duration out of range) doesn't stop the server: every tool answers with the configuration error.
|
|
122
|
+
|
|
123
|
+
`npm test -w @blockula/mcp` checks the network rules, the pins, the release checks and the dependency pins offline, and runs the built server against fake nodes (`test/spend.ts`: the spending caps with pending, refused (and a node that refuses but relays), failed, dropped and final txs, name prices, untrusted text, timeouts, unproven names; `test/agent.ts`: the caps, accounts and ledgers per chain and 0.5.1's files, costs, what can be spent, the refusals before signing, nonces under parallel calls and two sessions, your own node's witnesses, a chain without a faucet).
|
|
83
124
|
|
|
84
125
|
⚠️ Early and unaudited: test chains only.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** A key file as it's stored. Unknown fields are kept as they are. */
|
|
2
|
+
export type WalletFile = {
|
|
3
|
+
address: string;
|
|
4
|
+
key_id: string;
|
|
5
|
+
/** The account `use_account` chose, per chain (`<chain_id> <genesis_hash>`). */
|
|
6
|
+
accounts?: Record<string, string>;
|
|
7
|
+
/** 0.5.1's one account for every network: moved into `accounts` by `actingFor`. */
|
|
8
|
+
account?: string;
|
|
9
|
+
[field: string]: unknown;
|
|
10
|
+
};
|
|
11
|
+
/** The key file's 32-byte secret (hex). */
|
|
12
|
+
export declare function secretOf(f: WalletFile): string;
|
|
13
|
+
/** A new key file's contents. */
|
|
14
|
+
export declare function newWalletFile(address: string, keyId: string, secretHex: string): WalletFile;
|
|
15
|
+
export declare function readWalletFile(file: string): WalletFile | null;
|
|
16
|
+
/** Writes the whole file (a temporary file beside it, then a rename), readable by this user only. */
|
|
17
|
+
export declare function writeWalletFile(file: string, f: WalletFile): void;
|
|
18
|
+
/**
|
|
19
|
+
* Writes a new key file, only if there's none yet (a link, which fails if the file exists): two
|
|
20
|
+
* sessions creating a wallet at once can't replace each other's key. The loser reads the winner's.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createWalletFile(file: string, f: WalletFile): void;
|
|
23
|
+
/** Who the agent acts for on one chain. */
|
|
24
|
+
export interface ActingFor {
|
|
25
|
+
/** The account chosen with use_account on this chain; undefined: the key's own address. */
|
|
26
|
+
account?: string;
|
|
27
|
+
/** A 0.5.1 account that belongs to another chain (another prefix): not used here. */
|
|
28
|
+
ignored?: string;
|
|
29
|
+
/** The file changed (a 0.5.1 account moved to this chain): write it back. */
|
|
30
|
+
changed: boolean;
|
|
31
|
+
}
|
|
32
|
+
/** The account the agent acts for on the chain `chain` (`<chain_id> <genesis_hash>`) whose addresses start with `prefix`1. */
|
|
33
|
+
export declare function actingFor(f: WalletFile, chain: string, prefix: string): ActingFor;
|
|
34
|
+
/** Sets (or, with undefined, clears) the account the agent acts for on `chain`. */
|
|
35
|
+
export declare function setActingFor(f: WalletFile, chain: string, account: string | undefined): void;
|
|
36
|
+
/** What wallet_info says about an ignored 0.5.1 account. */
|
|
37
|
+
export declare function ignoredNote(account: string, prefix: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* The web wallet's "Give an agent a budget", by chain id (where the user pastes a grant code), for
|
|
40
|
+
* chains with a public wallet: none since the public testnet was retired.
|
|
41
|
+
*/
|
|
42
|
+
export declare const WALLET_URLS: Record<string, string>;
|
|
43
|
+
/** The short code the web wallet shows for a key (web/lib/format.ts `keyCode`), for the user to compare. */
|
|
44
|
+
export declare const checkCode: (keyId: string) => string;
|
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The agent's key file (`agent-wallet.json`) and the account it acts for, per chain (AGENT-2).
|
|
2
|
+
//
|
|
3
|
+
// - The account `use_account` chose is kept per chain (`accounts`, keyed `<chain_id> <genesis_hash>`).
|
|
4
|
+
// 0.5.1 kept one `account` for every network, so a testnet `tboo1…` account followed the agent to
|
|
5
|
+
// other chains. It moves to the first chain whose prefix it matches; on any other chain it's
|
|
6
|
+
// ignored (wallet_info says so) and left in place for its own chain.
|
|
7
|
+
// - The file is written whole (a temporary file, then a rename) with mode 600, and keeps every
|
|
8
|
+
// field it was read with, so rewriting it can't lose the key.
|
|
9
|
+
import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
import { isAddress } from "@blockula/sdk";
|
|
12
|
+
/** The key file's secret field (written by 0.5.1 and every release since). */
|
|
13
|
+
const SECRET = "seed";
|
|
14
|
+
/** The key file's 32-byte secret (hex). */
|
|
15
|
+
export function secretOf(f) {
|
|
16
|
+
const v = f[SECRET];
|
|
17
|
+
if (typeof v !== "string" || !/^[0-9a-f]{64}$/i.test(v))
|
|
18
|
+
throw new Error("the agent's key file has no valid key; restore it from a backup, or move it aside and call create_wallet");
|
|
19
|
+
return v;
|
|
20
|
+
}
|
|
21
|
+
/** A new key file's contents. */
|
|
22
|
+
export function newWalletFile(address, keyId, secretHex) {
|
|
23
|
+
return { address, key_id: keyId, [SECRET]: secretHex };
|
|
24
|
+
}
|
|
25
|
+
export function readWalletFile(file) {
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = readFileSync(file, "utf8");
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
if (e.code === "ENOENT")
|
|
32
|
+
return null;
|
|
33
|
+
throw e;
|
|
34
|
+
}
|
|
35
|
+
let f;
|
|
36
|
+
try {
|
|
37
|
+
f = JSON.parse(raw);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new Error(`${file} is unreadable (it holds the agent's key): fix it, or restore it from a backup`);
|
|
41
|
+
}
|
|
42
|
+
if (!f || typeof f !== "object" || Array.isArray(f))
|
|
43
|
+
throw new Error(`${file} is malformed (it holds the agent's key): fix it, or restore it from a backup`);
|
|
44
|
+
return f;
|
|
45
|
+
}
|
|
46
|
+
/** Writes the whole file (a temporary file beside it, then a rename), readable by this user only. */
|
|
47
|
+
export function writeWalletFile(file, f) {
|
|
48
|
+
secretOf(f); // never write a file without its key
|
|
49
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
50
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
51
|
+
try {
|
|
52
|
+
writeFileSync(tmp, JSON.stringify(f, null, 2), { mode: 0o600 });
|
|
53
|
+
renameSync(tmp, file);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
rmSync(tmp, { force: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Writes a new key file, only if there's none yet (a link, which fails if the file exists): two
|
|
61
|
+
* sessions creating a wallet at once can't replace each other's key. The loser reads the winner's.
|
|
62
|
+
*/
|
|
63
|
+
export function createWalletFile(file, f) {
|
|
64
|
+
secretOf(f);
|
|
65
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
66
|
+
const tmp = `${file}.${process.pid}.new`;
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(tmp, JSON.stringify(f, null, 2), { mode: 0o600 });
|
|
69
|
+
linkSync(tmp, file);
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
if (e.code !== "EEXIST")
|
|
73
|
+
throw e;
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
rmSync(tmp, { force: true });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** The account the agent acts for on the chain `chain` (`<chain_id> <genesis_hash>`) whose addresses start with `prefix`1. */
|
|
80
|
+
export function actingFor(f, chain, prefix) {
|
|
81
|
+
const chosen = f.accounts?.[chain];
|
|
82
|
+
if (chosen !== undefined) {
|
|
83
|
+
const legacy = typeof f.account === "string" && !isAddress(f.account, prefix) ? { ignored: f.account } : {};
|
|
84
|
+
return isAddress(chosen, prefix) ? { account: chosen, changed: false, ...legacy } : { ignored: chosen, changed: false };
|
|
85
|
+
}
|
|
86
|
+
if (typeof f.account !== "string" || f.account === "")
|
|
87
|
+
return { changed: false };
|
|
88
|
+
if (!isAddress(f.account, prefix))
|
|
89
|
+
return { ignored: f.account, changed: false };
|
|
90
|
+
// 0.5.1's account matches this chain's prefix: it's this chain's from now on.
|
|
91
|
+
f.accounts = { ...(f.accounts ?? {}), [chain]: f.account };
|
|
92
|
+
delete f.account;
|
|
93
|
+
return { account: f.accounts[chain], changed: true };
|
|
94
|
+
}
|
|
95
|
+
/** Sets (or, with undefined, clears) the account the agent acts for on `chain`. */
|
|
96
|
+
export function setActingFor(f, chain, account) {
|
|
97
|
+
const accounts = { ...(f.accounts ?? {}) };
|
|
98
|
+
if (account === undefined)
|
|
99
|
+
delete accounts[chain];
|
|
100
|
+
else
|
|
101
|
+
accounts[chain] = account;
|
|
102
|
+
f.accounts = accounts;
|
|
103
|
+
}
|
|
104
|
+
/** What wallet_info says about an ignored 0.5.1 account. */
|
|
105
|
+
export function ignoredNote(account, prefix) {
|
|
106
|
+
const other = account.startsWith("tboo1") ? "a testnet's" : account.startsWith("boo1") ? "a boo1… chain's" : "another chain's";
|
|
107
|
+
return `This agent used to act for ${account}, ${other} account, which isn't an address on this chain (its addresses start with ${prefix}1). Here it acts for its own key until use_account is called with this chain's account.`;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The web wallet's "Give an agent a budget", by chain id (where the user pastes a grant code), for
|
|
111
|
+
* chains with a public wallet: none since the public testnet was retired.
|
|
112
|
+
*/
|
|
113
|
+
export const WALLET_URLS = {};
|
|
114
|
+
/** The short code the web wallet shows for a key (web/lib/format.ts `keyCode`), for the user to compare. */
|
|
115
|
+
export const checkCode = (keyId) => {
|
|
116
|
+
const k = keyId.toUpperCase();
|
|
117
|
+
return `${k.slice(0, 4)}-${k.slice(4, 8)}-${k.slice(-4)}`;
|
|
118
|
+
};
|
package/dist/budget.d.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default per-call cap; the daily cap is it plus DAILY_FEE_ALLOWANCE (review-fix-clients-3).
|
|
3
|
+
* The caps guard against mistakes. A compromised agent can read its own key file, so only the
|
|
4
|
+
* session key's on-chain limit protects the user's FANG from one.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DEFAULT_MAX_SEND = "100";
|
|
7
|
+
/** Fees count toward the daily cap: by default it's the per-call cap plus this, so one full-size call fits. */
|
|
8
|
+
export declare const DAILY_FEE_ALLOWANCE = "1";
|
|
9
|
+
/** The spending caps in force. */
|
|
10
|
+
export interface Caps {
|
|
11
|
+
maxSend: bigint;
|
|
12
|
+
maxDaily: bigint;
|
|
13
|
+
/** How a refusal names them. */
|
|
14
|
+
sendText: string;
|
|
15
|
+
dailyText: string;
|
|
16
|
+
}
|
|
17
|
+
/** The caps from the environment. Throws on a bad value. */
|
|
18
|
+
export declare function capsFrom(e: Record<string, string | undefined>): Caps;
|
|
19
|
+
/** A duration in milliseconds from the environment: whole and within a timer's range. */
|
|
20
|
+
export declare function msFrom(e: Record<string, string | undefined>, name: string, fallback: number): number;
|
|
21
|
+
/**
|
|
22
|
+
* The configuration error in these variables, if any: every cap and duration is parsed once at
|
|
23
|
+
* startup, inside a try, and the server reports the error through every tool (as it does a bad
|
|
24
|
+
* network) instead of exiting.
|
|
25
|
+
*/
|
|
26
|
+
export declare function configError(e: Record<string, string | undefined>): Error | null;
|
|
27
|
+
/** An amount the agent passes: FANG with up to 9 decimals; a trailing " FANG" is allowed (AGENT-14). */
|
|
28
|
+
export declare function amountArg(s: string, what?: string): bigint;
|
|
29
|
+
/** About how long `blocks` blocks take at the chain's block time, e.g. "about 23 h". */
|
|
30
|
+
export declare function aboutBlocks(blocks: number, blockTimeMs?: number): string;
|
|
31
|
+
/** A drops amount the node reports (a digit string, or a safe whole number), or undefined. */
|
|
32
|
+
export declare const dropsOf: (v: unknown) => bigint | undefined;
|
|
33
|
+
/** A key on an account, as `/api/account` lists it. */
|
|
34
|
+
export interface KeyEntry {
|
|
35
|
+
key_id?: unknown;
|
|
36
|
+
label?: unknown;
|
|
37
|
+
role?: {
|
|
38
|
+
type?: unknown;
|
|
39
|
+
limit?: unknown;
|
|
40
|
+
period_blocks?: unknown;
|
|
41
|
+
expires_at?: unknown;
|
|
42
|
+
};
|
|
43
|
+
spent_this_period?: unknown;
|
|
44
|
+
window_start?: unknown;
|
|
45
|
+
}
|
|
46
|
+
/** A session key's allowance as of the block a tx can run in first. */
|
|
47
|
+
export interface Allowance {
|
|
48
|
+
limit: bigint;
|
|
49
|
+
/** Spent in the window that block falls in (0 once the window restarts). */
|
|
50
|
+
spent: bigint;
|
|
51
|
+
left: bigint;
|
|
52
|
+
periodBlocks: number;
|
|
53
|
+
/** When the window restarts, if anything's spent in it. */
|
|
54
|
+
resetsAt?: number;
|
|
55
|
+
expiresAt: number;
|
|
56
|
+
expired: boolean;
|
|
57
|
+
}
|
|
58
|
+
/** The node's session-key rule (node/src/chain.rs `admit`): spent + amount + charge ≤ limit, the window restarting `period_blocks` after it began. */
|
|
59
|
+
export declare function allowanceOf(key: KeyEntry | undefined, height: number): Allowance | null;
|
|
60
|
+
/** The key with this id on an account (`/api/account`'s `keys`), or undefined when it isn't there; null when the node doesn't list keys. */
|
|
61
|
+
export declare function keyOn(account: {
|
|
62
|
+
keys?: unknown;
|
|
63
|
+
} | null | undefined, keyId: string): KeyEntry | undefined | null;
|
|
64
|
+
/** Everything the checks before signing need: reads the MCP makes anyway, and the tx's cost. */
|
|
65
|
+
export interface Preflight {
|
|
66
|
+
/** `/api/account` for the account the tx acts for (null: the node didn't answer). */
|
|
67
|
+
account: any;
|
|
68
|
+
keyId: string;
|
|
69
|
+
actingFor: string;
|
|
70
|
+
action: string;
|
|
71
|
+
/**
|
|
72
|
+
* FANG the action takes out of what the account can move: sent, a name's price, escrowed, a
|
|
73
|
+
* bond. Not a stake (locked FANG can be staked) or a vote (it locks, it doesn't spend).
|
|
74
|
+
*/
|
|
75
|
+
outflow: bigint;
|
|
76
|
+
/** What opening the recipient's account burns on top. */
|
|
77
|
+
opening: bigint;
|
|
78
|
+
/** The tx's fee (what it can be charged at most). */
|
|
79
|
+
fee: bigint;
|
|
80
|
+
/** The first block the tx can run in (the node's height + 1). */
|
|
81
|
+
height: number;
|
|
82
|
+
blockTimeMs?: number;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Throws when the chain would refuse the tx for something the node's reads already show (AGENT-6).
|
|
86
|
+
* A field the node doesn't report isn't checked: the chain still is.
|
|
87
|
+
*/
|
|
88
|
+
export declare function preflight(p: Preflight): void;
|
|
89
|
+
/**
|
|
90
|
+
* What can be spent now, before fees, and what limits it: the account's transferable FANG, the
|
|
91
|
+
* session key's allowance left this window, the MCP's daily cap left and its per-call cap.
|
|
92
|
+
*/
|
|
93
|
+
export declare function spendableNow(o: {
|
|
94
|
+
transferable?: bigint;
|
|
95
|
+
allowance?: Allowance | null;
|
|
96
|
+
height: number;
|
|
97
|
+
blockTimeMs?: number;
|
|
98
|
+
dailyLeft: bigint;
|
|
99
|
+
caps: Caps;
|
|
100
|
+
}): {
|
|
101
|
+
amount: bigint;
|
|
102
|
+
limitedBy: string;
|
|
103
|
+
limits: string[];
|
|
104
|
+
};
|
package/dist/budget.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// What the agent may spend, and the checks made before anything is signed.
|
|
2
|
+
//
|
|
3
|
+
// - Caps (AGENT-19, AGENT-15): BLOCKULA_MAX_SEND per call and BLOCKULA_MAX_DAILY per UTC day.
|
|
4
|
+
// Unset, they're 100 and 101 FANG. A bad value is a configuration error every tool reports,
|
|
5
|
+
// never a crash at startup.
|
|
6
|
+
// - Refusals before signing (AGENT-6): a session key signing what it can't, or more than its
|
|
7
|
+
// allowance left; a payment past what the account can move; a key that isn't on the account.
|
|
8
|
+
// They read what the node reports (the account, its keys, the fee), so a lying node can only
|
|
9
|
+
// cause a refusal here, never a spend: whatever passes is still checked by the chain, and still
|
|
10
|
+
// reserved against the caps (spend.ts).
|
|
11
|
+
// - What can be spent now (AGENT-7): the smallest of what the account can move, the session key's
|
|
12
|
+
// allowance left this window, and the MCP's caps, with the one that binds.
|
|
13
|
+
import { checkTimeoutMs, formatFang, parseFang } from "@blockula/sdk";
|
|
14
|
+
/**
|
|
15
|
+
* The default per-call cap; the daily cap is it plus DAILY_FEE_ALLOWANCE (review-fix-clients-3).
|
|
16
|
+
* The caps guard against mistakes. A compromised agent can read its own key file, so only the
|
|
17
|
+
* session key's on-chain limit protects the user's FANG from one.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_MAX_SEND = "100";
|
|
20
|
+
/** Fees count toward the daily cap: by default it's the per-call cap plus this, so one full-size call fits. */
|
|
21
|
+
export const DAILY_FEE_ALLOWANCE = "1";
|
|
22
|
+
const env = (e, name) => {
|
|
23
|
+
const v = e[name]?.trim();
|
|
24
|
+
return v === undefined || v === "" ? undefined : v;
|
|
25
|
+
};
|
|
26
|
+
const fangEnv = (name, v) => {
|
|
27
|
+
try {
|
|
28
|
+
return parseFang(v);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error(`${name} must be an amount of FANG with up to 9 decimals, like "10" (got "${v.slice(0, 40)}")`);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
/** The caps from the environment. Throws on a bad value. */
|
|
35
|
+
export function capsFrom(e) {
|
|
36
|
+
const send = env(e, "BLOCKULA_MAX_SEND");
|
|
37
|
+
const daily = env(e, "BLOCKULA_MAX_DAILY");
|
|
38
|
+
const maxSend = fangEnv("BLOCKULA_MAX_SEND", send ?? DEFAULT_MAX_SEND);
|
|
39
|
+
const maxDaily = daily !== undefined ? fangEnv("BLOCKULA_MAX_DAILY", daily) : maxSend + parseFang(DAILY_FEE_ALLOWANCE);
|
|
40
|
+
const sendText = send !== undefined ? `BLOCKULA_MAX_SEND=${send} FANG` : `BLOCKULA_MAX_SEND, by default ${formatFang(maxSend)}`;
|
|
41
|
+
const dailyText = daily !== undefined ? `BLOCKULA_MAX_DAILY=${daily} FANG` : `BLOCKULA_MAX_DAILY, by default BLOCKULA_MAX_SEND + ${DAILY_FEE_ALLOWANCE} FANG for fees = ${formatFang(maxDaily)}`;
|
|
42
|
+
return { maxSend, maxDaily, sendText, dailyText };
|
|
43
|
+
}
|
|
44
|
+
/** A duration in milliseconds from the environment: whole and within a timer's range. */
|
|
45
|
+
export function msFrom(e, name, fallback) {
|
|
46
|
+
const v = env(e, name);
|
|
47
|
+
if (v === undefined)
|
|
48
|
+
return fallback;
|
|
49
|
+
if (!/^\d+$/.test(v))
|
|
50
|
+
throw new Error(`${name} must be a positive number of milliseconds`);
|
|
51
|
+
return checkTimeoutMs(Number(v), name);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The configuration error in these variables, if any: every cap and duration is parsed once at
|
|
55
|
+
* startup, inside a try, and the server reports the error through every tool (as it does a bad
|
|
56
|
+
* network) instead of exiting.
|
|
57
|
+
*/
|
|
58
|
+
export function configError(e) {
|
|
59
|
+
try {
|
|
60
|
+
capsFrom(e);
|
|
61
|
+
msFrom(e, "BLOCKULA_TIMEOUT_MS", 15_000);
|
|
62
|
+
msFrom(e, "BLOCKULA_TX_WAIT_MS", 15_000);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
return err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** An amount the agent passes: FANG with up to 9 decimals; a trailing " FANG" is allowed (AGENT-14). */
|
|
70
|
+
export function amountArg(s, what = "amount") {
|
|
71
|
+
const v = s.trim().replace(/\s*FANG$/i, "");
|
|
72
|
+
try {
|
|
73
|
+
return parseFang(v);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new Error(`${what} must be a number in FANG with up to 9 decimals, like "1.5" (got "${s.slice(0, 40)}")`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** About how long `blocks` blocks take at the chain's block time, e.g. "about 23 h". */
|
|
80
|
+
export function aboutBlocks(blocks, blockTimeMs = 1000) {
|
|
81
|
+
const s = Math.max(0, blocks) * (blockTimeMs / 1000);
|
|
82
|
+
if (s < 90)
|
|
83
|
+
return `about ${Math.round(s)} s`;
|
|
84
|
+
if (s < 5400)
|
|
85
|
+
return `about ${Math.round(s / 60)} min`;
|
|
86
|
+
if (s < 172_800)
|
|
87
|
+
return `about ${Math.round(s / 3600)} h`;
|
|
88
|
+
return `about ${Math.round(s / 86_400)} days`;
|
|
89
|
+
}
|
|
90
|
+
/** A drops amount the node reports (a digit string, or a safe whole number), or undefined. */
|
|
91
|
+
export const dropsOf = (v) => (typeof v === "string" && /^\d+$/.test(v) ? BigInt(v) : typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? BigInt(v) : undefined);
|
|
92
|
+
const digits = dropsOf;
|
|
93
|
+
const whole = (v) => (Number.isSafeInteger(v) && v >= 0 ? v : undefined);
|
|
94
|
+
/** The node's session-key rule (node/src/chain.rs `admit`): spent + amount + charge ≤ limit, the window restarting `period_blocks` after it began. */
|
|
95
|
+
export function allowanceOf(key, height) {
|
|
96
|
+
if (key?.role?.type !== "session")
|
|
97
|
+
return null;
|
|
98
|
+
const limit = digits(key.role.limit);
|
|
99
|
+
const periodBlocks = whole(key.role.period_blocks);
|
|
100
|
+
const expiresAt = whole(key.role.expires_at);
|
|
101
|
+
if (limit === undefined || periodBlocks === undefined || expiresAt === undefined)
|
|
102
|
+
return null;
|
|
103
|
+
const windowStart = whole(key.window_start) ?? 0;
|
|
104
|
+
const restarts = height >= windowStart + periodBlocks;
|
|
105
|
+
const spent = restarts ? 0n : (digits(key.spent_this_period) ?? 0n);
|
|
106
|
+
return { limit, spent, left: limit > spent ? limit - spent : 0n, periodBlocks, ...(spent > 0n ? { resetsAt: windowStart + periodBlocks } : {}), expiresAt, expired: height > expiresAt };
|
|
107
|
+
}
|
|
108
|
+
/** The key with this id on an account (`/api/account`'s `keys`), or undefined when it isn't there; null when the node doesn't list keys. */
|
|
109
|
+
export function keyOn(account, keyId) {
|
|
110
|
+
if (!account || !Array.isArray(account.keys))
|
|
111
|
+
return null;
|
|
112
|
+
return account.keys.find((k) => k?.key_id === keyId);
|
|
113
|
+
}
|
|
114
|
+
const REFUSED = "Refused before signing: nothing was signed or sent, and nothing counts toward today's limit.";
|
|
115
|
+
/**
|
|
116
|
+
* Throws when the chain would refuse the tx for something the node's reads already show (AGENT-6).
|
|
117
|
+
* A field the node doesn't report isn't checked: the chain still is.
|
|
118
|
+
*/
|
|
119
|
+
export function preflight(p) {
|
|
120
|
+
const short = (a) => (a.length > 20 ? `${a.slice(0, 12)}…${a.slice(-6)}` : a);
|
|
121
|
+
const key = keyOn(p.account, p.keyId);
|
|
122
|
+
if (key === undefined) {
|
|
123
|
+
throw new Error(`${REFUSED} This agent's key (${checkCodeOf(p.keyId)}) isn't on ${p.actingFor}: it was removed, or never added. Call use_account with the account to get a new grant code for the user, or use_account "self" to act for the key's own address.`);
|
|
124
|
+
}
|
|
125
|
+
const allowance = allowanceOf(key ?? undefined, p.height);
|
|
126
|
+
if (key && allowance) {
|
|
127
|
+
if (allowance.expired)
|
|
128
|
+
throw new Error(`${REFUSED} This agent's session key on ${short(p.actingFor)} expired at block ${allowance.expiresAt}. Ask the user for a new budget: call use_account with the account for a new grant code.`);
|
|
129
|
+
const familiar = Array.isArray(p.account?.familiars) && p.account.familiars.some((f) => f?.key_id === p.keyId);
|
|
130
|
+
const allowed = p.action === "transfer" || (p.action === "submit_claim" && familiar);
|
|
131
|
+
if (!allowed) {
|
|
132
|
+
const what = p.action === "submit_claim"
|
|
133
|
+
? "Only a session key the user registered as a Familiar can claim contributions: ask them to register it in their wallet (Programs → Accounts → Register a Familiar)."
|
|
134
|
+
: p.action === "link_github"
|
|
135
|
+
? "Linking GitHub needs an owner key: ask the user to link GitHub in their wallet."
|
|
136
|
+
: "Ask the user to do it in their wallet, with an owner key.";
|
|
137
|
+
throw new Error(`${REFUSED} This agent acts for ${short(p.actingFor)} through a session key, and session keys can only send transfers (and claims, for registered Familiars). ${what}`);
|
|
138
|
+
}
|
|
139
|
+
// A transfer counts what it sends and what opening the recipient burns; a claim its bond; both the fee.
|
|
140
|
+
const need = p.outflow + p.opening + p.fee;
|
|
141
|
+
if (need > allowance.left) {
|
|
142
|
+
const resets = allowance.resetsAt !== undefined ? ` It resets at block ${allowance.resetsAt} (${aboutBlocks(allowance.resetsAt - p.height, p.blockTimeMs)}).` : "";
|
|
143
|
+
throw new Error(`${REFUSED} This needs ${formatFang(need)} with the fee${p.opening > 0n ? ` and the account-opening burn (${formatFang(p.opening)})` : ""}, but the session key has ${formatFang(allowance.left)} of its ${formatFang(allowance.limit)} allowance left this window.${resets} Send less, or ask the user to raise the limit.`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const transferable = digits(p.account?.transferable);
|
|
147
|
+
if (p.outflow > 0n && transferable !== undefined && p.outflow + p.opening + p.fee > transferable) {
|
|
148
|
+
const locked = digits(p.account?.locked) ?? 0n;
|
|
149
|
+
throw new Error(`${REFUSED} This needs ${formatFang(p.outflow + p.opening + p.fee)} with the fee${p.opening > 0n ? ` and the account-opening burn (${formatFang(p.opening)})` : ""}, but ${short(p.actingFor)} can move ${formatFang(transferable)} now${locked > 0n ? ` (${formatFang(locked)} more is locked: wallet_info shows until when)` : ""}.`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const checkCodeOf = (keyId) => (typeof keyId === "string" && keyId.length >= 12 ? `${keyId.slice(0, 4).toUpperCase()}-${keyId.slice(4, 8).toUpperCase()}-${keyId.slice(-4).toUpperCase()}` : keyId);
|
|
153
|
+
/**
|
|
154
|
+
* What can be spent now, before fees, and what limits it: the account's transferable FANG, the
|
|
155
|
+
* session key's allowance left this window, the MCP's daily cap left and its per-call cap.
|
|
156
|
+
*/
|
|
157
|
+
export function spendableNow(o) {
|
|
158
|
+
const limits = [];
|
|
159
|
+
if (o.transferable !== undefined)
|
|
160
|
+
limits.push({ amount: o.transferable, why: `the account can move ${formatFang(o.transferable)} now` });
|
|
161
|
+
if (o.allowance) {
|
|
162
|
+
const resets = o.allowance.resetsAt !== undefined ? ` until block ${o.allowance.resetsAt} (${aboutBlocks(o.allowance.resetsAt - o.height, o.blockTimeMs)})` : "";
|
|
163
|
+
limits.push({ amount: o.allowance.expired ? 0n : o.allowance.left, why: o.allowance.expired ? `the session key expired at block ${o.allowance.expiresAt}` : `the session key has ${formatFang(o.allowance.left)} of ${formatFang(o.allowance.limit)} left${resets}` });
|
|
164
|
+
}
|
|
165
|
+
limits.push({ amount: o.dailyLeft, why: `this MCP's daily cap has ${formatFang(o.dailyLeft)} of ${formatFang(o.caps.maxDaily)} left today (UTC)` });
|
|
166
|
+
limits.push({ amount: o.caps.maxSend, why: `this MCP's per-call cap is ${formatFang(o.caps.maxSend)}` });
|
|
167
|
+
const binding = limits.reduce((a, b) => (b.amount < a.amount ? b : a));
|
|
168
|
+
return { amount: binding.amount, limitedBy: binding.why, limits: limits.map((l) => l.why) };
|
|
169
|
+
}
|