@floatonnear/mcp 0.2.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 +95 -10
- package/dist/payloads.js +216 -43
- 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 +425 -19
- package/dist/sign.d.ts +2 -1
- package/dist/sign.js +2 -1
- package/package.json +5 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The node a creation-time agent is bound to.
|
|
3
|
+
*
|
|
4
|
+
* ROOT_NODE in the vault. The factory binds at the root because it is the only
|
|
5
|
+
* node that exists at `new` — children are added later by the owner.
|
|
6
|
+
*/
|
|
7
|
+
export declare const AGENT_NODE_ID = 0;
|
|
8
|
+
export interface GeneratedAgentKey {
|
|
9
|
+
publicKey: string;
|
|
10
|
+
/** The role this credential was filed under, when one was given. */
|
|
11
|
+
role?: string;
|
|
12
|
+
/** Where the secret was written. The secret itself is deliberately absent. */
|
|
13
|
+
keyringPath: string;
|
|
14
|
+
envPath: string;
|
|
15
|
+
/** A prior keyring that was moved aside, if any. */
|
|
16
|
+
backedUpTo?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Write the agent's credential where the adapter will actually find it.
|
|
20
|
+
*
|
|
21
|
+
* THE SHAPE MATTERS MORE THAN THE PATH. `loadKeyring` in
|
|
22
|
+
* packages/float-adapter/src/keyring.ts accepts an entry only when it has a
|
|
23
|
+
* string `agentId` and a string `secret`:
|
|
24
|
+
*
|
|
25
|
+
* if (e && typeof e.agentId === "string" && typeof e.secret === "string")
|
|
26
|
+
*
|
|
27
|
+
* Anything else is skipped in silence — no warning, no error, the file simply
|
|
28
|
+
* contributes nothing. `float-agent.mjs` writes
|
|
29
|
+
* `{kind, vault, node, publicKey, privateKey}` into this same file, which that
|
|
30
|
+
* condition rejects on both counts. So the convenient-looking shape is the
|
|
31
|
+
* dead one, and this writes the shape that loads.
|
|
32
|
+
*
|
|
33
|
+
* `agentId` is the VAULT, not a separate account. A virtual agent has no
|
|
34
|
+
* account of its own: its key is a NEP-611 gas key installed ON the vault, so
|
|
35
|
+
* the account it signs as is the vault itself — which is also why
|
|
36
|
+
* `NEAR_VAULT_AGENT_ID` is set to a vault id in `.mcp.json`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function generateAgentKey(opts: {
|
|
39
|
+
vault: string;
|
|
40
|
+
dir?: string;
|
|
41
|
+
/**
|
|
42
|
+
* The node this agent spends from.
|
|
43
|
+
*
|
|
44
|
+
* `null` means NOT YET KNOWN, and is written as an absent field rather
|
|
45
|
+
* than a guess. `add_role` creates the node inside the contract, so its id
|
|
46
|
+
* does not exist until the transaction lands — and a client cannot read
|
|
47
|
+
* one action's return value from the same transaction.
|
|
48
|
+
*
|
|
49
|
+
* A WRONG NODE ID IS WORSE THAN NONE. Defaulting to 0 filed the first
|
|
50
|
+
* `add_role` agent against the ROOT node while the contract had assigned
|
|
51
|
+
* it node 1: `byNode.get(1)` missed it entirely, and `byNode.get(0)`
|
|
52
|
+
* returned it under the root's name. Selection by role still worked, so
|
|
53
|
+
* nothing failed loudly. `loadKeyring` treats a missing `nodeId` as
|
|
54
|
+
* "selectable by role only", which is exactly the truth here.
|
|
55
|
+
*
|
|
56
|
+
* Omitted entirely still means ROOT, which is correct for the agent bound
|
|
57
|
+
* at vault creation — that one genuinely is node 0.
|
|
58
|
+
*/
|
|
59
|
+
nodeId?: number | null;
|
|
60
|
+
/**
|
|
61
|
+
* A human name for this agent — "marketing", "news".
|
|
62
|
+
*
|
|
63
|
+
* THE ONLY UNIQUE IDENTIFIER once a vault has several agents. `agentId` is
|
|
64
|
+
* the account the key signs as, which for every virtual agent on one vault
|
|
65
|
+
* is the SAME vault, so a keyring indexed by it collapses them and the
|
|
66
|
+
* last one written silently wins.
|
|
67
|
+
*/
|
|
68
|
+
role?: string;
|
|
69
|
+
}): GeneratedAgentKey;
|
|
70
|
+
/** Where the files would go, without creating anything. */
|
|
71
|
+
export declare function agentKeyPaths(dir?: string): {
|
|
72
|
+
keyringPath: string;
|
|
73
|
+
envPath: string;
|
|
74
|
+
dir: string;
|
|
75
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generating and storing the agent's key for one-signature onboarding.
|
|
3
|
+
*
|
|
4
|
+
* This is the ONE place `vault_create` stops being keyless, and the departure
|
|
5
|
+
* is deliberate but narrow: the key generated here is the AGENT's, never the
|
|
6
|
+
* owner's. An agent key can call exactly `request_payment` on one vault, is
|
|
7
|
+
* bounded by the budget tree, and cannot attach a deposit — so the worst a
|
|
8
|
+
* leaked copy does is spend inside limits the owner already set. The owner's
|
|
9
|
+
* full-access key still never comes near this server.
|
|
10
|
+
*
|
|
11
|
+
* THE SECRET NEVER GOES TO THE CALLER. It is written to disk and only the
|
|
12
|
+
* public half is returned. That is not a style preference: this session's own
|
|
13
|
+
* transcript acquired one private key from a screenshot and another from a
|
|
14
|
+
* config dump, and a tool response is a transcript entry forever. The agent
|
|
15
|
+
* calling `vault_create` has filesystem access to the same machine, so a path
|
|
16
|
+
* is strictly more useful to it than a string anyway.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, chmodSync } from "node:fs";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
import { KeyPair } from "near-api-js";
|
|
21
|
+
/**
|
|
22
|
+
* The node a creation-time agent is bound to.
|
|
23
|
+
*
|
|
24
|
+
* ROOT_NODE in the vault. The factory binds at the root because it is the only
|
|
25
|
+
* node that exists at `new` — children are added later by the owner.
|
|
26
|
+
*/
|
|
27
|
+
export const AGENT_NODE_ID = 0;
|
|
28
|
+
/**
|
|
29
|
+
* Write the agent's credential where the adapter will actually find it.
|
|
30
|
+
*
|
|
31
|
+
* THE SHAPE MATTERS MORE THAN THE PATH. `loadKeyring` in
|
|
32
|
+
* packages/float-adapter/src/keyring.ts accepts an entry only when it has a
|
|
33
|
+
* string `agentId` and a string `secret`:
|
|
34
|
+
*
|
|
35
|
+
* if (e && typeof e.agentId === "string" && typeof e.secret === "string")
|
|
36
|
+
*
|
|
37
|
+
* Anything else is skipped in silence — no warning, no error, the file simply
|
|
38
|
+
* contributes nothing. `float-agent.mjs` writes
|
|
39
|
+
* `{kind, vault, node, publicKey, privateKey}` into this same file, which that
|
|
40
|
+
* condition rejects on both counts. So the convenient-looking shape is the
|
|
41
|
+
* dead one, and this writes the shape that loads.
|
|
42
|
+
*
|
|
43
|
+
* `agentId` is the VAULT, not a separate account. A virtual agent has no
|
|
44
|
+
* account of its own: its key is a NEP-611 gas key installed ON the vault, so
|
|
45
|
+
* the account it signs as is the vault itself — which is also why
|
|
46
|
+
* `NEAR_VAULT_AGENT_ID` is set to a vault id in `.mcp.json`.
|
|
47
|
+
*/
|
|
48
|
+
export function generateAgentKey(opts) {
|
|
49
|
+
const dir = opts.dir ?? ".float";
|
|
50
|
+
// `undefined` -> root (the creation-time agent). `null` -> unknown, and the
|
|
51
|
+
// field is omitted rather than guessed.
|
|
52
|
+
const nodeId = opts.nodeId === null ? undefined : (opts.nodeId ?? AGENT_NODE_ID);
|
|
53
|
+
const keyringPath = join(dir, "agent-keys.json");
|
|
54
|
+
const envPath = join(dir, "agent.env");
|
|
55
|
+
const kp = KeyPair.fromRandom("ed25519");
|
|
56
|
+
const publicKey = kp.getPublicKey().toString();
|
|
57
|
+
const secret = kp.toString();
|
|
58
|
+
mkdirSync(dir, { recursive: true });
|
|
59
|
+
// APPEND, NEVER REPLACE. A vault may have several agents, and more to the
|
|
60
|
+
// point: overwriting would destroy the credential of an agent that is
|
|
61
|
+
// already bound on chain and working. An on-chain binding cannot be undone
|
|
62
|
+
// by deleting a file, so a lost secret is a permanently stranded agent.
|
|
63
|
+
let existing = [];
|
|
64
|
+
let backedUpTo;
|
|
65
|
+
if (existsSync(keyringPath)) {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(readFileSync(keyringPath, "utf8"));
|
|
68
|
+
if (Array.isArray(parsed))
|
|
69
|
+
existing = parsed;
|
|
70
|
+
else
|
|
71
|
+
throw new Error("not an array");
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// A malformed keyring must not be parsed into oblivion. Move it aside
|
|
75
|
+
// rather than overwrite it — whatever is in there may be the only copy
|
|
76
|
+
// of a working secret, and this is not the moment to decide it is junk.
|
|
77
|
+
backedUpTo = `${keyringPath}.unreadable-${publicKey.slice(8, 16)}`;
|
|
78
|
+
copyFileSync(keyringPath, backedUpTo);
|
|
79
|
+
existing = [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
existing.push({
|
|
83
|
+
agentId: opts.vault,
|
|
84
|
+
secret,
|
|
85
|
+
publicKey,
|
|
86
|
+
role: opts.role,
|
|
87
|
+
// Only when known. `JSON.stringify` drops an undefined value, so an
|
|
88
|
+
// unknown node leaves no field at all — which is what `loadKeyring`
|
|
89
|
+
// reads as "selectable by role, not by node".
|
|
90
|
+
...(typeof nodeId === "number" ? { nodeId } : {}),
|
|
91
|
+
});
|
|
92
|
+
writeFileSync(keyringPath, JSON.stringify(existing, null, 2) + "\n");
|
|
93
|
+
// The env form the MCP server itself reads. Written second and separately
|
|
94
|
+
// because it holds ONE agent, while the keyring holds every agent — so the
|
|
95
|
+
// keyring is the durable record and this is a convenience.
|
|
96
|
+
writeFileSync(envPath, [
|
|
97
|
+
`# Written by vault_create. Contains a PRIVATE KEY — do not commit or paste.`,
|
|
98
|
+
`# The agent can only call request_payment on this vault, inside its budget.`,
|
|
99
|
+
`NEAR_VAULT_AGENT_ID=${opts.vault}`,
|
|
100
|
+
`NEAR_VAULT_AGENT_KEY=${secret}`,
|
|
101
|
+
``,
|
|
102
|
+
].join("\n"));
|
|
103
|
+
// Best effort. Windows ignores POSIX modes, so this must not be load-bearing
|
|
104
|
+
// — the real protection is that the key is restricted on chain.
|
|
105
|
+
for (const p of [keyringPath, envPath]) {
|
|
106
|
+
try {
|
|
107
|
+
chmodSync(p, 0o600);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* not POSIX; the on-chain restriction is the actual boundary */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { publicKey, role: opts.role, keyringPath, envPath, backedUpTo };
|
|
114
|
+
}
|
|
115
|
+
/** Where the files would go, without creating anything. */
|
|
116
|
+
export function agentKeyPaths(dir = ".float") {
|
|
117
|
+
return { keyringPath: join(dir, "agent-keys.json"), envPath: join(dir, "agent.env"), dir: dirname(join(dir, "x")) };
|
|
118
|
+
}
|
package/dist/create.d.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding: what a user with NO vault yet needs in order to get one.
|
|
3
|
+
*
|
|
4
|
+
* This is the only part of the server that talks to someone who is not yet a
|
|
5
|
+
* customer, and it holds no key and signs nothing — same trust model as the
|
|
6
|
+
* plan tools. It answers three questions and hands back a URL:
|
|
7
|
+
*
|
|
8
|
+
* where does my vault go the FACTORY's own `vault_id_for` view
|
|
9
|
+
* what must I attach the factory's `required_deposit` + code storage
|
|
10
|
+
* how do I sign it the web page, which the user's wallet approves
|
|
11
|
+
*
|
|
12
|
+
* THE DERIVATION IS NOT REIMPLEMENTED HERE. `vault_id_for` exists on the
|
|
13
|
+
* factory precisely so there is one implementation of it; a local copy is a
|
|
14
|
+
* thing that drifts. The factory's prefix rule has already changed once (labels
|
|
15
|
+
* are now rooted in the caller's prefix after a squatting hole), and a client
|
|
16
|
+
* that had copied the old rule would have gone on confidently printing wrong
|
|
17
|
+
* addresses.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Bytes of the vault contract the factory deploys.
|
|
21
|
+
*
|
|
22
|
+
* 301,033 until `create_agent_account` was removed, which took 7,305 bytes
|
|
23
|
+
* with it — about 0.073 NEAR off every vault's storage deposit. The pin
|
|
24
|
+
* caught the drift the moment the contract shrank, which is the entire
|
|
25
|
+
* reason it exists.
|
|
26
|
+
*
|
|
27
|
+
* Mirrors `packages/create-float/res/vault.wasm`, and `create.pins.test.ts`
|
|
28
|
+
* reads that file and fails if the two disagree — so a contract rebuild cannot
|
|
29
|
+
* silently leave this quoting a deposit that is too small. Too small is not a
|
|
30
|
+
* clean failure: CreateAccount succeeds and the vault sits below its own
|
|
31
|
+
* storage requirement, bricking on the first write.
|
|
32
|
+
*/
|
|
33
|
+
export declare const CONTRACT_BYTES = 298819;
|
|
34
|
+
/** NEAR charges 1e19 yoctoNEAR per byte of storage. */
|
|
35
|
+
export declare const STORAGE_COST_PER_BYTE: bigint;
|
|
36
|
+
/**
|
|
37
|
+
* What `web/index.html` adds on top of the code's storage stake.
|
|
38
|
+
*
|
|
39
|
+
* THE FACTORY'S FLOOR IS NOT WHAT THE USER IS ASKED FOR, and conflating the
|
|
40
|
+
* two quoted a deposit 0.26 NEAR too low. `begin()` in
|
|
41
|
+
* contracts/factory/src/lib.rs rejects below `required_deposit + bytes * 1e19`
|
|
42
|
+
* — a real floor, 3.037280 on testnet today. But the page attaches
|
|
43
|
+
* `stake + 0.36` instead, as headroom for the root node's state, and a wallet
|
|
44
|
+
* checks the balance against what the PAGE attaches.
|
|
45
|
+
*
|
|
46
|
+
* Measured 2026-11-03: a vault created through the page,
|
|
47
|
+
* tinyrice9138-testnet.fac.floatv4.testnet, holds exactly 3.297280 — the
|
|
48
|
+
* page's figure to the yoctoNEAR. A user funded to the old 3.237280 quote is
|
|
49
|
+
* rejected by their wallet needing 3.497280, which is precisely the failure
|
|
50
|
+
* GAS_RESERVATION_YOCTO below was written to prevent, arriving through the
|
|
51
|
+
* other term.
|
|
52
|
+
*
|
|
53
|
+
* This is a COPY of a value in a file that cannot import from here — the page
|
|
54
|
+
* is standalone HTML served to a browser with no build step. `create.pins.
|
|
55
|
+
* test.ts` reads that file and fails if the two disagree, the same way
|
|
56
|
+
* CONTRACT_BYTES and CREATE_GAS_TGAS are pinned. A copy nothing checks is a
|
|
57
|
+
* copy that drifts.
|
|
58
|
+
*/
|
|
59
|
+
export declare const PAGE_HEADROOM_YOCTO: bigint;
|
|
60
|
+
/**
|
|
61
|
+
* Funding for an agent's gas key, when one is bound at creation.
|
|
62
|
+
*
|
|
63
|
+
* A NEP-611 gas key's BALANCE is its spending cap — `allowance` is null by
|
|
64
|
+
* design — so this is the only thing bounding how much gas the agent burns.
|
|
65
|
+
* 0.12 NEAR ≈ 50 payments at the vault's 50-TGas `MIN_GAS_FOR_DISPATCH`
|
|
66
|
+
* floor, matching `gasKeyFunding({tgas:60, payments:50})` in create-float.
|
|
67
|
+
*
|
|
68
|
+
* Charged ONLY when an agent is bound. The factory raises its own on-chain
|
|
69
|
+
* deposit floor by exactly this amount in that case, so a quote that omits it
|
|
70
|
+
* is refused with DEPOSIT_TOO_SMALL — and a quote that always includes it
|
|
71
|
+
* overcharges everyone who creates a bare vault.
|
|
72
|
+
*
|
|
73
|
+
* A COPY of `AGENT_GAS_KEY_FUNDING` in contracts/factory/src/lib.rs and
|
|
74
|
+
* `AGENT_GAS` in web/index.html. Three files, one number, which is exactly
|
|
75
|
+
* the shape that already shipped a 0.26 NEAR underquote here — so
|
|
76
|
+
* `create.pins.test.ts` reads both of the others and fails if any disagree.
|
|
77
|
+
*/
|
|
78
|
+
export declare const AGENT_GAS_KEY_FUNDING_YOCTO: bigint;
|
|
79
|
+
/**
|
|
80
|
+
* Gas the create page attaches. MEASURED commitment is 124.88 TGas:
|
|
81
|
+
* 42.79 to deliver and decode the 401 KB base64 argument (measured on chain),
|
|
82
|
+
* plus CreateAccount 7.70, Transfer 0.23, DeployContract 34.16, and the
|
|
83
|
+
* factory's forwarded 30 + 10. 200 keeps ~60% headroom.
|
|
84
|
+
*
|
|
85
|
+
* Must match `web/index.html`. It is not a free choice — see
|
|
86
|
+
* `GAS_RESERVATION_YOCTO`.
|
|
87
|
+
*/
|
|
88
|
+
export declare const CREATE_GAS_TGAS = 200n;
|
|
89
|
+
/**
|
|
90
|
+
* What the user must HOLD on top of the deposit, at signing time.
|
|
91
|
+
*
|
|
92
|
+
* A transaction reserves `prepaid_gas * 1e9` — the MAXIMUM gas price — for the
|
|
93
|
+
* duration of the call, and refunds the unused part a receipt later. Almost
|
|
94
|
+
* none of it is burned (~0.004 in practice), but the wallet checks the balance
|
|
95
|
+
* against the reservation and refuses below it. Quoting only the deposit is
|
|
96
|
+
* what produced:
|
|
97
|
+
*
|
|
98
|
+
* Sender work12.testnet does not have enough balance 3.5234
|
|
99
|
+
* for operation costing 3.674061500357969
|
|
100
|
+
*
|
|
101
|
+
* so the quoted total includes it.
|
|
102
|
+
*/
|
|
103
|
+
export declare const GAS_RESERVATION_YOCTO: bigint;
|
|
104
|
+
/**
|
|
105
|
+
* Known factories.
|
|
106
|
+
*
|
|
107
|
+
* MAINNET IS DELIBERATELY ABSENT until one is deployed and verified. A wrong
|
|
108
|
+
* default here does not fail safely: it sends a real user with real NEAR to
|
|
109
|
+
* sign a 3.4 NEAR deposit into an account we do not control. Absent means the
|
|
110
|
+
* tool asks, which is the correct behaviour for a value nobody has measured.
|
|
111
|
+
*/
|
|
112
|
+
export declare const FACTORY_BY_NETWORK: Record<string, string | undefined>;
|
|
113
|
+
export interface CreatePlanInput {
|
|
114
|
+
owner: string;
|
|
115
|
+
/**
|
|
116
|
+
* False when the caller does not know the user's NEAR account.
|
|
117
|
+
*
|
|
118
|
+
* An agent onboarding a stranger has no account id to pass, and demanding
|
|
119
|
+
* one assumes the user knows it, types it correctly, and has one at all.
|
|
120
|
+
* The page derives everything from the CONNECTED wallet anyway, so an
|
|
121
|
+
* owner-less link is not degraded — it asks the wallet instead of the human.
|
|
122
|
+
*/
|
|
123
|
+
ownerKnown?: boolean;
|
|
124
|
+
/** From the factory's `vault_id_for`. `null` means no legal derived id. */
|
|
125
|
+
vaultId: string | null;
|
|
126
|
+
factory: string;
|
|
127
|
+
network: string;
|
|
128
|
+
/** From the factory's `get_config`. */
|
|
129
|
+
requiredDepositYocto: string;
|
|
130
|
+
paused: boolean;
|
|
131
|
+
appUrl: string;
|
|
132
|
+
/** Whether the derived account is already on chain. */
|
|
133
|
+
exists?: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* The agent public key to bind at creation, if any.
|
|
136
|
+
*
|
|
137
|
+
* Only affects the QUOTE here — the server neither generates nor holds the
|
|
138
|
+
* secret half, and the page still asks the user to confirm the key before
|
|
139
|
+
* anything is signed.
|
|
140
|
+
*/
|
|
141
|
+
agentPk?: string | null;
|
|
142
|
+
}
|
|
143
|
+
export interface CreatePlan {
|
|
144
|
+
owner: string | null;
|
|
145
|
+
/** Null when the owner is unknown — the wallet decides it at signing time. */
|
|
146
|
+
vault: string | null;
|
|
147
|
+
factory: string;
|
|
148
|
+
network: string;
|
|
149
|
+
depositYocto: string;
|
|
150
|
+
depositNear: string;
|
|
151
|
+
gasTgas: number;
|
|
152
|
+
gasReservationNear: string;
|
|
153
|
+
totalRequiredYocto: string;
|
|
154
|
+
totalRequiredNear: string;
|
|
155
|
+
url: string;
|
|
156
|
+
warnings: string[];
|
|
157
|
+
note: string;
|
|
158
|
+
}
|
|
159
|
+
/** yoctoNEAR -> a NEAR string, without floating point. */
|
|
160
|
+
export declare function yoctoToNear(y: bigint, dp?: number): string;
|
|
161
|
+
export declare function buildCreatePlan(i: CreatePlanInput): CreatePlan;
|
package/dist/create.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding: what a user with NO vault yet needs in order to get one.
|
|
3
|
+
*
|
|
4
|
+
* This is the only part of the server that talks to someone who is not yet a
|
|
5
|
+
* customer, and it holds no key and signs nothing — same trust model as the
|
|
6
|
+
* plan tools. It answers three questions and hands back a URL:
|
|
7
|
+
*
|
|
8
|
+
* where does my vault go the FACTORY's own `vault_id_for` view
|
|
9
|
+
* what must I attach the factory's `required_deposit` + code storage
|
|
10
|
+
* how do I sign it the web page, which the user's wallet approves
|
|
11
|
+
*
|
|
12
|
+
* THE DERIVATION IS NOT REIMPLEMENTED HERE. `vault_id_for` exists on the
|
|
13
|
+
* factory precisely so there is one implementation of it; a local copy is a
|
|
14
|
+
* thing that drifts. The factory's prefix rule has already changed once (labels
|
|
15
|
+
* are now rooted in the caller's prefix after a squatting hole), and a client
|
|
16
|
+
* that had copied the old rule would have gone on confidently printing wrong
|
|
17
|
+
* addresses.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Bytes of the vault contract the factory deploys.
|
|
21
|
+
*
|
|
22
|
+
* 301,033 until `create_agent_account` was removed, which took 7,305 bytes
|
|
23
|
+
* with it — about 0.073 NEAR off every vault's storage deposit. The pin
|
|
24
|
+
* caught the drift the moment the contract shrank, which is the entire
|
|
25
|
+
* reason it exists.
|
|
26
|
+
*
|
|
27
|
+
* Mirrors `packages/create-float/res/vault.wasm`, and `create.pins.test.ts`
|
|
28
|
+
* reads that file and fails if the two disagree — so a contract rebuild cannot
|
|
29
|
+
* silently leave this quoting a deposit that is too small. Too small is not a
|
|
30
|
+
* clean failure: CreateAccount succeeds and the vault sits below its own
|
|
31
|
+
* storage requirement, bricking on the first write.
|
|
32
|
+
*/
|
|
33
|
+
export const CONTRACT_BYTES = 298819;
|
|
34
|
+
/** NEAR charges 1e19 yoctoNEAR per byte of storage. */
|
|
35
|
+
export const STORAGE_COST_PER_BYTE = 10n ** 19n;
|
|
36
|
+
/**
|
|
37
|
+
* What `web/index.html` adds on top of the code's storage stake.
|
|
38
|
+
*
|
|
39
|
+
* THE FACTORY'S FLOOR IS NOT WHAT THE USER IS ASKED FOR, and conflating the
|
|
40
|
+
* two quoted a deposit 0.26 NEAR too low. `begin()` in
|
|
41
|
+
* contracts/factory/src/lib.rs rejects below `required_deposit + bytes * 1e19`
|
|
42
|
+
* — a real floor, 3.037280 on testnet today. But the page attaches
|
|
43
|
+
* `stake + 0.36` instead, as headroom for the root node's state, and a wallet
|
|
44
|
+
* checks the balance against what the PAGE attaches.
|
|
45
|
+
*
|
|
46
|
+
* Measured 2026-11-03: a vault created through the page,
|
|
47
|
+
* tinyrice9138-testnet.fac.floatv4.testnet, holds exactly 3.297280 — the
|
|
48
|
+
* page's figure to the yoctoNEAR. A user funded to the old 3.237280 quote is
|
|
49
|
+
* rejected by their wallet needing 3.497280, which is precisely the failure
|
|
50
|
+
* GAS_RESERVATION_YOCTO below was written to prevent, arriving through the
|
|
51
|
+
* other term.
|
|
52
|
+
*
|
|
53
|
+
* This is a COPY of a value in a file that cannot import from here — the page
|
|
54
|
+
* is standalone HTML served to a browser with no build step. `create.pins.
|
|
55
|
+
* test.ts` reads that file and fails if the two disagree, the same way
|
|
56
|
+
* CONTRACT_BYTES and CREATE_GAS_TGAS are pinned. A copy nothing checks is a
|
|
57
|
+
* copy that drifts.
|
|
58
|
+
*/
|
|
59
|
+
export const PAGE_HEADROOM_YOCTO = 36n * 10n ** 22n;
|
|
60
|
+
/**
|
|
61
|
+
* Funding for an agent's gas key, when one is bound at creation.
|
|
62
|
+
*
|
|
63
|
+
* A NEP-611 gas key's BALANCE is its spending cap — `allowance` is null by
|
|
64
|
+
* design — so this is the only thing bounding how much gas the agent burns.
|
|
65
|
+
* 0.12 NEAR ≈ 50 payments at the vault's 50-TGas `MIN_GAS_FOR_DISPATCH`
|
|
66
|
+
* floor, matching `gasKeyFunding({tgas:60, payments:50})` in create-float.
|
|
67
|
+
*
|
|
68
|
+
* Charged ONLY when an agent is bound. The factory raises its own on-chain
|
|
69
|
+
* deposit floor by exactly this amount in that case, so a quote that omits it
|
|
70
|
+
* is refused with DEPOSIT_TOO_SMALL — and a quote that always includes it
|
|
71
|
+
* overcharges everyone who creates a bare vault.
|
|
72
|
+
*
|
|
73
|
+
* A COPY of `AGENT_GAS_KEY_FUNDING` in contracts/factory/src/lib.rs and
|
|
74
|
+
* `AGENT_GAS` in web/index.html. Three files, one number, which is exactly
|
|
75
|
+
* the shape that already shipped a 0.26 NEAR underquote here — so
|
|
76
|
+
* `create.pins.test.ts` reads both of the others and fails if any disagree.
|
|
77
|
+
*/
|
|
78
|
+
export const AGENT_GAS_KEY_FUNDING_YOCTO = 12n * 10n ** 22n;
|
|
79
|
+
/**
|
|
80
|
+
* Gas the create page attaches. MEASURED commitment is 124.88 TGas:
|
|
81
|
+
* 42.79 to deliver and decode the 401 KB base64 argument (measured on chain),
|
|
82
|
+
* plus CreateAccount 7.70, Transfer 0.23, DeployContract 34.16, and the
|
|
83
|
+
* factory's forwarded 30 + 10. 200 keeps ~60% headroom.
|
|
84
|
+
*
|
|
85
|
+
* Must match `web/index.html`. It is not a free choice — see
|
|
86
|
+
* `GAS_RESERVATION_YOCTO`.
|
|
87
|
+
*/
|
|
88
|
+
export const CREATE_GAS_TGAS = 200n;
|
|
89
|
+
/**
|
|
90
|
+
* What the user must HOLD on top of the deposit, at signing time.
|
|
91
|
+
*
|
|
92
|
+
* A transaction reserves `prepaid_gas * 1e9` — the MAXIMUM gas price — for the
|
|
93
|
+
* duration of the call, and refunds the unused part a receipt later. Almost
|
|
94
|
+
* none of it is burned (~0.004 in practice), but the wallet checks the balance
|
|
95
|
+
* against the reservation and refuses below it. Quoting only the deposit is
|
|
96
|
+
* what produced:
|
|
97
|
+
*
|
|
98
|
+
* Sender work12.testnet does not have enough balance 3.5234
|
|
99
|
+
* for operation costing 3.674061500357969
|
|
100
|
+
*
|
|
101
|
+
* so the quoted total includes it.
|
|
102
|
+
*/
|
|
103
|
+
export const GAS_RESERVATION_YOCTO = CREATE_GAS_TGAS * 10n ** 12n * 10n ** 9n;
|
|
104
|
+
/**
|
|
105
|
+
* Known factories.
|
|
106
|
+
*
|
|
107
|
+
* MAINNET IS DELIBERATELY ABSENT until one is deployed and verified. A wrong
|
|
108
|
+
* default here does not fail safely: it sends a real user with real NEAR to
|
|
109
|
+
* sign a 3.4 NEAR deposit into an account we do not control. Absent means the
|
|
110
|
+
* tool asks, which is the correct behaviour for a value nobody has measured.
|
|
111
|
+
*/
|
|
112
|
+
export const FACTORY_BY_NETWORK = {
|
|
113
|
+
testnet: "fac.floatv4.testnet",
|
|
114
|
+
mainnet: undefined,
|
|
115
|
+
};
|
|
116
|
+
/** yoctoNEAR -> a NEAR string, without floating point. */
|
|
117
|
+
export function yoctoToNear(y, dp = 5) {
|
|
118
|
+
const neg = y < 0n;
|
|
119
|
+
const v = neg ? -y : y;
|
|
120
|
+
const whole = v / 10n ** 24n;
|
|
121
|
+
const frac = (v % 10n ** 24n).toString().padStart(24, "0").slice(0, dp);
|
|
122
|
+
return `${neg ? "-" : ""}${whole}.${frac}`;
|
|
123
|
+
}
|
|
124
|
+
export function buildCreatePlan(i) {
|
|
125
|
+
if (i.paused) {
|
|
126
|
+
throw new Error(`The factory ${i.factory} is paused — vault creation is disabled. ` +
|
|
127
|
+
`This is the operator's kill switch; try again later.`);
|
|
128
|
+
}
|
|
129
|
+
// UNKNOWN OWNER IS A VALID CASE, not a missing argument. Everything below
|
|
130
|
+
// that depends on the owner is skipped rather than guessed: no derived
|
|
131
|
+
// address, no existence check, and no `owner` on the url — the page refuses
|
|
132
|
+
// when that parameter disagrees with the connected wallet, so a blank or
|
|
133
|
+
// invented one would turn every link into a refusal.
|
|
134
|
+
const known = i.ownerKnown !== false && !!i.owner;
|
|
135
|
+
if (known && !i.vaultId) {
|
|
136
|
+
throw new Error(`${i.owner} has no legal vault id under ${i.factory}. ` +
|
|
137
|
+
`An implicit account (a 64-character hex id) cannot have one: the derived ` +
|
|
138
|
+
`prefix is already 64 characters, so "<prefix>.${i.factory}" exceeds NEAR's ` +
|
|
139
|
+
`64-character limit. Use a named account.`);
|
|
140
|
+
}
|
|
141
|
+
if (known && i.exists) {
|
|
142
|
+
throw new Error(`${i.vaultId} already exists. That is this owner's vault — connect to it ` +
|
|
143
|
+
`with NEAR_VAULT_CONTRACT=${i.vaultId} rather than creating another.`);
|
|
144
|
+
}
|
|
145
|
+
// Two different floors, and the quote must clear BOTH.
|
|
146
|
+
//
|
|
147
|
+
// pageAttaches what the wallet is actually asked to approve
|
|
148
|
+
// factoryFloor what `begin()` rejects below, on chain
|
|
149
|
+
//
|
|
150
|
+
// They are independent: the page's headroom is a fixed constant while the
|
|
151
|
+
// factory's floor moves with `set_required_deposit`. Quoting either one
|
|
152
|
+
// alone is what broke — the old code quoted the floor, and the page then
|
|
153
|
+
// asked for 0.26 more.
|
|
154
|
+
const stake = BigInt(CONTRACT_BYTES) * STORAGE_COST_PER_BYTE;
|
|
155
|
+
// Both floors move by the gas-key funding when an agent is bound: the page
|
|
156
|
+
// attaches it, and the factory requires it.
|
|
157
|
+
const agentFunding = i.agentPk ? AGENT_GAS_KEY_FUNDING_YOCTO : 0n;
|
|
158
|
+
const pageAttaches = stake + PAGE_HEADROOM_YOCTO + agentFunding;
|
|
159
|
+
const factoryFloor = BigInt(i.requiredDepositYocto) + stake + agentFunding;
|
|
160
|
+
const deposit = pageAttaches > factoryFloor ? pageAttaches : factoryFloor;
|
|
161
|
+
const total = deposit + GAS_RESERVATION_YOCTO;
|
|
162
|
+
// If the operator raises `required_deposit` past the page's headroom, the
|
|
163
|
+
// page under-attaches and the create is rejected ON CHAIN with
|
|
164
|
+
// DEPOSIT_TOO_SMALL. Quoting the higher figure does not rescue that — the
|
|
165
|
+
// user would hold enough and still fail, because the page attaches the
|
|
166
|
+
// lesser amount regardless of what they hold. Say so rather than quoting a
|
|
167
|
+
// number that cannot help.
|
|
168
|
+
const underAttachWarning = factoryFloor > pageAttaches
|
|
169
|
+
? [
|
|
170
|
+
`WARNING: ${i.factory} now requires ${yoctoToNear(factoryFloor)} NEAR, but the ` +
|
|
171
|
+
`create page attaches only ${yoctoToNear(pageAttaches)}. The transaction will be ` +
|
|
172
|
+
`rejected on chain with DEPOSIT_TOO_SMALL no matter how much you hold. The page's ` +
|
|
173
|
+
`headroom constant needs raising — this is an operator bug, not a funding problem.`,
|
|
174
|
+
]
|
|
175
|
+
: [];
|
|
176
|
+
const url = new URL(i.appUrl);
|
|
177
|
+
if (known)
|
|
178
|
+
url.searchParams.set("owner", i.owner);
|
|
179
|
+
if (i.agentPk)
|
|
180
|
+
url.searchParams.set("agent_pk", i.agentPk);
|
|
181
|
+
url.searchParams.set("factory", i.factory);
|
|
182
|
+
url.searchParams.set("network", i.network);
|
|
183
|
+
const unknownOwnerWarnings = known
|
|
184
|
+
? []
|
|
185
|
+
: [
|
|
186
|
+
`The vault address is not known yet — it is derived from whichever wallet ` +
|
|
187
|
+
`you connect, and the page will show it before you approve anything.`,
|
|
188
|
+
`Nothing has checked whether you already have a vault. If you do, the page ` +
|
|
189
|
+
`will say so rather than creating a second one.`,
|
|
190
|
+
];
|
|
191
|
+
return {
|
|
192
|
+
owner: known ? i.owner : null,
|
|
193
|
+
vault: known ? i.vaultId : null,
|
|
194
|
+
factory: i.factory,
|
|
195
|
+
network: i.network,
|
|
196
|
+
depositYocto: deposit.toString(),
|
|
197
|
+
depositNear: yoctoToNear(deposit),
|
|
198
|
+
gasTgas: Number(CREATE_GAS_TGAS),
|
|
199
|
+
gasReservationNear: yoctoToNear(GAS_RESERVATION_YOCTO),
|
|
200
|
+
totalRequiredYocto: total.toString(),
|
|
201
|
+
totalRequiredNear: yoctoToNear(total),
|
|
202
|
+
url: url.toString(),
|
|
203
|
+
warnings: [
|
|
204
|
+
...underAttachWarning,
|
|
205
|
+
...unknownOwnerWarnings,
|
|
206
|
+
`Almost all of the ${yoctoToNear(deposit)} NEAR is a refundable storage ` +
|
|
207
|
+
`deposit held by your vault, not a fee.`,
|
|
208
|
+
`You must HOLD ${yoctoToNear(total)} NEAR at signing time: the deposit plus a ` +
|
|
209
|
+
`${yoctoToNear(GAS_RESERVATION_YOCTO)} gas reservation. The reservation is ` +
|
|
210
|
+
`refunded moments later — only about 0.004 is actually spent — but a wallet ` +
|
|
211
|
+
`refuses the transaction if the balance is below it.`,
|
|
212
|
+
`Nothing can spend from this vault until you add an agent and give it a budget.`,
|
|
213
|
+
],
|
|
214
|
+
note: `This server never sees or holds a key. Open the URL and approve the ` +
|
|
215
|
+
`transaction in your own wallet; the contract travels as an argument to ` +
|
|
216
|
+
`one ordinary function call, and the factory deploys it for you.`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export declare class KeyFileError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export interface KeyFileContents {
|
|
4
|
+
secret: string;
|
|
5
|
+
/** From a `~/.near-credentials`-shaped file. Absent for a bare key file. */
|
|
6
|
+
accountId?: string;
|
|
7
|
+
publicKey?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Read a key out of `path`.
|
|
11
|
+
*
|
|
12
|
+
* Accepts either the `~/.near-credentials` JSON shape or a bare key on one
|
|
13
|
+
* line. Throws `KeyFileError` for anything else — never returns an empty or
|
|
14
|
+
* partial credential, because "I could not read it" collapsing into "there
|
|
15
|
+
* isn't one" is the failure this is built to avoid.
|
|
16
|
+
*/
|
|
17
|
+
export declare function readKeyFile(path: string): KeyFileContents;
|
|
18
|
+
export type KeySource = "file" | "inline" | "none";
|
|
19
|
+
export interface ResolvedKey {
|
|
20
|
+
/** Absent when nothing is configured OR when the named file failed to read. */
|
|
21
|
+
secret?: string;
|
|
22
|
+
source: KeySource;
|
|
23
|
+
/** The path that was tried, when one was named. */
|
|
24
|
+
path?: string;
|
|
25
|
+
accountId?: string;
|
|
26
|
+
publicKey?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Set when a file was NAMED but could not be read.
|
|
29
|
+
*
|
|
30
|
+
* Distinct from `source === "none"` on purpose, and the caller must keep it
|
|
31
|
+
* that way: "no key is configured" and "the key file you named is broken"
|
|
32
|
+
* are different problems with different fixes, and reporting the second as
|
|
33
|
+
* the first sends the reader to set a variable that is already set. The
|
|
34
|
+
* adapter's `loadKeyring` carries `skippedOtherVault` for exactly this
|
|
35
|
+
* reason.
|
|
36
|
+
*/
|
|
37
|
+
error?: string;
|
|
38
|
+
/** Operator-facing notes. NAMES ONLY — these get logged. */
|
|
39
|
+
warnings: string[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Decide which of the two variables supplies a secret.
|
|
43
|
+
*
|
|
44
|
+
* The file wins. A failed file read does NOT fall back to the inline value:
|
|
45
|
+
* silently using the config an operator believes they have stopped using is
|
|
46
|
+
* worse than not starting.
|
|
47
|
+
*/
|
|
48
|
+
export declare function resolveKeySource(opts: {
|
|
49
|
+
inline?: string;
|
|
50
|
+
file?: string;
|
|
51
|
+
/** e.g. "NEAR_VAULT_AGENT_KEY" — named so messages can be acted on. */
|
|
52
|
+
inlineVar: string;
|
|
53
|
+
/** e.g. "NEAR_VAULT_AGENT_KEY_FILE" */
|
|
54
|
+
fileVar: string;
|
|
55
|
+
}): ResolvedKey;
|