@floatonnear/mcp 0.10.1 → 0.10.3
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/SKILL.md +144 -14
- package/dist/agent-key.d.ts +50 -0
- package/dist/agent-key.js +68 -2
- package/dist/create.d.ts +10 -0
- package/dist/create.js +23 -1
- package/dist/explain.d.ts +33 -0
- package/dist/explain.js +55 -0
- package/dist/keyring-path.d.ts +36 -0
- package/dist/keyring-path.js +117 -0
- package/dist/payloads.d.ts +10 -8
- package/dist/payloads.js +92 -23
- package/dist/roles.d.ts +29 -0
- package/dist/roles.js +49 -0
- package/dist/server.js +562 -85
- package/dist/token-meta.d.ts +16 -0
- package/dist/token-meta.js +18 -0
- package/package.json +1 -1
package/SKILL.md
CHANGED
|
@@ -30,8 +30,101 @@ the agent itself can change. Float gives it a **function-call key restricted to
|
|
|
30
30
|
one method on one contract**, and puts the limits in a contract the key cannot
|
|
31
31
|
reach.
|
|
32
32
|
|
|
33
|
-
Read the
|
|
34
|
-
of your requests
|
|
33
|
+
**Read the two sections below before doing anything.** The first decides how you
|
|
34
|
+
speak to the person you are helping; the second decides which of your requests
|
|
35
|
+
can succeed at all.
|
|
36
|
+
|
|
37
|
+
## How you talk to the human (non-negotiable)
|
|
38
|
+
|
|
39
|
+
**You are the product voice. The human is not debugging Float.** They asked for
|
|
40
|
+
something to be paid, or set up, or limited. Everything below exists because
|
|
41
|
+
the natural failure mode of a coding agent is to narrate the machine — rails,
|
|
42
|
+
gas keys, reason codes, env vars — to someone who wanted one sentence.
|
|
43
|
+
|
|
44
|
+
**Always:**
|
|
45
|
+
|
|
46
|
+
- **2–4 short sentences** for a normal outcome. Not a report.
|
|
47
|
+
- **Lead with the outcome**: paid / blocked / needs your approval / needs setup.
|
|
48
|
+
- **Say whether money moved.** Yes or no, explicitly. It is the one fact a
|
|
49
|
+
person cannot infer and must never have to guess.
|
|
50
|
+
- **One clear next step** when something is blocked — not a menu.
|
|
51
|
+
- Everyday words: limit, budget, payment, wallet, *approve in your wallet*.
|
|
52
|
+
|
|
53
|
+
**Never — unless they explicitly ask for technical detail, or are plainly a
|
|
54
|
+
developer debugging:**
|
|
55
|
+
|
|
56
|
+
- reason codes (`PARENT_PER_TX_LIMIT`, `COOLDOWN`, …)
|
|
57
|
+
- tool names (`vault_pay`, `vault_add_role`, `vault_plan_set_caps`)
|
|
58
|
+
- env vars, key files, keyrings, RPC urls, gas keys, nonces, wasm, yoctoNEAR,
|
|
59
|
+
base units, node ids
|
|
60
|
+
- a setup essay in the middle of a task
|
|
61
|
+
- several options when one sensible default exists
|
|
62
|
+
|
|
63
|
+
### Use the tool's own words
|
|
64
|
+
|
|
65
|
+
Tools return the user-facing sentence already built, with the real numbers in
|
|
66
|
+
it:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"say": "Sent 0.25 USDC to test015.near. Money moved.",
|
|
71
|
+
"next_step": null,
|
|
72
|
+
"money_moved": true,
|
|
73
|
+
"technical": { "status": "SETTLED", "reason_code": null }
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Relay `say`. Add `next_step` if it is not null. Stop.** Do not re-derive a
|
|
78
|
+
more technical story out of `technical` — those fields are for you, and for the
|
|
79
|
+
moment someone asks *why*.
|
|
80
|
+
|
|
81
|
+
`say` is built where the numbers live, which is why it can name the cap that
|
|
82
|
+
bound and what is left today. A sentence you compose from `status` and
|
|
83
|
+
`reason_code` alone cannot, because the payment response never carried them.
|
|
84
|
+
|
|
85
|
+
### Templates
|
|
86
|
+
|
|
87
|
+
**Paid:**
|
|
88
|
+
|
|
89
|
+
> Sent **0.25 USDC** to test015.near. Money moved. About **24.75 USDC** left in
|
|
90
|
+
> today's budget.
|
|
91
|
+
|
|
92
|
+
**Blocked, over a limit:**
|
|
93
|
+
|
|
94
|
+
> That payment was **too big** — the most this budget allows per payment is
|
|
95
|
+
> **10 USDC**, and this asked for 15. **Nothing was sent.** Try 10 USDC or
|
|
96
|
+
> less, or raise the limit (I'll give you something to approve in your wallet).
|
|
97
|
+
|
|
98
|
+
**Needs a human signature:**
|
|
99
|
+
|
|
100
|
+
> I can't change limits myself — only you can. Here's the change to **approve
|
|
101
|
+
> in your wallet**. Tell me once you have and I'll confirm it's live.
|
|
102
|
+
|
|
103
|
+
**No vault yet:**
|
|
104
|
+
|
|
105
|
+
> You don't have a vault yet. Setting one up is **one approval in your wallet**
|
|
106
|
+
> and about **3.7 NEAR** — almost all of it a refundable deposit your vault
|
|
107
|
+
> holds, not a fee. Want me to start?
|
|
108
|
+
|
|
109
|
+
**Not enough money:**
|
|
110
|
+
|
|
111
|
+
> The vault doesn't have enough USDC for that. **Nothing was sent.** Add USDC to
|
|
112
|
+
> the vault and ask me again.
|
|
113
|
+
|
|
114
|
+
### Worked examples, before and after
|
|
115
|
+
|
|
116
|
+
| situation | ❌ what not to say | ✅ what to say |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| settled | `status: SETTLED, node_id: 1` | Sent 0.25 USDC to test015.near. Money moved. 24.75 USDC left today. |
|
|
119
|
+
| over cap | "BLOCKED with reason_code PARENT_PER_TX_LIMIT" | That's too big for one payment — the limit is 10 USDC and this asked for 15. Nothing was sent. |
|
|
120
|
+
| policy change | "Returns an unsigned payload; the server does not sign. Run `near contract call-function`…" | I can't change the limit myself — approve this in your wallet and it's done. |
|
|
121
|
+
| new agent | "Generated an ed25519 keypair, wrote it to the keyring; the 0.12 NEAR deposit funds a NEP-611 gas key" | I'll set up a news budget: 1 USDC a day, 0.10 max per payment. Approve once in your wallet and I can pay within those limits. |
|
|
122
|
+
| no vault | "`NEAR_VAULT_CONTRACT` is not set" | You don't have a vault yet — want me to set one up? |
|
|
123
|
+
|
|
124
|
+
A person who asked to pay a vendor and got `PARENT_PER_TX_LIMIT` has been told
|
|
125
|
+
nothing they can act on. The codes are precise, and that precision is a real
|
|
126
|
+
asset for debugging and for explaining the design. It is just never the first
|
|
127
|
+
thing a person wants.
|
|
35
128
|
|
|
36
129
|
## Setup — do this first if you arrived here cold
|
|
37
130
|
|
|
@@ -151,11 +244,49 @@ key on the machine, which is the thing above.
|
|
|
151
244
|
Your key's `method_names` is `["request_payment"]` and nothing else.
|
|
152
245
|
- attach NEAR to anything. A NEAR function-call key cannot carry a deposit.
|
|
153
246
|
- **fund the vault.** That needs the owner's tokens and the owner's key. If the
|
|
154
|
-
vault is empty, say so and ask the human — do not try to fix it yourself
|
|
247
|
+
vault is empty, say so and ask the human — do not try to fix it yourself, and
|
|
248
|
+
send them to their WALLET, never to a CLI. See below.
|
|
155
249
|
|
|
156
250
|
If a request needs one of those, produce a plan with `vault_plan_*` and hand it
|
|
157
251
|
to the human. Do not attempt it and report the error.
|
|
158
252
|
|
|
253
|
+
### Funding a vault — the wallet, always, never the CLI
|
|
254
|
+
|
|
255
|
+
Someone has to put tokens in. **Tell them to send from their own wallet**, name
|
|
256
|
+
the three fields, and stop:
|
|
257
|
+
|
|
258
|
+
> Open your wallet as `<owner>` and send `<amount>` `<TOKEN>` to `<vault>`.
|
|
259
|
+
> Check the token's CONTRACT ID, not just the ticker — `<contract>`.
|
|
260
|
+
|
|
261
|
+
**Do NOT hand over an `ft_transfer` command, and do not offer to run one.**
|
|
262
|
+
Measured 2026-11-13: asked to fund a vault, an agent found a full-access key in
|
|
263
|
+
`~/.near-credentials` and offered `near call … ft_transfer` as a co-equal option
|
|
264
|
+
beside the wallet. The user had to correct it. A token send is the one operation
|
|
265
|
+
every wallet already does well — a button, with the balance, the ticker and the
|
|
266
|
+
destination on screen before anything is signed. Replacing that with base-unit
|
|
267
|
+
arithmetic (`"amount":"1000000"`), a `--depositYocto 1` the user cannot
|
|
268
|
+
sanity-check, and a key read off disk is worse precisely when they are deciding
|
|
269
|
+
whether to trust this thing with money.
|
|
270
|
+
|
|
271
|
+
It also contradicts the rest of this file. Creation already refuses the CLI for
|
|
272
|
+
anyone being onboarded — *"never offer to run the CLI on someone else's
|
|
273
|
+
behalf"* — and funding is the same situation with the same answer. The wizard's
|
|
274
|
+
`--fund` flag belongs to an operator on their own machine with their own key; it
|
|
275
|
+
is not a funding path to offer a user.
|
|
276
|
+
|
|
277
|
+
Two things to say in the same message, because both fail SILENTLY:
|
|
278
|
+
|
|
279
|
+
- **the token contract, not the ticker.** Caps are keyed per token. NEAR has a
|
|
280
|
+
native USDC and an older bridged `USDC.e` — send the wrong one and it arrives,
|
|
281
|
+
shows as a balance, and the agent still has zero budget for it. The refusal
|
|
282
|
+
reads as a broken vault.
|
|
283
|
+
- **the payee allowlist.** Read it with `vault_tree` first. If it still holds
|
|
284
|
+
only the owner, the agent can pay nobody but the owner, and funding past a
|
|
285
|
+
test amount buys nothing yet.
|
|
286
|
+
|
|
287
|
+
Then verify instead of taking their word for it: read the vault's
|
|
288
|
+
`ft_balance_of` and tell them what actually landed.
|
|
289
|
+
|
|
159
290
|
## Daily use — the MCP tools
|
|
160
291
|
|
|
161
292
|
If `float` is configured in your MCP client, you have these. Read before you
|
|
@@ -269,10 +400,13 @@ A refusal comes back as a **value**, not an error. `status: "BLOCKED"` with a
|
|
|
269
400
|
|
|
270
401
|
### Never show a reason code to the user
|
|
271
402
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
403
|
+
This is governed by **How you talk to the human** near the top of this file —
|
|
404
|
+
one source of truth, so the two cannot drift apart. The short form:
|
|
405
|
+
|
|
406
|
+
**`vault_pay` returns `say`, `next_step` and `money_moved`. Relay `say`, add
|
|
407
|
+
`next_step` if it is not null, and stop.** The `reason_code` lives under
|
|
408
|
+
`technical`; quote it only if the user asks why, asks for detail, or is plainly
|
|
409
|
+
a developer.
|
|
276
410
|
|
|
277
411
|
> Say: *"That's too big for one payment — the most per payment is 10 USDC, and
|
|
278
412
|
> this asked for 15. Nothing was sent. Send 10 USDC or less, or ask the vault
|
|
@@ -280,13 +414,9 @@ asks for detail, or is plainly a developer.
|
|
|
280
414
|
>
|
|
281
415
|
> Not: *"The payment was BLOCKED with reason_code PARENT_PER_TX_LIMIT."*
|
|
282
416
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
Someone who asked to pay a vendor and got `PARENT_PER_TX_LIMIT` has been told
|
|
287
|
-
nothing they can act on. The codes are precise and that precision is a real
|
|
288
|
-
asset for debugging and for explaining the design — it is just not what a
|
|
289
|
-
person wants first.
|
|
417
|
+
**A refusal is still an answer, not a failure.** Report it as the budget doing
|
|
418
|
+
its job. And never imply money moved when `money_moved` is false — a blocked
|
|
419
|
+
payment moved nothing, which is the whole point.
|
|
290
420
|
|
|
291
421
|
### The codes themselves
|
|
292
422
|
|
package/dist/agent-key.d.ts
CHANGED
|
@@ -50,6 +50,22 @@ export interface GeneratedAgentKey {
|
|
|
50
50
|
export declare function generateAgentKey(opts: {
|
|
51
51
|
vault: string;
|
|
52
52
|
dir?: string;
|
|
53
|
+
/**
|
|
54
|
+
* The exact keyring file to write, overriding `dir` and the hardcoded
|
|
55
|
+
* `agent-keys.json` filename.
|
|
56
|
+
*
|
|
57
|
+
* WHY IT HAS TO EXIST. The caller configures a keyring by full PATH
|
|
58
|
+
* (`NEAR_VAULT_AGENT_KEYS`), and this function used to accept only a
|
|
59
|
+
* directory and append a fixed filename. So pinning the keyring to, say,
|
|
60
|
+
* `~/keys/fleet.json` meant the server READ `fleet.json` and WROTE
|
|
61
|
+
* `~/keys/agent-keys.json` — a brand new agent, filed where nothing would
|
|
62
|
+
* ever look for it, reported as success. Silent, and exactly the "written
|
|
63
|
+
* somewhere you are not reading" failure this module is supposed to prevent.
|
|
64
|
+
*
|
|
65
|
+
* Caught by the smoke test that pins a non-default filename, not by a unit
|
|
66
|
+
* test: every unit test here passes a directory.
|
|
67
|
+
*/
|
|
68
|
+
keyringPath?: string;
|
|
53
69
|
/**
|
|
54
70
|
* The node this agent spends from.
|
|
55
71
|
*
|
|
@@ -85,3 +101,37 @@ export declare function agentKeyPaths(dir?: string): {
|
|
|
85
101
|
envPath: string;
|
|
86
102
|
dir: string;
|
|
87
103
|
};
|
|
104
|
+
/**
|
|
105
|
+
* Take a role name off a keyring entry that is PROVEN not bound on chain,
|
|
106
|
+
* without destroying its secret.
|
|
107
|
+
*
|
|
108
|
+
* WHY THIS IS NEEDED. `generateAgentKey` appends and never replaces, for the
|
|
109
|
+
* reason above: overwriting could destroy the only copy of a credential for an
|
|
110
|
+
* agent that is already bound and funded. But `selectRole` resolves a role with
|
|
111
|
+
* `entries.find(...)` — FIRST match wins. So an orphaned entry (a key written
|
|
112
|
+
* by a `vault_add_role` that then failed to land on chain) would permanently
|
|
113
|
+
* SHADOW the replacement: every future payment under that role would select a
|
|
114
|
+
* key the contract answers NOT_VIRTUAL_AGENT for, and the working key beside it
|
|
115
|
+
* would never be reached.
|
|
116
|
+
*
|
|
117
|
+
* So the orphan's role is RENAMED rather than deleted. The secret stays on
|
|
118
|
+
* disk, the entry stays in the file, and the name resolves to exactly one
|
|
119
|
+
* credential again. Deleting would be tidier and is not worth the risk: the
|
|
120
|
+
* caller's proof of deadness is a chain read, and a chain read can be wrong
|
|
121
|
+
* about a vault that is paused, migrating, or being served by a lagging RPC.
|
|
122
|
+
* A renamed entry survives that mistake; a deleted one does not.
|
|
123
|
+
*
|
|
124
|
+
* Identified BY PUBLIC KEY, never by role alone — the whole point is that two
|
|
125
|
+
* entries can share a role at this moment, and renaming the wrong one would
|
|
126
|
+
* retire the live agent.
|
|
127
|
+
*
|
|
128
|
+
* Returns the new role name, or null if no entry matched (already retired, or
|
|
129
|
+
* a keyring that moved under us) — which is not an error.
|
|
130
|
+
*/
|
|
131
|
+
export declare function retireRole(opts: {
|
|
132
|
+
keyringPath: string;
|
|
133
|
+
vault: string;
|
|
134
|
+
publicKey: string;
|
|
135
|
+
}): {
|
|
136
|
+
retiredTo: string;
|
|
137
|
+
} | null;
|
package/dist/agent-key.js
CHANGED
|
@@ -58,11 +58,13 @@ export const AGENT_NODE_ID = 1;
|
|
|
58
58
|
* `NEAR_VAULT_AGENT_ID` is set to a vault id in `.mcp.json`.
|
|
59
59
|
*/
|
|
60
60
|
export function generateAgentKey(opts) {
|
|
61
|
-
const dir = opts.dir ?? ".float";
|
|
62
61
|
// `undefined` -> root (the creation-time agent). `null` -> unknown, and the
|
|
63
62
|
// field is omitted rather than guessed.
|
|
64
63
|
const nodeId = opts.nodeId === null ? undefined : (opts.nodeId ?? AGENT_NODE_ID);
|
|
65
|
-
|
|
64
|
+
// An explicit path wins and decides the directory; otherwise the old
|
|
65
|
+
// dir-plus-fixed-filename behaviour, unchanged.
|
|
66
|
+
const keyringPath = opts.keyringPath ?? join(opts.dir ?? ".float", "agent-keys.json");
|
|
67
|
+
const dir = dirname(keyringPath);
|
|
66
68
|
const envPath = join(dir, "agent.env");
|
|
67
69
|
const kp = KeyPair.fromRandom("ed25519");
|
|
68
70
|
const publicKey = kp.getPublicKey().toString();
|
|
@@ -133,3 +135,67 @@ export function generateAgentKey(opts) {
|
|
|
133
135
|
export function agentKeyPaths(dir = ".float") {
|
|
134
136
|
return { keyringPath: join(dir, "agent-keys.json"), envPath: join(dir, "agent.env"), dir: dirname(join(dir, "x")) };
|
|
135
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Take a role name off a keyring entry that is PROVEN not bound on chain,
|
|
140
|
+
* without destroying its secret.
|
|
141
|
+
*
|
|
142
|
+
* WHY THIS IS NEEDED. `generateAgentKey` appends and never replaces, for the
|
|
143
|
+
* reason above: overwriting could destroy the only copy of a credential for an
|
|
144
|
+
* agent that is already bound and funded. But `selectRole` resolves a role with
|
|
145
|
+
* `entries.find(...)` — FIRST match wins. So an orphaned entry (a key written
|
|
146
|
+
* by a `vault_add_role` that then failed to land on chain) would permanently
|
|
147
|
+
* SHADOW the replacement: every future payment under that role would select a
|
|
148
|
+
* key the contract answers NOT_VIRTUAL_AGENT for, and the working key beside it
|
|
149
|
+
* would never be reached.
|
|
150
|
+
*
|
|
151
|
+
* So the orphan's role is RENAMED rather than deleted. The secret stays on
|
|
152
|
+
* disk, the entry stays in the file, and the name resolves to exactly one
|
|
153
|
+
* credential again. Deleting would be tidier and is not worth the risk: the
|
|
154
|
+
* caller's proof of deadness is a chain read, and a chain read can be wrong
|
|
155
|
+
* about a vault that is paused, migrating, or being served by a lagging RPC.
|
|
156
|
+
* A renamed entry survives that mistake; a deleted one does not.
|
|
157
|
+
*
|
|
158
|
+
* Identified BY PUBLIC KEY, never by role alone — the whole point is that two
|
|
159
|
+
* entries can share a role at this moment, and renaming the wrong one would
|
|
160
|
+
* retire the live agent.
|
|
161
|
+
*
|
|
162
|
+
* Returns the new role name, or null if no entry matched (already retired, or
|
|
163
|
+
* a keyring that moved under us) — which is not an error.
|
|
164
|
+
*/
|
|
165
|
+
export function retireRole(opts) {
|
|
166
|
+
if (!existsSync(opts.keyringPath))
|
|
167
|
+
return null;
|
|
168
|
+
let entries;
|
|
169
|
+
try {
|
|
170
|
+
const parsed = JSON.parse(readFileSync(opts.keyringPath, "utf8"));
|
|
171
|
+
if (!Array.isArray(parsed))
|
|
172
|
+
return null;
|
|
173
|
+
entries = parsed;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// Unreadable is not this function's problem to fix; `generateAgentKey`
|
|
177
|
+
// already backs such a file aside before writing.
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
let retiredTo;
|
|
181
|
+
for (const e of entries) {
|
|
182
|
+
if (!e || typeof e !== "object")
|
|
183
|
+
continue;
|
|
184
|
+
if (e.agentId !== opts.vault)
|
|
185
|
+
continue;
|
|
186
|
+
if (e.publicKey !== opts.publicKey)
|
|
187
|
+
continue;
|
|
188
|
+
if (typeof e.role !== "string")
|
|
189
|
+
continue;
|
|
190
|
+
// A short slice of the public key keeps two retirements of the same role
|
|
191
|
+
// from colliding, and keeps the name traceable back to the exact entry.
|
|
192
|
+
retiredTo = `${e.role}-retired-${opts.publicKey.slice(8, 16)}`;
|
|
193
|
+
e.role = retiredTo;
|
|
194
|
+
// The node id, if any, was never real for an unbound key.
|
|
195
|
+
delete e.nodeId;
|
|
196
|
+
}
|
|
197
|
+
if (!retiredTo)
|
|
198
|
+
return null;
|
|
199
|
+
writeFileSync(opts.keyringPath, `${JSON.stringify(entries, null, 2)}${String.fromCharCode(10)}`);
|
|
200
|
+
return { retiredTo };
|
|
201
|
+
}
|
package/dist/create.d.ts
CHANGED
|
@@ -62,6 +62,16 @@ export declare const STORAGE_COST_PER_BYTE: bigint;
|
|
|
62
62
|
* copy that drifts.
|
|
63
63
|
*/
|
|
64
64
|
export declare const PAGE_HEADROOM_YOCTO: bigint;
|
|
65
|
+
/**
|
|
66
|
+
* NEP-145 registration for ONE token, mirroring the factory's
|
|
67
|
+
* `TOKEN_REGISTRATION` and the vault's `DEPOSIT_ADDRESS_STORAGE`.
|
|
68
|
+
*
|
|
69
|
+
* The factory registers a new vault on every token named in `root_limits`, so
|
|
70
|
+
* a plain `ft_transfer` into it succeeds — before that it failed with "the
|
|
71
|
+
* account is not registered", which broke funding for every browser-created
|
|
72
|
+
* vault. Pinned against the contract by `create.pins.test.ts`.
|
|
73
|
+
*/
|
|
74
|
+
export declare const TOKEN_REGISTRATION_YOCTO: bigint;
|
|
65
75
|
/**
|
|
66
76
|
* Funding for an agent's gas key, when one is bound at creation.
|
|
67
77
|
*
|
package/dist/create.js
CHANGED
|
@@ -62,6 +62,16 @@ export const STORAGE_COST_PER_BYTE = 10n ** 19n;
|
|
|
62
62
|
* copy that drifts.
|
|
63
63
|
*/
|
|
64
64
|
export const PAGE_HEADROOM_YOCTO = 36n * 10n ** 22n;
|
|
65
|
+
/**
|
|
66
|
+
* NEP-145 registration for ONE token, mirroring the factory's
|
|
67
|
+
* `TOKEN_REGISTRATION` and the vault's `DEPOSIT_ADDRESS_STORAGE`.
|
|
68
|
+
*
|
|
69
|
+
* The factory registers a new vault on every token named in `root_limits`, so
|
|
70
|
+
* a plain `ft_transfer` into it succeeds — before that it failed with "the
|
|
71
|
+
* account is not registered", which broke funding for every browser-created
|
|
72
|
+
* vault. Pinned against the contract by `create.pins.test.ts`.
|
|
73
|
+
*/
|
|
74
|
+
export const TOKEN_REGISTRATION_YOCTO = 125n * 10n ** 19n;
|
|
65
75
|
/**
|
|
66
76
|
* Funding for an agent's gas key, when one is bound at creation.
|
|
67
77
|
*
|
|
@@ -258,8 +268,20 @@ export function buildCreatePlan(i) {
|
|
|
258
268
|
// Both floors move by the gas-key funding when an agent is bound: the page
|
|
259
269
|
// attaches it, and the factory requires it.
|
|
260
270
|
const agentFunding = i.agentPk ? AGENT_GAS_KEY_FUNDING_YOCTO : 0n;
|
|
271
|
+
/**
|
|
272
|
+
* The factory also registers the new vault on each token in `root_limits`,
|
|
273
|
+
* so its floor covers that too — otherwise it would fund those receipts from
|
|
274
|
+
* its OWN balance, which is the drain documented in the contract.
|
|
275
|
+
*
|
|
276
|
+
* Counted as ONE because `vault_create` builds a single-token `root_limits`.
|
|
277
|
+
* It does not change the quoted total today: the page's 0.36 headroom keeps
|
|
278
|
+
* `pageAttaches` above the floor either way, and `max` picks the page. It is
|
|
279
|
+
* included so the floor stays correct if an operator raises
|
|
280
|
+
* `required_deposit` and the floor becomes the binding term.
|
|
281
|
+
*/
|
|
282
|
+
const registrations = TOKEN_REGISTRATION_YOCTO;
|
|
261
283
|
const pageAttaches = stake + PAGE_HEADROOM_YOCTO + agentFunding;
|
|
262
|
-
const factoryFloor = BigInt(i.requiredDepositYocto) + stake + agentFunding;
|
|
284
|
+
const factoryFloor = BigInt(i.requiredDepositYocto) + stake + agentFunding + registrations;
|
|
263
285
|
const deposit = pageAttaches > factoryFloor ? pageAttaches : factoryFloor;
|
|
264
286
|
const total = deposit + GAS_RESERVATION_YOCTO;
|
|
265
287
|
// If the operator raises `required_deposit` past the page's headroom, the
|
package/dist/explain.d.ts
CHANGED
|
@@ -57,3 +57,36 @@ export interface Explanation {
|
|
|
57
57
|
moved: boolean;
|
|
58
58
|
}
|
|
59
59
|
export declare function explainOutcome(c: OutcomeContext): Explanation;
|
|
60
|
+
/**
|
|
61
|
+
* The user-facing sentence for an UNSIGNED owner payload.
|
|
62
|
+
*
|
|
63
|
+
* WHY PLAN TOOLS NEEDED THIS TOO. `vault_pay` returned `say`/`next_step`/
|
|
64
|
+
* `money_moved` and the plan tools returned `{action, summary, warnings,
|
|
65
|
+
* unsigned_payload, approve_with}` — five technical keys and no sentence. So
|
|
66
|
+
* agents narrated the mechanism: "returns an unsigned payload; the server does
|
|
67
|
+
* not sign; run `near contract call-function`…". All true, and unusable by the
|
|
68
|
+
* person who asked to raise a limit.
|
|
69
|
+
*
|
|
70
|
+
* THE THREE THINGS A PERSON NEEDS HERE, in order:
|
|
71
|
+
* 1. this needs YOU — the agent cannot do it
|
|
72
|
+
* 2. what it will do, in the units they named
|
|
73
|
+
* 3. nothing has happened yet
|
|
74
|
+
*
|
|
75
|
+
* (3) matters most and is the easiest to leave out. An unsigned payload is a
|
|
76
|
+
* *proposal*; an agent that says "I've raised the limit" has lied about state
|
|
77
|
+
* the user is about to be asked to change themselves.
|
|
78
|
+
*
|
|
79
|
+
* `summary` is reused verbatim rather than rewritten. It is built where the
|
|
80
|
+
* amounts and tokens are known, in the units the caller passed, and
|
|
81
|
+
* duplicating that formatting here is how the two drift into disagreeing about
|
|
82
|
+
* a number. It does still say "node 1" — the honest fix for that is better
|
|
83
|
+
* summaries in `payloads.ts`, not a second renderer guessing at them here.
|
|
84
|
+
*/
|
|
85
|
+
export declare function planVoice(p: {
|
|
86
|
+
summary: string;
|
|
87
|
+
depositYocto?: string;
|
|
88
|
+
warnings?: string[];
|
|
89
|
+
}): {
|
|
90
|
+
say: string;
|
|
91
|
+
next: string;
|
|
92
|
+
};
|
package/dist/explain.js
CHANGED
|
@@ -207,3 +207,58 @@ export function explainOutcome(c) {
|
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* The user-facing sentence for an UNSIGNED owner payload.
|
|
212
|
+
*
|
|
213
|
+
* WHY PLAN TOOLS NEEDED THIS TOO. `vault_pay` returned `say`/`next_step`/
|
|
214
|
+
* `money_moved` and the plan tools returned `{action, summary, warnings,
|
|
215
|
+
* unsigned_payload, approve_with}` — five technical keys and no sentence. So
|
|
216
|
+
* agents narrated the mechanism: "returns an unsigned payload; the server does
|
|
217
|
+
* not sign; run `near contract call-function`…". All true, and unusable by the
|
|
218
|
+
* person who asked to raise a limit.
|
|
219
|
+
*
|
|
220
|
+
* THE THREE THINGS A PERSON NEEDS HERE, in order:
|
|
221
|
+
* 1. this needs YOU — the agent cannot do it
|
|
222
|
+
* 2. what it will do, in the units they named
|
|
223
|
+
* 3. nothing has happened yet
|
|
224
|
+
*
|
|
225
|
+
* (3) matters most and is the easiest to leave out. An unsigned payload is a
|
|
226
|
+
* *proposal*; an agent that says "I've raised the limit" has lied about state
|
|
227
|
+
* the user is about to be asked to change themselves.
|
|
228
|
+
*
|
|
229
|
+
* `summary` is reused verbatim rather than rewritten. It is built where the
|
|
230
|
+
* amounts and tokens are known, in the units the caller passed, and
|
|
231
|
+
* duplicating that formatting here is how the two drift into disagreeing about
|
|
232
|
+
* a number. It does still say "node 1" — the honest fix for that is better
|
|
233
|
+
* summaries in `payloads.ts`, not a second renderer guessing at them here.
|
|
234
|
+
*/
|
|
235
|
+
export function planVoice(p) {
|
|
236
|
+
const bits = [];
|
|
237
|
+
// (1) and (2) together, because "needs you" is meaningless without "to do
|
|
238
|
+
// what". Leading with the actor is what stops the agent claiming the credit.
|
|
239
|
+
bits.push(`This one needs you — I can't make this change myself. ${p.summary}.`);
|
|
240
|
+
/**
|
|
241
|
+
* A DEPOSIT IS STATED, ALWAYS, WHEN THERE IS ONE.
|
|
242
|
+
*
|
|
243
|
+
* 1 yoctoNEAR is the NEP-141 anti-phishing deposit — a rounding error that
|
|
244
|
+
* no person should ever be told about. Anything larger is real money leaving
|
|
245
|
+
* their wallet, and a wallet prompt showing an unexplained NEAR figure is
|
|
246
|
+
* the fastest way to make a safe action look like a scam.
|
|
247
|
+
*/
|
|
248
|
+
const dep = BigInt(p.depositYocto ?? "0");
|
|
249
|
+
if (dep > 1n) {
|
|
250
|
+
const near = (Number(dep) / 1e24).toFixed(3).replace(/\.?0+$/, "");
|
|
251
|
+
bits.push(`It sends ${near} NEAR with it, which the vault keeps — not a fee.`);
|
|
252
|
+
}
|
|
253
|
+
// (3). Said last so it is the thing left ringing.
|
|
254
|
+
bits.push("Nothing has changed yet.");
|
|
255
|
+
// `warnings` is non-empty only for destructive or hard-to-undo actions, so
|
|
256
|
+
// when one exists it outranks brevity.
|
|
257
|
+
const firstWarning = (p.warnings ?? []).find((w) => typeof w === "string" && !!w.trim());
|
|
258
|
+
if (firstWarning)
|
|
259
|
+
bits.push(firstWarning);
|
|
260
|
+
return {
|
|
261
|
+
say: bits.join(" "),
|
|
262
|
+
next: "Approve it in your wallet, then tell me and I'll confirm it went through.",
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare const KEYRING_FILENAME: string;
|
|
2
|
+
export interface ResolvedKeyring {
|
|
3
|
+
/** Absolute path. Never relative — that is the whole point of this module. */
|
|
4
|
+
path: string;
|
|
5
|
+
/** Plain-English reason, safe to show a user. Names no secrets. */
|
|
6
|
+
why: string;
|
|
7
|
+
/**
|
|
8
|
+
* Other keyrings that ALSO hold a credential for this vault and are not
|
|
9
|
+
* being read. Empty in the normal case. Non-empty means a split keyring,
|
|
10
|
+
* which a user needs told rather than silently resolved.
|
|
11
|
+
*/
|
|
12
|
+
alsoFound: string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* How many credentials a keyring file holds for `vault`.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately TOLERANT about the credential field name. `roles.ts` loads only
|
|
18
|
+
* `secret`, but `prove-refusal.mjs` reads `privateKey ?? secret ?? agentKey`,
|
|
19
|
+
* and a file written by one path must still be FOUND by this one — otherwise
|
|
20
|
+
* resolution sends the user to an empty home keyring while their real keys sit
|
|
21
|
+
* in a shape this function refused to count. Finding the file and loading an
|
|
22
|
+
* entry out of it are different jobs; `listUnloadable` reports the second.
|
|
23
|
+
*
|
|
24
|
+
* Never throws, and never returns the contents. A corrupt or unreadable file
|
|
25
|
+
* counts as zero; the caller is choosing a path, not validating a keyring.
|
|
26
|
+
*/
|
|
27
|
+
export declare function countFor(path: string, vault?: string): number;
|
|
28
|
+
export declare function resolveKeyringPath(opts: {
|
|
29
|
+
/** The raw `NEAR_VAULT_AGENT_KEYS` value, if the operator set one. */
|
|
30
|
+
explicit?: string;
|
|
31
|
+
vault?: string;
|
|
32
|
+
/** Injectable so a test does not depend on where it was run from. */
|
|
33
|
+
cwd?: string;
|
|
34
|
+
home?: string;
|
|
35
|
+
count?: (path: string, vault?: string) => number;
|
|
36
|
+
}): ResolvedKeyring;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE THE FLEET KEYRING LIVES — and why it must not be a relative path.
|
|
3
|
+
*
|
|
4
|
+
* THE BUG THIS EXISTS TO KILL. The default was the bare string
|
|
5
|
+
* `.float/agent-keys.json`, which Node resolves against `process.cwd()`. An MCP
|
|
6
|
+
* server's CWD is wherever the CLIENT was launched, which the user does not
|
|
7
|
+
* choose and cannot see. So one single server entry in `~/.claude.json` became
|
|
8
|
+
* a DIFFERENT keyring per directory:
|
|
9
|
+
*
|
|
10
|
+
* launched from New project\ -> New project\.float\agent-keys.json
|
|
11
|
+
* launched from near-vault\ -> near-vault\.float\agent-keys.json
|
|
12
|
+
*
|
|
13
|
+
* Measured 2026-11-13 on this machine: `NEAR_VAULT_AGENT_KEYS` is UNSET for the
|
|
14
|
+
* `float` server, so a `travel` agent key written from one directory was
|
|
15
|
+
* invisible from the other. The tool reported "no agent with role 'travel'"
|
|
16
|
+
* while the key sat on disk a few folders away. Nothing in the output could
|
|
17
|
+
* distinguish "never created" from "created somewhere you are not looking",
|
|
18
|
+
* which is the same shape as the keyring-reader disagreement `roles.ts` already
|
|
19
|
+
* has a comment about.
|
|
20
|
+
*
|
|
21
|
+
* THE RULE. Resolution answers one question: *which file can sign for THIS
|
|
22
|
+
* vault?* Not "which directory am I in".
|
|
23
|
+
*
|
|
24
|
+
* 1. `NEAR_VAULT_AGENT_KEYS` set -> that, made absolute. Explicit wins; an
|
|
25
|
+
* operator who pinned a path gets the path they pinned.
|
|
26
|
+
* 2. Otherwise, of the CWD keyring and the HOME keyring, the one that already
|
|
27
|
+
* holds a credential for this vault. This is the back-compat case: anyone
|
|
28
|
+
* whose keys are in a project-local `.float/` keeps working untouched.
|
|
29
|
+
* 3. Otherwise HOME — `~/.float/agent-keys.json`. New keys land somewhere
|
|
30
|
+
* stable, so the NEXT session finds them whatever directory it starts in.
|
|
31
|
+
*
|
|
32
|
+
* Rule 3 is the actual fix. Rules 1 and 2 are there so turning it on cannot
|
|
33
|
+
* strand a key that already exists.
|
|
34
|
+
*
|
|
35
|
+
* WHEN BOTH HOLD KEYS it is a split keyring, and picking silently would hide
|
|
36
|
+
* one agent's roles behind another's. One is chosen so the tool still works,
|
|
37
|
+
* and the other is RETURNED in `alsoFound` so a caller can say so out loud.
|
|
38
|
+
*/
|
|
39
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
40
|
+
import { homedir } from "node:os";
|
|
41
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
42
|
+
export const KEYRING_FILENAME = join(".float", "agent-keys.json");
|
|
43
|
+
/**
|
|
44
|
+
* How many credentials a keyring file holds for `vault`.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately TOLERANT about the credential field name. `roles.ts` loads only
|
|
47
|
+
* `secret`, but `prove-refusal.mjs` reads `privateKey ?? secret ?? agentKey`,
|
|
48
|
+
* and a file written by one path must still be FOUND by this one — otherwise
|
|
49
|
+
* resolution sends the user to an empty home keyring while their real keys sit
|
|
50
|
+
* in a shape this function refused to count. Finding the file and loading an
|
|
51
|
+
* entry out of it are different jobs; `listUnloadable` reports the second.
|
|
52
|
+
*
|
|
53
|
+
* Never throws, and never returns the contents. A corrupt or unreadable file
|
|
54
|
+
* counts as zero; the caller is choosing a path, not validating a keyring.
|
|
55
|
+
*/
|
|
56
|
+
export function countFor(path, vault) {
|
|
57
|
+
if (!existsSync(path))
|
|
58
|
+
return 0;
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(parsed))
|
|
67
|
+
return 0;
|
|
68
|
+
return parsed.filter((e) => {
|
|
69
|
+
if (!e || typeof e !== "object")
|
|
70
|
+
return false;
|
|
71
|
+
const id = e.agentId ?? e.vault;
|
|
72
|
+
if (vault && id !== vault)
|
|
73
|
+
return false;
|
|
74
|
+
return ["secret", "privateKey", "agentKey"].some((k) => typeof e[k] === "string");
|
|
75
|
+
}).length;
|
|
76
|
+
}
|
|
77
|
+
export function resolveKeyringPath(opts) {
|
|
78
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
79
|
+
const home = opts.home ?? homedir();
|
|
80
|
+
const count = opts.count ?? countFor;
|
|
81
|
+
if (opts.explicit) {
|
|
82
|
+
// Resolved even when already absolute, so the returned path is normalised
|
|
83
|
+
// and a relative override still cannot vary with CWD mid-session.
|
|
84
|
+
const path = isAbsolute(opts.explicit) ? opts.explicit : resolve(cwd, opts.explicit);
|
|
85
|
+
return { path, why: "NEAR_VAULT_AGENT_KEYS is set", alsoFound: [] };
|
|
86
|
+
}
|
|
87
|
+
const cwdRing = resolve(cwd, KEYRING_FILENAME);
|
|
88
|
+
const homeRing = join(home, KEYRING_FILENAME);
|
|
89
|
+
// Same directory: nothing to choose between, and no split to report.
|
|
90
|
+
if (cwdRing === homeRing) {
|
|
91
|
+
return { path: homeRing, why: "the default keyring in your home folder", alsoFound: [] };
|
|
92
|
+
}
|
|
93
|
+
const inCwd = count(cwdRing, opts.vault);
|
|
94
|
+
const inHome = count(homeRing, opts.vault);
|
|
95
|
+
if (inCwd > 0 && inHome === 0) {
|
|
96
|
+
return {
|
|
97
|
+
path: cwdRing,
|
|
98
|
+
why: "it already holds a key for this vault",
|
|
99
|
+
alsoFound: [],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (inHome > 0 && inCwd > 0) {
|
|
103
|
+
return {
|
|
104
|
+
path: homeRing,
|
|
105
|
+
why: "it holds a key for this vault",
|
|
106
|
+
// The one NOT being read. Surfaced so "my agent vanished" is answerable.
|
|
107
|
+
alsoFound: [cwdRing],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
path: homeRing,
|
|
112
|
+
why: inHome > 0
|
|
113
|
+
? "it already holds a key for this vault"
|
|
114
|
+
: "the default keyring in your home folder, so the next session finds it from any directory",
|
|
115
|
+
alsoFound: [],
|
|
116
|
+
};
|
|
117
|
+
}
|