@floatonnear/mcp 0.3.0 → 0.4.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/dist/agent-key.d.ts +75 -0
- package/dist/agent-key.js +118 -0
- package/dist/create.d.ts +161 -0
- package/dist/create.js +218 -0
- package/dist/key-file.d.ts +55 -0
- package/dist/key-file.js +237 -0
- package/dist/payloads.d.ts +69 -9
- package/dist/payloads.js +182 -38
- package/dist/roles.d.ts +18 -0
- package/dist/roles.js +61 -0
- package/dist/rpc.d.ts +51 -0
- package/dist/rpc.js +100 -4
- package/dist/server.d.ts +29 -3
- package/dist/server.js +416 -18
- package/dist/sign.d.ts +2 -1
- package/dist/sign.js +2 -1
- package/package.json +5 -1
package/dist/key-file.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a secret from a FILE PATH instead of a plaintext env value.
|
|
3
|
+
*
|
|
4
|
+
* This is the same reasoning as `env-file.ts` in the adapter, taken one step
|
|
5
|
+
* further, so it is worth saying what the two have in common and where they
|
|
6
|
+
* part.
|
|
7
|
+
*
|
|
8
|
+
* `env-file.ts` exists because a secrets file must be DATA, never something a
|
|
9
|
+
* shell executes: `set -a && . .float/adapter.env` makes the shell run `$(...)`
|
|
10
|
+
* and backticks found in a value, and it has already died for real on one
|
|
11
|
+
* unquoted value containing a space. That argument is about how a file is
|
|
12
|
+
* LOADED.
|
|
13
|
+
*
|
|
14
|
+
* This module is about where the secret LIVES. Right now the agent key is a
|
|
15
|
+
* plaintext env value in two places — `.mcp.json` (`mcpServers.float-near.env`)
|
|
16
|
+
* and `.float/adapter.env`. Both are gitignored and neither has ever been
|
|
17
|
+
* committed, so nothing has leaked. The exposure is ORDINARY WORK: checking
|
|
18
|
+
* which vault an adapter is pointed at means reading its config, and reading
|
|
19
|
+
* its config prints a live private key into a terminal scrollback and into the
|
|
20
|
+
* transcript of whatever agent is driving. That is routine, it is not a
|
|
21
|
+
* mistake anyone would flag in review, and it is how the session that filed
|
|
22
|
+
* this acquired one.
|
|
23
|
+
*
|
|
24
|
+
* A PATH is not a secret, so the same `cat` becomes harmless. This repo already
|
|
25
|
+
* prefers that shape for the one credential it handles most carefully: NEAR CLI
|
|
26
|
+
* signing uses `sign-with-access-key-file` rather than
|
|
27
|
+
* `sign-with-plaintext-private-key`, chosen after a key was echoed into a
|
|
28
|
+
* terminal. This brings the MCP server and the adapter to the same footing.
|
|
29
|
+
*
|
|
30
|
+
* THE INLINE VARIABLES KEEP WORKING. They are in use; a file is an
|
|
31
|
+
* alternative, not a replacement. When both are set the file wins and the
|
|
32
|
+
* inline one is reported as ignored — the other way round would let an operator
|
|
33
|
+
* believe they had moved a secret out of their config while the config was
|
|
34
|
+
* still silently the thing being read.
|
|
35
|
+
*
|
|
36
|
+
* ---- WHAT NO MESSAGE IN HERE MAY CONTAIN
|
|
37
|
+
*
|
|
38
|
+
* The secret. Every refusal below is reached while holding a usable key, so
|
|
39
|
+
* quoting "what the file contained" would undo the entire point of the change
|
|
40
|
+
* at the one moment an operator is most likely to paste the output somewhere.
|
|
41
|
+
* Field NAMES are safe and are the actual diagnosis; values are not. This is
|
|
42
|
+
* pinned by `no refusal ever names the secret` in key-file.test.ts, which drives
|
|
43
|
+
* only cases that have a real secret in hand.
|
|
44
|
+
*
|
|
45
|
+
* That rule is also why a JSON parse failure is reported as "not valid JSON"
|
|
46
|
+
* with the parser's own message DISCARDED: V8's `JSON.parse` errors quote a
|
|
47
|
+
* fragment of the input, so passing one through would print part of the key.
|
|
48
|
+
*/
|
|
49
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
50
|
+
export class KeyFileError extends Error {
|
|
51
|
+
}
|
|
52
|
+
/** The shape to quote in a refusal, so the fix is in the message. */
|
|
53
|
+
const CRED_SHAPE = '{"account_id", "public_key", "private_key"}';
|
|
54
|
+
/**
|
|
55
|
+
* An env-file assignment, which is a FILE POINTED AT THE WRONG VARIABLE
|
|
56
|
+
* rather than a key.
|
|
57
|
+
*
|
|
58
|
+
* Why the name must contain an underscore and the value must be non-empty
|
|
59
|
+
* after stripping '=': a base64url secret can legitimately end in '=' padding
|
|
60
|
+
* and base64url's alphabet includes '_', so `ABC_DEF=` is a possible SECRET,
|
|
61
|
+
* not necessarily an assignment. Padding is always trailing, so requiring
|
|
62
|
+
* something other than '=' after the first one tells the two apart. Neither of
|
|
63
|
+
* the keys this actually carries can collide anyway — an `ed25519:` key is
|
|
64
|
+
* base58 (no '=', no '_') and a quote signer is hex — but this reader is
|
|
65
|
+
* generic and the wrong answer here is the silent kind.
|
|
66
|
+
*/
|
|
67
|
+
const ENV_LINE = /^(?:export\s+)?([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\s*=(.*)$/;
|
|
68
|
+
/**
|
|
69
|
+
* Read a key out of `path`.
|
|
70
|
+
*
|
|
71
|
+
* Accepts either the `~/.near-credentials` JSON shape or a bare key on one
|
|
72
|
+
* line. Throws `KeyFileError` for anything else — never returns an empty or
|
|
73
|
+
* partial credential, because "I could not read it" collapsing into "there
|
|
74
|
+
* isn't one" is the failure this is built to avoid.
|
|
75
|
+
*/
|
|
76
|
+
export function readKeyFile(path) {
|
|
77
|
+
if (!existsSync(path)) {
|
|
78
|
+
throw new KeyFileError(`key file ${path} does not exist. It should hold either the ` +
|
|
79
|
+
`~/.near-credentials shape ${CRED_SHAPE} or the bare key on one line.`);
|
|
80
|
+
}
|
|
81
|
+
let raw;
|
|
82
|
+
try {
|
|
83
|
+
// Checked explicitly: readFileSync on a directory throws EISDIR, and an
|
|
84
|
+
// errno in an error message is not a diagnosis.
|
|
85
|
+
if (statSync(path).isDirectory()) {
|
|
86
|
+
throw new KeyFileError(`key file ${path} is a directory, not a file.`);
|
|
87
|
+
}
|
|
88
|
+
raw = readFileSync(path, "utf8");
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
if (e instanceof KeyFileError)
|
|
92
|
+
throw e;
|
|
93
|
+
const code = e.code;
|
|
94
|
+
throw new KeyFileError(`key file ${path} could not be read${code ? ` (${code})` : ""}.`);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A BOM IS ALREADY HANDLED, by `trim()`, and that is worth a sentence
|
|
98
|
+
* because it does not look handled.
|
|
99
|
+
*
|
|
100
|
+
* PowerShell's Set-Content and Out-File write a U+FEFF by default, so any
|
|
101
|
+
* file the operator has touched from a one-liner carries one, and left in
|
|
102
|
+
* place it becomes part of the secret or of the first JSON key's name — a
|
|
103
|
+
* failure whose cause is invisible. env-file.ts strips it explicitly, so the
|
|
104
|
+
* obvious thing here was to copy that.
|
|
105
|
+
*
|
|
106
|
+
* It is not needed. U+FEFF satisfies the spec's WhiteSpace production, so
|
|
107
|
+
* `String.prototype.trim` removes it (verified on node 24.14:
|
|
108
|
+
* `/^\s$/.test("")` is true). An explicit `startsWith("") ?
|
|
109
|
+
* slice(1)` ahead of this line is therefore dead code — it was written, and
|
|
110
|
+
* mutation testing exposed it: deleting it killed no test, because it could
|
|
111
|
+
* not change any outcome.
|
|
112
|
+
*
|
|
113
|
+
* The `tolerates CRLF and a BOM` test stays, pinning the real behaviour
|
|
114
|
+
* rather than the redundant guard.
|
|
115
|
+
*/
|
|
116
|
+
const trimmed = raw.trim();
|
|
117
|
+
// EMPTY IS NOT ABSENT. A zero-byte key file resolving to "no key configured"
|
|
118
|
+
// would send the operator to set a variable they have already set, while the
|
|
119
|
+
// real fault is the file they are pointing at.
|
|
120
|
+
if (!trimmed) {
|
|
121
|
+
throw new KeyFileError(`key file ${path} is empty — there is no key in it.`);
|
|
122
|
+
}
|
|
123
|
+
// `[` as well as `{`: a JSON array must be named as the wrong shape, not fed
|
|
124
|
+
// to the bare-key reader, which would otherwise accept the literal text
|
|
125
|
+
// `["ed25519:..."]` as a secret.
|
|
126
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("["))
|
|
127
|
+
return fromJson(trimmed, path);
|
|
128
|
+
return { secret: fromBare(trimmed, path) };
|
|
129
|
+
}
|
|
130
|
+
function fromJson(text, path) {
|
|
131
|
+
let parsed;
|
|
132
|
+
try {
|
|
133
|
+
parsed = JSON.parse(text);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/**
|
|
137
|
+
* THE PARSER'S MESSAGE IS DELIBERATELY DROPPED, and this is load-bearing
|
|
138
|
+
* rather than tidiness.
|
|
139
|
+
*
|
|
140
|
+
* V8 quotes a run of the input in its `is not valid JSON` form. Measured on
|
|
141
|
+
* node 24.14 against a credentials file whose private_key had been
|
|
142
|
+
* hand-edited and left unquoted:
|
|
143
|
+
*
|
|
144
|
+
* Unexpected token 'e', ..."ate_key": ed25519:3D"... is not valid JSON
|
|
145
|
+
*
|
|
146
|
+
* Forwarding that publishes the head of the key — into a terminal, a pm2
|
|
147
|
+
* log, or an agent transcript, which is the exact exposure this module
|
|
148
|
+
* exists to close. Pinned by `unquoted JSON value` in the leak test.
|
|
149
|
+
*/
|
|
150
|
+
throw new KeyFileError(`key file ${path} is not valid JSON. Expected the ~/.near-credentials shape ` +
|
|
151
|
+
`${CRED_SHAPE}, or a bare key on one line.`);
|
|
152
|
+
}
|
|
153
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
154
|
+
throw new KeyFileError(`key file ${path} is JSON but not an object. Expected the ~/.near-credentials ` +
|
|
155
|
+
`shape ${CRED_SHAPE}.`);
|
|
156
|
+
}
|
|
157
|
+
const o = parsed;
|
|
158
|
+
const secret = typeof o.private_key === "string" ? o.private_key.trim() : "";
|
|
159
|
+
if (!secret) {
|
|
160
|
+
// FIELD NAMES, NEVER VALUES. The names are the whole diagnosis: a file
|
|
161
|
+
// written by a tool that calls it `secret` or `privateKey` is otherwise
|
|
162
|
+
// indistinguishable from a corrupt one.
|
|
163
|
+
const names = Object.keys(o);
|
|
164
|
+
throw new KeyFileError(`key file ${path} has no usable "private_key". Fields present: ` +
|
|
165
|
+
`${names.length ? names.join(", ") : "none"}. Expected the ~/.near-credentials ` +
|
|
166
|
+
`shape ${CRED_SHAPE}.`);
|
|
167
|
+
}
|
|
168
|
+
const str = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
|
169
|
+
return { secret, accountId: str(o.account_id), publicKey: str(o.public_key) };
|
|
170
|
+
}
|
|
171
|
+
function fromBare(text, path) {
|
|
172
|
+
const lines = text
|
|
173
|
+
.split(/\r?\n/)
|
|
174
|
+
.map((l) => l.trim())
|
|
175
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
176
|
+
// Reachable: a file holding nothing but comments trims to something, then
|
|
177
|
+
// filters to nothing.
|
|
178
|
+
if (lines.length === 0) {
|
|
179
|
+
throw new KeyFileError(`key file ${path} is empty — there is no key in it.`);
|
|
180
|
+
}
|
|
181
|
+
// BEFORE the line count, because an env file has many lines and "expected
|
|
182
|
+
// one key" would be a true statement that hides the actual mistake.
|
|
183
|
+
for (const line of lines) {
|
|
184
|
+
const m = ENV_LINE.exec(line);
|
|
185
|
+
if (m && m[2].replace(/=/g, "").trim()) {
|
|
186
|
+
throw new KeyFileError(`key file ${path} looks like an env file: it contains an assignment to ` +
|
|
187
|
+
`${m[1]}. This variable wants a file holding ONLY the key — either the ` +
|
|
188
|
+
`~/.near-credentials shape ${CRED_SHAPE} or the bare key on one line. ` +
|
|
189
|
+
`To load a whole env file instead, use FLOAT_ENV_FILE.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (lines.length > 1) {
|
|
193
|
+
throw new KeyFileError(`key file ${path} has ${lines.length} content lines; expected exactly one key. ` +
|
|
194
|
+
`Which key signs is which budget is charged, so this is not guessed.`);
|
|
195
|
+
}
|
|
196
|
+
return lines[0];
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Decide which of the two variables supplies a secret.
|
|
200
|
+
*
|
|
201
|
+
* The file wins. A failed file read does NOT fall back to the inline value:
|
|
202
|
+
* silently using the config an operator believes they have stopped using is
|
|
203
|
+
* worse than not starting.
|
|
204
|
+
*/
|
|
205
|
+
export function resolveKeySource(opts) {
|
|
206
|
+
const warnings = [];
|
|
207
|
+
// Trimmed, and blank treated as absent. `NEAR_VAULT_AGENT_KEY=` in an env
|
|
208
|
+
// file parses to "", and a key made of spaces fails at the signer several
|
|
209
|
+
// frames from its cause. No valid key has surrounding whitespace, so this
|
|
210
|
+
// can only rescue a config that was already broken.
|
|
211
|
+
const inline = (opts.inline ?? "").trim() || undefined;
|
|
212
|
+
const path = (opts.file ?? "").trim() || undefined;
|
|
213
|
+
if (!path) {
|
|
214
|
+
return inline ? { secret: inline, source: "inline", warnings } : { source: "none", warnings };
|
|
215
|
+
}
|
|
216
|
+
if (inline) {
|
|
217
|
+
warnings.push(`${opts.fileVar} is set, so ${opts.inlineVar} is ignored. Remove the inline ` +
|
|
218
|
+
`value: a key held in an env value is printed by any read of the config, ` +
|
|
219
|
+
`which is routine, whereas a path is not a secret.`);
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const got = readKeyFile(path);
|
|
223
|
+
return {
|
|
224
|
+
secret: got.secret,
|
|
225
|
+
source: "file",
|
|
226
|
+
path,
|
|
227
|
+
accountId: got.accountId,
|
|
228
|
+
publicKey: got.publicKey,
|
|
229
|
+
warnings,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
// `source` stays "file" with no secret. A caller that reports this as
|
|
234
|
+
// "nothing configured" has lost the only useful part of the message.
|
|
235
|
+
return { source: "file", path, error: `${opts.fileVar}: ${e.message}`, warnings };
|
|
236
|
+
}
|
|
237
|
+
}
|
package/dist/payloads.d.ts
CHANGED
|
@@ -12,10 +12,6 @@
|
|
|
12
12
|
* message can explain *why* a bound exists.
|
|
13
13
|
*/
|
|
14
14
|
export declare const ONE_YOCTO = "1";
|
|
15
|
-
/** Mirrors `AGENT_ACCOUNT_STORAGE` in the contract: 0.05 NEAR. */
|
|
16
|
-
export declare const AGENT_ACCOUNT_STORAGE_YOCTO: string;
|
|
17
|
-
/** Mirrors `MIN_AGENT_KEY_ALLOWANCE` in the contract: 0.5 NEAR. */
|
|
18
|
-
export declare const MIN_AGENT_KEY_ALLOWANCE_YOCTO: string;
|
|
19
15
|
/**
|
|
20
16
|
* Gas attached to a payment, for every route.
|
|
21
17
|
*
|
|
@@ -28,7 +24,6 @@ export declare const DEFAULT_GAS_TGAS = 100;
|
|
|
28
24
|
export declare const MAX_TOKENS = 4;
|
|
29
25
|
export declare const MAX_RECIPIENTS = 32;
|
|
30
26
|
export declare const MAX_LABEL_LEN = 48;
|
|
31
|
-
export declare const MAX_SUB_LABEL_LEN = 32;
|
|
32
27
|
export declare const MAX_COOLDOWN_SEC: number;
|
|
33
28
|
export declare const MAX_PURGE_LIMIT = 100;
|
|
34
29
|
export interface HumanTokenLimit {
|
|
@@ -51,13 +46,66 @@ export interface OwnerPayload {
|
|
|
51
46
|
export declare function planAddNode(vault: string, parent: number | null, label: string, limits: HumanTokenLimit[], cooldownSec: number, recipients: string[] | null): OwnerPayload;
|
|
52
47
|
export declare function planSetNodeCaps(vault: string, nodeId: number, limits: HumanTokenLimit[]): OwnerPayload;
|
|
53
48
|
export declare function planBindAgent(vault: string, nodeId: number, accountId: string): OwnerPayload;
|
|
54
|
-
|
|
49
|
+
/**
|
|
50
|
+
* A whole departmental agent in ONE owner signature: node, binding, gas key.
|
|
51
|
+
*
|
|
52
|
+
* WHY THIS IS NOT `add_node` + `bind_virtual_agent` + an AddKey.
|
|
53
|
+
*
|
|
54
|
+
* Installing a gas key is an `AddKey` action, and NEAR requires
|
|
55
|
+
* `signer_id == receiver_id` for one. The owner signing a transaction TO the
|
|
56
|
+
* vault cannot carry it — only the vault can key itself. A browser wallet
|
|
57
|
+
* signs as the connected account and cannot sign as the vault, so composing
|
|
58
|
+
* this client-side would make fleet expansion CLI-only.
|
|
59
|
+
*
|
|
60
|
+
* And the node id cannot be threaded: `add_node` returns it, but no client
|
|
61
|
+
* can read one action's return value inside the same transaction, and
|
|
62
|
+
* predicting it from `next_node_id` races a concurrent `add_node`.
|
|
63
|
+
*
|
|
64
|
+
* So the contract does all three. This is one plain FunctionCall.
|
|
65
|
+
*
|
|
66
|
+
* THE DEPOSIT IS THE GAS KEY'S ENTIRE BALANCE, and that balance is its
|
|
67
|
+
* spending cap — a gas key's `allowance` is null by design. It is not a fee:
|
|
68
|
+
* the owner can reclaim it, because `WithdrawFromGasKey` is account-signed
|
|
69
|
+
* and the vault carries the owner's full-access key.
|
|
70
|
+
*/
|
|
71
|
+
export declare function planAddRole(vault: string, label: string, limits: HumanTokenLimit[], cooldownSec: number, recipients: string[] | null, publicKey: string, fundingYocto: string): OwnerPayload;
|
|
72
|
+
export declare function planBindVirtualAgent(vault: string, nodeId: number, publicKey: string): OwnerPayload;
|
|
73
|
+
/**
|
|
74
|
+
* Revoke a virtual agent's authority.
|
|
75
|
+
*
|
|
76
|
+
* Frees the 82-byte `pubkey_to_node` entry. The 86-byte `pubkey_to_nonce`
|
|
77
|
+
* entry is RETAINED deliberately and never reclaimed — confirmed live, where
|
|
78
|
+
* next_nonce stayed at 2 instead of resetting to 1. Dropping it would let
|
|
79
|
+
* unbind-then-rebind reset the nonce and reopen the replay window, so about
|
|
80
|
+
* half the binding's storage is intentionally unrecoverable.
|
|
81
|
+
*/
|
|
82
|
+
export declare function planUnbindVirtualAgent(vault: string, publicKey: string): OwnerPayload;
|
|
55
83
|
export declare function planSetRecipients(vault: string, nodeId: number, recipients: string[] | null): OwnerPayload;
|
|
56
84
|
export declare function planSetCooldown(vault: string, nodeId: number, cooldownSec: number): OwnerPayload;
|
|
57
85
|
export declare function planFreezeNode(vault: string, nodeId: number, frozen: boolean): OwnerPayload;
|
|
58
86
|
export declare function planSetPaused(vault: string, paused: boolean): OwnerPayload;
|
|
59
87
|
export declare function planSetStrictAllocation(vault: string, strict: boolean): OwnerPayload;
|
|
60
88
|
export declare function planPurgeSettled(vault: string, beforeDay: number, limit: number): OwnerPayload;
|
|
89
|
+
/**
|
|
90
|
+
* Move tokens out of the vault, to anywhere the owner says.
|
|
91
|
+
*
|
|
92
|
+
* THE STEP THAT DECIDES WHETHER MONEY SURVIVES A TEARDOWN. Deleting a NEAR
|
|
93
|
+
* account does NOT return its fungible tokens — the token ledger keeps a row
|
|
94
|
+
* keyed to an account nobody can ever sign for again. So this is not hygiene
|
|
95
|
+
* before closing a vault, it is the difference between recovering a balance
|
|
96
|
+
* and burning it, and `scripts/teardown-vault.mjs` runs it first for exactly
|
|
97
|
+
* that reason.
|
|
98
|
+
*
|
|
99
|
+
* BASE UNITS, NOT HUMAN ONES. `withdraw` passes the amount straight to
|
|
100
|
+
* `ft_transfer`, so this takes what the token uses. The caller scales, and
|
|
101
|
+
* the summary says the human figure so the two cannot silently disagree in
|
|
102
|
+
* the wallet.
|
|
103
|
+
*
|
|
104
|
+
* The contract refuses a zero amount (`amount must be positive`), so this
|
|
105
|
+
* does too — refusing here means the owner is not sent to a wallet to sign
|
|
106
|
+
* something that will revert and cost gas to learn.
|
|
107
|
+
*/
|
|
108
|
+
export declare function planWithdraw(vault: string, token: string, amountBase: string, receiverId: string, decimals: number): OwnerPayload;
|
|
61
109
|
/** Registry used by the MCP server, and asserted against the contract's method list. */
|
|
62
110
|
export declare const OWNER_PLANNERS: {
|
|
63
111
|
readonly add_node: {
|
|
@@ -72,9 +120,17 @@ export declare const OWNER_PLANNERS: {
|
|
|
72
120
|
readonly methodName: "bind_agent";
|
|
73
121
|
readonly fn: typeof planBindAgent;
|
|
74
122
|
};
|
|
75
|
-
readonly
|
|
76
|
-
readonly methodName: "
|
|
77
|
-
readonly fn: typeof
|
|
123
|
+
readonly add_role: {
|
|
124
|
+
readonly methodName: "add_role";
|
|
125
|
+
readonly fn: typeof planAddRole;
|
|
126
|
+
};
|
|
127
|
+
readonly bind_virtual_agent: {
|
|
128
|
+
readonly methodName: "bind_virtual_agent";
|
|
129
|
+
readonly fn: typeof planBindVirtualAgent;
|
|
130
|
+
};
|
|
131
|
+
readonly unbind_virtual_agent: {
|
|
132
|
+
readonly methodName: "unbind_virtual_agent";
|
|
133
|
+
readonly fn: typeof planUnbindVirtualAgent;
|
|
78
134
|
};
|
|
79
135
|
readonly set_recipients: {
|
|
80
136
|
readonly methodName: "set_recipients";
|
|
@@ -100,6 +156,10 @@ export declare const OWNER_PLANNERS: {
|
|
|
100
156
|
readonly methodName: "purge_settled";
|
|
101
157
|
readonly fn: typeof planPurgeSettled;
|
|
102
158
|
};
|
|
159
|
+
readonly withdraw: {
|
|
160
|
+
readonly methodName: "withdraw";
|
|
161
|
+
readonly fn: typeof planWithdraw;
|
|
162
|
+
};
|
|
103
163
|
};
|
|
104
164
|
/** A ready-to-run command, so approving does not require trusting this server. */
|
|
105
165
|
export declare function toCliCommand(p: OwnerPayload, network?: string): string;
|
package/dist/payloads.js
CHANGED
|
@@ -11,12 +11,27 @@
|
|
|
11
11
|
* too, but failing locally costs no gas and no wallet round-trip, and the
|
|
12
12
|
* message can explain *why* a bound exists.
|
|
13
13
|
*/
|
|
14
|
-
import { toBaseUnits
|
|
14
|
+
import { toBaseUnits } from "./amounts.js";
|
|
15
|
+
/** A node id must be a non-negative integer; there is no node -1. */
|
|
16
|
+
function assertNodeId(nodeId) {
|
|
17
|
+
if (!Number.isInteger(nodeId) || nodeId < 0) {
|
|
18
|
+
throw new Error(`node id must be a non-negative integer, got ${nodeId}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Shape-check a public key before it reaches a wallet.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately not a full base58 decode — the contract does that and rejects
|
|
25
|
+
* a malformed key. This catches the empty string and the obvious typo locally,
|
|
26
|
+
* where it costs nothing instead of a wallet round-trip.
|
|
27
|
+
*/
|
|
28
|
+
function assertPublicKey(publicKey) {
|
|
29
|
+
if (!/^(ed25519|secp256k1):[1-9A-HJ-NP-Za-km-z]{32,}$/.test(publicKey)) {
|
|
30
|
+
throw new Error(`not a public key: ${JSON.stringify(publicKey)}. ` +
|
|
31
|
+
`Expected "ed25519:<base58>" or "secp256k1:<base58>".`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
15
34
|
export const ONE_YOCTO = "1";
|
|
16
|
-
/** Mirrors `AGENT_ACCOUNT_STORAGE` in the contract: 0.05 NEAR. */
|
|
17
|
-
export const AGENT_ACCOUNT_STORAGE_YOCTO = (5n * 10n ** 22n).toString();
|
|
18
|
-
/** Mirrors `MIN_AGENT_KEY_ALLOWANCE` in the contract: 0.5 NEAR. */
|
|
19
|
-
export const MIN_AGENT_KEY_ALLOWANCE_YOCTO = (5n * 10n ** 23n).toString();
|
|
20
35
|
/**
|
|
21
36
|
* Gas attached to a payment, for every route.
|
|
22
37
|
*
|
|
@@ -33,7 +48,6 @@ export const DEFAULT_GAS_TGAS = 100;
|
|
|
33
48
|
export const MAX_TOKENS = 4;
|
|
34
49
|
export const MAX_RECIPIENTS = 32;
|
|
35
50
|
export const MAX_LABEL_LEN = 48;
|
|
36
|
-
export const MAX_SUB_LABEL_LEN = 32;
|
|
37
51
|
export const MAX_COOLDOWN_SEC = 7 * 24 * 60 * 60;
|
|
38
52
|
export const MAX_PURGE_LIMIT = 100;
|
|
39
53
|
const NEAR_DECIMALS = 24;
|
|
@@ -70,7 +84,6 @@ function describeLimits(limits) {
|
|
|
70
84
|
.map((l) => `${l.token}: ${l.dailyCap}/day, max ${l.perTxCap}/payment`)
|
|
71
85
|
.join("; ");
|
|
72
86
|
}
|
|
73
|
-
const SUB_LABEL = new RegExp(`^[a-z0-9_-]{1,${MAX_SUB_LABEL_LEN}}$`);
|
|
74
87
|
function assertRecipients(recipients) {
|
|
75
88
|
if (recipients === null)
|
|
76
89
|
return;
|
|
@@ -138,47 +151,118 @@ export function planBindAgent(vault, nodeId, accountId) {
|
|
|
138
151
|
summary: `Let ${accountId} spend from node ${nodeId}`,
|
|
139
152
|
warnings: [
|
|
140
153
|
`${accountId} is an EXISTING account whose keys you do not control. ` +
|
|
141
|
-
`
|
|
154
|
+
`It can call request_payment with the full authority of node ${nodeId}.`,
|
|
155
|
+
`Prefer a VIRTUAL agent (bind_virtual_agent): it binds one key you generate ` +
|
|
156
|
+
`rather than a whole account, costs ~168 bytes, and strands nothing.`,
|
|
142
157
|
],
|
|
143
158
|
};
|
|
144
159
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
160
|
+
/**
|
|
161
|
+
* A whole departmental agent in ONE owner signature: node, binding, gas key.
|
|
162
|
+
*
|
|
163
|
+
* WHY THIS IS NOT `add_node` + `bind_virtual_agent` + an AddKey.
|
|
164
|
+
*
|
|
165
|
+
* Installing a gas key is an `AddKey` action, and NEAR requires
|
|
166
|
+
* `signer_id == receiver_id` for one. The owner signing a transaction TO the
|
|
167
|
+
* vault cannot carry it — only the vault can key itself. A browser wallet
|
|
168
|
+
* signs as the connected account and cannot sign as the vault, so composing
|
|
169
|
+
* this client-side would make fleet expansion CLI-only.
|
|
170
|
+
*
|
|
171
|
+
* And the node id cannot be threaded: `add_node` returns it, but no client
|
|
172
|
+
* can read one action's return value inside the same transaction, and
|
|
173
|
+
* predicting it from `next_node_id` races a concurrent `add_node`.
|
|
174
|
+
*
|
|
175
|
+
* So the contract does all three. This is one plain FunctionCall.
|
|
176
|
+
*
|
|
177
|
+
* THE DEPOSIT IS THE GAS KEY'S ENTIRE BALANCE, and that balance is its
|
|
178
|
+
* spending cap — a gas key's `allowance` is null by design. It is not a fee:
|
|
179
|
+
* the owner can reclaim it, because `WithdrawFromGasKey` is account-signed
|
|
180
|
+
* and the vault carries the owner's full-access key.
|
|
181
|
+
*/
|
|
182
|
+
export function planAddRole(vault, label, limits, cooldownSec, recipients, publicKey, fundingYocto) {
|
|
183
|
+
if (!label || label.length > MAX_LABEL_LEN) {
|
|
184
|
+
throw new Error(`label must be 1..=${MAX_LABEL_LEN} characters (MAX_LABEL_LEN)`);
|
|
153
185
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
186
|
+
assertCooldown(cooldownSec);
|
|
187
|
+
assertRecipients(recipients);
|
|
188
|
+
assertPublicKey(publicKey);
|
|
189
|
+
// Mirrors MIN_AGENT_GAS_FUNDING in the vault. Below this the key is
|
|
190
|
+
// installed and cannot complete a single call: NEAR reserves
|
|
191
|
+
// `prepaid_gas * 1e9` and the tooling sends 60 TGas, which reserves 0.06.
|
|
192
|
+
// The contract refuses too; refusing here means the owner is not sent to a
|
|
193
|
+
// wallet to sign something that will revert.
|
|
194
|
+
if (BigInt(fundingYocto) < 6n * 10n ** 22n) {
|
|
195
|
+
throw new Error(`the agent's gas key needs at least 0.06 NEAR to make one 60 TGas call — ` +
|
|
196
|
+
`NEAR reserves prepaid gas up front, so a key funded to the expected ` +
|
|
197
|
+
`burn (~0.0007) cannot transact at all.`);
|
|
161
198
|
}
|
|
162
|
-
const deposit = allowance + BigInt(AGENT_ACCOUNT_STORAGE_YOCTO);
|
|
163
|
-
const derived = `${subLabel}.${vault}`;
|
|
164
199
|
return {
|
|
165
200
|
receiverId: vault,
|
|
166
|
-
methodName: "
|
|
201
|
+
methodName: "add_role",
|
|
167
202
|
args: {
|
|
168
|
-
|
|
169
|
-
|
|
203
|
+
label,
|
|
204
|
+
limits: limitsToArgs(limits),
|
|
205
|
+
cooldown_sec: cooldownSec,
|
|
206
|
+
recipients,
|
|
170
207
|
public_key: publicKey,
|
|
171
|
-
key_allowance: allowance.toString(),
|
|
172
208
|
},
|
|
173
209
|
gasTgas: DEFAULT_GAS_TGAS,
|
|
174
|
-
depositYocto:
|
|
175
|
-
summary: `
|
|
176
|
-
|
|
177
|
-
`
|
|
178
|
-
|
|
179
|
-
`
|
|
180
|
-
|
|
181
|
-
|
|
210
|
+
depositYocto: fundingYocto,
|
|
211
|
+
summary: `Add the "${label}" agent with its own budget, under root`,
|
|
212
|
+
warnings: [
|
|
213
|
+
`The ${(Number(fundingYocto) / 1e24).toFixed(3)} NEAR deposit funds the agent's ` +
|
|
214
|
+
`gas key. It is recoverable, not a fee.`,
|
|
215
|
+
`The new node is a CHILD OF ROOT, so root's caps still bound the whole ` +
|
|
216
|
+
`fleet: this divides what the vault can spend in a day, it does not raise it.`,
|
|
217
|
+
`Check the public key matches the one your agent reported before signing.`,
|
|
218
|
+
],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
export function planBindVirtualAgent(vault, nodeId, publicKey) {
|
|
222
|
+
assertNodeId(nodeId);
|
|
223
|
+
assertPublicKey(publicKey);
|
|
224
|
+
return {
|
|
225
|
+
receiverId: vault,
|
|
226
|
+
methodName: "bind_virtual_agent",
|
|
227
|
+
args: { node_id: nodeId, public_key: publicKey },
|
|
228
|
+
gasTgas: DEFAULT_GAS_TGAS,
|
|
229
|
+
// ONE YOCTO, not a funding amount. `bind_virtual_agent` calls
|
|
230
|
+
// `assert_owner()` -> `assert_one_yocto()`. Nothing is funded here, which
|
|
231
|
+
// is the whole point of moving off sub-accounts.
|
|
232
|
+
depositYocto: ONE_YOCTO,
|
|
233
|
+
summary: `Let the key ${publicKey.slice(0, 22)}… spend from node ${nodeId}`,
|
|
234
|
+
warnings: [
|
|
235
|
+
`A node may hold more than one key — binding another does not replace this one.`,
|
|
236
|
+
`This grants spending authority only. The key still needs gas: install it as ` +
|
|
237
|
+
`a NEP-611 gas key on the vault and fund it. That balance is recoverable.`,
|
|
238
|
+
],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Revoke a virtual agent's authority.
|
|
243
|
+
*
|
|
244
|
+
* Frees the 82-byte `pubkey_to_node` entry. The 86-byte `pubkey_to_nonce`
|
|
245
|
+
* entry is RETAINED deliberately and never reclaimed — confirmed live, where
|
|
246
|
+
* next_nonce stayed at 2 instead of resetting to 1. Dropping it would let
|
|
247
|
+
* unbind-then-rebind reset the nonce and reopen the replay window, so about
|
|
248
|
+
* half the binding's storage is intentionally unrecoverable.
|
|
249
|
+
*/
|
|
250
|
+
export function planUnbindVirtualAgent(vault, publicKey) {
|
|
251
|
+
assertPublicKey(publicKey);
|
|
252
|
+
return {
|
|
253
|
+
receiverId: vault,
|
|
254
|
+
methodName: "unbind_virtual_agent",
|
|
255
|
+
args: { public_key: publicKey },
|
|
256
|
+
gasTgas: DEFAULT_GAS_TGAS,
|
|
257
|
+
depositYocto: ONE_YOCTO,
|
|
258
|
+
summary: `Revoke the key ${publicKey.slice(0, 22)}…`,
|
|
259
|
+
warnings: [
|
|
260
|
+
`The replay nonce for this key is kept forever, on purpose: resetting it ` +
|
|
261
|
+
`would let an unbind/rebind replay old signed payloads. About half the ` +
|
|
262
|
+
`binding's storage is therefore not reclaimed.`,
|
|
263
|
+
`Gas still sitting in this key's NEP-611 gas key is NOT returned by ` +
|
|
264
|
+
`unbinding. Withdraw from the gas key first, or it stays there.`,
|
|
265
|
+
],
|
|
182
266
|
};
|
|
183
267
|
}
|
|
184
268
|
export function planSetRecipients(vault, nodeId, recipients) {
|
|
@@ -270,18 +354,78 @@ export function planPurgeSettled(vault, beforeDay, limit) {
|
|
|
270
354
|
],
|
|
271
355
|
};
|
|
272
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* Move tokens out of the vault, to anywhere the owner says.
|
|
359
|
+
*
|
|
360
|
+
* THE STEP THAT DECIDES WHETHER MONEY SURVIVES A TEARDOWN. Deleting a NEAR
|
|
361
|
+
* account does NOT return its fungible tokens — the token ledger keeps a row
|
|
362
|
+
* keyed to an account nobody can ever sign for again. So this is not hygiene
|
|
363
|
+
* before closing a vault, it is the difference between recovering a balance
|
|
364
|
+
* and burning it, and `scripts/teardown-vault.mjs` runs it first for exactly
|
|
365
|
+
* that reason.
|
|
366
|
+
*
|
|
367
|
+
* BASE UNITS, NOT HUMAN ONES. `withdraw` passes the amount straight to
|
|
368
|
+
* `ft_transfer`, so this takes what the token uses. The caller scales, and
|
|
369
|
+
* the summary says the human figure so the two cannot silently disagree in
|
|
370
|
+
* the wallet.
|
|
371
|
+
*
|
|
372
|
+
* The contract refuses a zero amount (`amount must be positive`), so this
|
|
373
|
+
* does too — refusing here means the owner is not sent to a wallet to sign
|
|
374
|
+
* something that will revert and cost gas to learn.
|
|
375
|
+
*/
|
|
376
|
+
export function planWithdraw(vault, token, amountBase, receiverId, decimals) {
|
|
377
|
+
let amt;
|
|
378
|
+
try {
|
|
379
|
+
amt = BigInt(amountBase);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
throw new Error(`amount must be an integer in base units, got "${amountBase}"`);
|
|
383
|
+
}
|
|
384
|
+
if (amt <= 0n) {
|
|
385
|
+
throw new Error(`amount must be positive — the contract rejects a zero withdrawal outright.`);
|
|
386
|
+
}
|
|
387
|
+
if (!receiverId)
|
|
388
|
+
throw new Error("a receiver account is required");
|
|
389
|
+
// Human figure for the summary only. Done with string surgery rather than
|
|
390
|
+
// Number(), because 2^53 is about 9 billion and a 6-decimal token hits that
|
|
391
|
+
// at 9,007 tokens — a figure a real vault can hold, and silently wrong is
|
|
392
|
+
// the one thing a wallet confirmation must never be.
|
|
393
|
+
const neg = amt < 0n;
|
|
394
|
+
const v = (neg ? -amt : amt).toString().padStart(decimals + 1, "0");
|
|
395
|
+
const whole = v.slice(0, v.length - decimals);
|
|
396
|
+
const frac = decimals ? v.slice(v.length - decimals).replace(/0+$/, "") : "";
|
|
397
|
+
const human = frac ? `${whole}.${frac}` : whole;
|
|
398
|
+
return {
|
|
399
|
+
receiverId: vault,
|
|
400
|
+
methodName: "withdraw",
|
|
401
|
+
args: { token, amount: amountBase, receiver_id: receiverId },
|
|
402
|
+
gasTgas: DEFAULT_GAS_TGAS,
|
|
403
|
+
// `withdraw` is #[payable] and calls assert_owner() -> assert_one_yocto().
|
|
404
|
+
depositYocto: ONE_YOCTO,
|
|
405
|
+
summary: `Send ${human} out of the vault to ${receiverId}`,
|
|
406
|
+
warnings: [
|
|
407
|
+
`This moves real tokens. ${receiverId} keeps them — the vault cannot pull them back.`,
|
|
408
|
+
`In-flight payments reserved against this balance may now fail at the token ` +
|
|
409
|
+
`contract. Their reservations are released by the existing sweep machinery, ` +
|
|
410
|
+
`so no accounting breaks, but a payment simply does not go through.`,
|
|
411
|
+
],
|
|
412
|
+
};
|
|
413
|
+
}
|
|
273
414
|
/** Registry used by the MCP server, and asserted against the contract's method list. */
|
|
274
415
|
export const OWNER_PLANNERS = {
|
|
275
416
|
add_node: { methodName: "add_node", fn: planAddNode },
|
|
276
417
|
set_node_caps: { methodName: "set_node_caps", fn: planSetNodeCaps },
|
|
277
418
|
bind_agent: { methodName: "bind_agent", fn: planBindAgent },
|
|
278
|
-
|
|
419
|
+
add_role: { methodName: "add_role", fn: planAddRole },
|
|
420
|
+
bind_virtual_agent: { methodName: "bind_virtual_agent", fn: planBindVirtualAgent },
|
|
421
|
+
unbind_virtual_agent: { methodName: "unbind_virtual_agent", fn: planUnbindVirtualAgent },
|
|
279
422
|
set_recipients: { methodName: "set_recipients", fn: planSetRecipients },
|
|
280
423
|
set_cooldown: { methodName: "set_cooldown", fn: planSetCooldown },
|
|
281
424
|
freeze_node: { methodName: "freeze_node", fn: planFreezeNode },
|
|
282
425
|
set_paused: { methodName: "set_paused", fn: planSetPaused },
|
|
283
426
|
set_strict_allocation: { methodName: "set_strict_allocation", fn: planSetStrictAllocation },
|
|
284
427
|
purge_settled: { methodName: "purge_settled", fn: planPurgeSettled },
|
|
428
|
+
withdraw: { methodName: "withdraw", fn: planWithdraw },
|
|
285
429
|
};
|
|
286
430
|
/** A ready-to-run command, so approving does not require trusting this server. */
|
|
287
431
|
export function toCliCommand(p, network = "testnet") {
|
package/dist/roles.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface RoleCredential {
|
|
2
|
+
agentId: string;
|
|
3
|
+
agentKey: string;
|
|
4
|
+
role: string;
|
|
5
|
+
nodeId?: number;
|
|
6
|
+
}
|
|
7
|
+
export declare class RoleSelectionError extends Error {
|
|
8
|
+
}
|
|
9
|
+
export declare function selectRole(cfg: {
|
|
10
|
+
agentKeysPath?: string;
|
|
11
|
+
}, role: string): {
|
|
12
|
+
agentId: string;
|
|
13
|
+
agentKey: string;
|
|
14
|
+
};
|
|
15
|
+
/** Every role that could actually sign, for listing back to a caller. */
|
|
16
|
+
export declare function listRoles(cfg: {
|
|
17
|
+
agentKeysPath?: string;
|
|
18
|
+
}): RoleCredential[];
|