@floatonnear/mcp 0.3.0 → 0.5.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.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The node a creation-time agent is bound to.
3
+ *
4
+ * ONE, NOT ZERO, SINCE 2026-11-08. `new` used to bind at ROOT_NODE because it
5
+ * was the only node that existed — which left the FIRST agent holding the
6
+ * whole vault budget while every agent added later through `add_role` got a
7
+ * child of its own. The hierarchy only began constraining from agent #2.
8
+ *
9
+ * `new` now creates the agent its own child of root, carrying the same limits,
10
+ * exactly as `add_role` does. Root is 0 and that child is the first node
11
+ * inserted after it, so the agent is 1.
12
+ *
13
+ * PREDICTED, NOT READ, and that is unavoidable: the credential is generated
14
+ * before the vault exists, so there is nothing to ask. It is pinned by
15
+ * `binding_at_init_matches_binding_afterwards` in the contract, which asserts
16
+ * the node is 1 and not 0 — if `new` ever inserts another node first, that
17
+ * test fails rather than this constant drifting in silence.
18
+ */
19
+ export declare const AGENT_NODE_ID = 1;
20
+ export interface GeneratedAgentKey {
21
+ publicKey: string;
22
+ /** The role this credential was filed under, when one was given. */
23
+ role?: string;
24
+ /** Where the secret was written. The secret itself is deliberately absent. */
25
+ keyringPath: string;
26
+ envPath: string;
27
+ /** A prior keyring that was moved aside, if any. */
28
+ backedUpTo?: string;
29
+ }
30
+ /**
31
+ * Write the agent's credential where the adapter will actually find it.
32
+ *
33
+ * THE SHAPE MATTERS MORE THAN THE PATH. `loadKeyring` in
34
+ * packages/float-adapter/src/keyring.ts accepts an entry only when it has a
35
+ * string `agentId` and a string `secret`:
36
+ *
37
+ * if (e && typeof e.agentId === "string" && typeof e.secret === "string")
38
+ *
39
+ * Anything else is skipped in silence — no warning, no error, the file simply
40
+ * contributes nothing. `float-agent.mjs` writes
41
+ * `{kind, vault, node, publicKey, privateKey}` into this same file, which that
42
+ * condition rejects on both counts. So the convenient-looking shape is the
43
+ * dead one, and this writes the shape that loads.
44
+ *
45
+ * `agentId` is the VAULT, not a separate account. A virtual agent has no
46
+ * account of its own: its key is a NEP-611 gas key installed ON the vault, so
47
+ * the account it signs as is the vault itself — which is also why
48
+ * `NEAR_VAULT_AGENT_ID` is set to a vault id in `.mcp.json`.
49
+ */
50
+ export declare function generateAgentKey(opts: {
51
+ vault: string;
52
+ dir?: string;
53
+ /**
54
+ * The node this agent spends from.
55
+ *
56
+ * `null` means NOT YET KNOWN, and is written as an absent field rather
57
+ * than a guess. `add_role` creates the node inside the contract, so its id
58
+ * does not exist until the transaction lands — and a client cannot read
59
+ * one action's return value from the same transaction.
60
+ *
61
+ * A WRONG NODE ID IS WORSE THAN NONE. Defaulting to 0 filed the first
62
+ * `add_role` agent against the ROOT node while the contract had assigned
63
+ * it node 1: `byNode.get(1)` missed it entirely, and `byNode.get(0)`
64
+ * returned it under the root's name. Selection by role still worked, so
65
+ * nothing failed loudly. `loadKeyring` treats a missing `nodeId` as
66
+ * "selectable by role only", which is exactly the truth here.
67
+ *
68
+ * Omitted entirely still means ROOT, which is correct for the agent bound
69
+ * at vault creation — that one genuinely is node 0.
70
+ */
71
+ nodeId?: number | null;
72
+ /**
73
+ * A human name for this agent — "marketing", "news".
74
+ *
75
+ * THE ONLY UNIQUE IDENTIFIER once a vault has several agents. `agentId` is
76
+ * the account the key signs as, which for every virtual agent on one vault
77
+ * is the SAME vault, so a keyring indexed by it collapses them and the
78
+ * last one written silently wins.
79
+ */
80
+ role?: string;
81
+ }): GeneratedAgentKey;
82
+ /** Where the files would go, without creating anything. */
83
+ export declare function agentKeyPaths(dir?: string): {
84
+ keyringPath: string;
85
+ envPath: string;
86
+ dir: string;
87
+ };
@@ -0,0 +1,130 @@
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
+ * ONE, NOT ZERO, SINCE 2026-11-08. `new` used to bind at ROOT_NODE because it
25
+ * was the only node that existed — which left the FIRST agent holding the
26
+ * whole vault budget while every agent added later through `add_role` got a
27
+ * child of its own. The hierarchy only began constraining from agent #2.
28
+ *
29
+ * `new` now creates the agent its own child of root, carrying the same limits,
30
+ * exactly as `add_role` does. Root is 0 and that child is the first node
31
+ * inserted after it, so the agent is 1.
32
+ *
33
+ * PREDICTED, NOT READ, and that is unavoidable: the credential is generated
34
+ * before the vault exists, so there is nothing to ask. It is pinned by
35
+ * `binding_at_init_matches_binding_afterwards` in the contract, which asserts
36
+ * the node is 1 and not 0 — if `new` ever inserts another node first, that
37
+ * test fails rather than this constant drifting in silence.
38
+ */
39
+ export const AGENT_NODE_ID = 1;
40
+ /**
41
+ * Write the agent's credential where the adapter will actually find it.
42
+ *
43
+ * THE SHAPE MATTERS MORE THAN THE PATH. `loadKeyring` in
44
+ * packages/float-adapter/src/keyring.ts accepts an entry only when it has a
45
+ * string `agentId` and a string `secret`:
46
+ *
47
+ * if (e && typeof e.agentId === "string" && typeof e.secret === "string")
48
+ *
49
+ * Anything else is skipped in silence — no warning, no error, the file simply
50
+ * contributes nothing. `float-agent.mjs` writes
51
+ * `{kind, vault, node, publicKey, privateKey}` into this same file, which that
52
+ * condition rejects on both counts. So the convenient-looking shape is the
53
+ * dead one, and this writes the shape that loads.
54
+ *
55
+ * `agentId` is the VAULT, not a separate account. A virtual agent has no
56
+ * account of its own: its key is a NEP-611 gas key installed ON the vault, so
57
+ * the account it signs as is the vault itself — which is also why
58
+ * `NEAR_VAULT_AGENT_ID` is set to a vault id in `.mcp.json`.
59
+ */
60
+ export function generateAgentKey(opts) {
61
+ const dir = opts.dir ?? ".float";
62
+ // `undefined` -> root (the creation-time agent). `null` -> unknown, and the
63
+ // field is omitted rather than guessed.
64
+ const nodeId = opts.nodeId === null ? undefined : (opts.nodeId ?? AGENT_NODE_ID);
65
+ const keyringPath = join(dir, "agent-keys.json");
66
+ const envPath = join(dir, "agent.env");
67
+ const kp = KeyPair.fromRandom("ed25519");
68
+ const publicKey = kp.getPublicKey().toString();
69
+ const secret = kp.toString();
70
+ mkdirSync(dir, { recursive: true });
71
+ // APPEND, NEVER REPLACE. A vault may have several agents, and more to the
72
+ // point: overwriting would destroy the credential of an agent that is
73
+ // already bound on chain and working. An on-chain binding cannot be undone
74
+ // by deleting a file, so a lost secret is a permanently stranded agent.
75
+ let existing = [];
76
+ let backedUpTo;
77
+ if (existsSync(keyringPath)) {
78
+ try {
79
+ const parsed = JSON.parse(readFileSync(keyringPath, "utf8"));
80
+ if (Array.isArray(parsed))
81
+ existing = parsed;
82
+ else
83
+ throw new Error("not an array");
84
+ }
85
+ catch {
86
+ // A malformed keyring must not be parsed into oblivion. Move it aside
87
+ // rather than overwrite it — whatever is in there may be the only copy
88
+ // of a working secret, and this is not the moment to decide it is junk.
89
+ backedUpTo = `${keyringPath}.unreadable-${publicKey.slice(8, 16)}`;
90
+ copyFileSync(keyringPath, backedUpTo);
91
+ existing = [];
92
+ }
93
+ }
94
+ existing.push({
95
+ agentId: opts.vault,
96
+ secret,
97
+ publicKey,
98
+ role: opts.role,
99
+ // Only when known. `JSON.stringify` drops an undefined value, so an
100
+ // unknown node leaves no field at all — which is what `loadKeyring`
101
+ // reads as "selectable by role, not by node".
102
+ ...(typeof nodeId === "number" ? { nodeId } : {}),
103
+ });
104
+ writeFileSync(keyringPath, JSON.stringify(existing, null, 2) + "\n");
105
+ // The env form the MCP server itself reads. Written second and separately
106
+ // because it holds ONE agent, while the keyring holds every agent — so the
107
+ // keyring is the durable record and this is a convenience.
108
+ writeFileSync(envPath, [
109
+ `# Written by vault_create. Contains a PRIVATE KEY — do not commit or paste.`,
110
+ `# The agent can only call request_payment on this vault, inside its budget.`,
111
+ `NEAR_VAULT_AGENT_ID=${opts.vault}`,
112
+ `NEAR_VAULT_AGENT_KEY=${secret}`,
113
+ ``,
114
+ ].join("\n"));
115
+ // Best effort. Windows ignores POSIX modes, so this must not be load-bearing
116
+ // — the real protection is that the key is restricted on chain.
117
+ for (const p of [keyringPath, envPath]) {
118
+ try {
119
+ chmodSync(p, 0o600);
120
+ }
121
+ catch {
122
+ /* not POSIX; the on-chain restriction is the actual boundary */
123
+ }
124
+ }
125
+ return { publicKey, role: opts.role, keyringPath, envPath, backedUpTo };
126
+ }
127
+ /** Where the files would go, without creating anything. */
128
+ export function agentKeyPaths(dir = ".float") {
129
+ return { keyringPath: join(dir, "agent-keys.json"), envPath: join(dir, "agent.env"), dir: dirname(join(dir, "x")) };
130
+ }
@@ -0,0 +1,186 @@
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
+ * 298,819 -> 298,920 on 2026-11-08, when `new` began giving a creation-time
28
+ * agent its OWN child node instead of binding it to root. +101 bytes, about
29
+ * 0.00101 NEAR more per vault. The pin caught this one too, on the first gate
30
+ * run after the rebuild.
31
+ *
32
+ * Mirrors `packages/create-float/res/vault.wasm`, and `create.pins.test.ts`
33
+ * reads that file and fails if the two disagree — so a contract rebuild cannot
34
+ * silently leave this quoting a deposit that is too small. Too small is not a
35
+ * clean failure: CreateAccount succeeds and the vault sits below its own
36
+ * storage requirement, bricking on the first write.
37
+ */
38
+ export declare const CONTRACT_BYTES = 298920;
39
+ /** NEAR charges 1e19 yoctoNEAR per byte of storage. */
40
+ export declare const STORAGE_COST_PER_BYTE: bigint;
41
+ /**
42
+ * What `web/index.html` adds on top of the code's storage stake.
43
+ *
44
+ * THE FACTORY'S FLOOR IS NOT WHAT THE USER IS ASKED FOR, and conflating the
45
+ * two quoted a deposit 0.26 NEAR too low. `begin()` in
46
+ * contracts/factory/src/lib.rs rejects below `required_deposit + bytes * 1e19`
47
+ * — a real floor, 3.037280 on testnet today. But the page attaches
48
+ * `stake + 0.36` instead, as headroom for the root node's state, and a wallet
49
+ * checks the balance against what the PAGE attaches.
50
+ *
51
+ * Measured 2026-11-03: a vault created through the page,
52
+ * tinyrice9138-testnet.fac.floatv4.testnet, holds exactly 3.297280 — the
53
+ * page's figure to the yoctoNEAR. A user funded to the old 3.237280 quote is
54
+ * rejected by their wallet needing 3.497280, which is precisely the failure
55
+ * GAS_RESERVATION_YOCTO below was written to prevent, arriving through the
56
+ * other term.
57
+ *
58
+ * This is a COPY of a value in a file that cannot import from here — the page
59
+ * is standalone HTML served to a browser with no build step. `create.pins.
60
+ * test.ts` reads that file and fails if the two disagree, the same way
61
+ * CONTRACT_BYTES and CREATE_GAS_TGAS are pinned. A copy nothing checks is a
62
+ * copy that drifts.
63
+ */
64
+ export declare const PAGE_HEADROOM_YOCTO: bigint;
65
+ /**
66
+ * Funding for an agent's gas key, when one is bound at creation.
67
+ *
68
+ * A NEP-611 gas key's BALANCE is its spending cap — `allowance` is null by
69
+ * design — so this is the only thing bounding how much gas the agent burns.
70
+ * 0.12 NEAR ≈ 50 payments at the vault's 50-TGas `MIN_GAS_FOR_DISPATCH`
71
+ * floor, matching `gasKeyFunding({tgas:60, payments:50})` in create-float.
72
+ *
73
+ * Charged ONLY when an agent is bound. The factory raises its own on-chain
74
+ * deposit floor by exactly this amount in that case, so a quote that omits it
75
+ * is refused with DEPOSIT_TOO_SMALL — and a quote that always includes it
76
+ * overcharges everyone who creates a bare vault.
77
+ *
78
+ * A COPY of `AGENT_GAS_KEY_FUNDING` in contracts/factory/src/lib.rs and
79
+ * `AGENT_GAS` in web/index.html. Three files, one number, which is exactly
80
+ * the shape that already shipped a 0.26 NEAR underquote here — so
81
+ * `create.pins.test.ts` reads both of the others and fails if any disagree.
82
+ */
83
+ export declare const AGENT_GAS_KEY_FUNDING_YOCTO: bigint;
84
+ /**
85
+ * Gas the create page attaches. MEASURED commitment is 124.88 TGas:
86
+ * 42.79 to deliver and decode the 401 KB base64 argument (measured on chain),
87
+ * plus CreateAccount 7.70, Transfer 0.23, DeployContract 34.16, and the
88
+ * factory's forwarded 30 + 10. 200 keeps ~60% headroom.
89
+ *
90
+ * Must match `web/index.html`. It is not a free choice — see
91
+ * `GAS_RESERVATION_YOCTO`.
92
+ */
93
+ export declare const CREATE_GAS_TGAS = 200n;
94
+ /**
95
+ * What the user must HOLD on top of the deposit, at signing time.
96
+ *
97
+ * A transaction reserves `prepaid_gas * 1e9` — the MAXIMUM gas price — for the
98
+ * duration of the call, and refunds the unused part a receipt later. Almost
99
+ * none of it is burned (~0.004 in practice), but the wallet checks the balance
100
+ * against the reservation and refuses below it. Quoting only the deposit is
101
+ * what produced:
102
+ *
103
+ * Sender work12.testnet does not have enough balance 3.5234
104
+ * for operation costing 3.674061500357969
105
+ *
106
+ * so the quoted total includes it.
107
+ */
108
+ export declare const GAS_RESERVATION_YOCTO: bigint;
109
+ /**
110
+ * Known factories.
111
+ *
112
+ * MAINNET IS DELIBERATELY ABSENT until one is deployed and verified. A wrong
113
+ * default here does not fail safely: it sends a real user with real NEAR to
114
+ * sign a 3.4 NEAR deposit into an account we do not control. Absent means the
115
+ * tool asks, which is the correct behaviour for a value nobody has measured.
116
+ */
117
+ export declare const FACTORY_BY_NETWORK: Record<string, string | undefined>;
118
+ export interface CreatePlanInput {
119
+ owner: string;
120
+ /**
121
+ * False when the caller does not know the user's NEAR account.
122
+ *
123
+ * An agent onboarding a stranger has no account id to pass, and demanding
124
+ * one assumes the user knows it, types it correctly, and has one at all.
125
+ * The page derives everything from the CONNECTED wallet anyway, so an
126
+ * owner-less link is not degraded — it asks the wallet instead of the human.
127
+ */
128
+ ownerKnown?: boolean;
129
+ /** From the factory's `vault_id_for`. `null` means no legal derived id. */
130
+ vaultId: string | null;
131
+ factory: string;
132
+ network: string;
133
+ /** From the factory's `get_config`. */
134
+ requiredDepositYocto: string;
135
+ paused: boolean;
136
+ appUrl: string;
137
+ /** Whether the derived account is already on chain. */
138
+ exists?: boolean;
139
+ /**
140
+ * The EXISTING account's own `get_config.owner`, when it could be read.
141
+ *
142
+ * WHY THIS IS NOT OPTIONAL REASONING. "The account at your derived id exists,
143
+ * therefore it is yours" is an inference from the derivation, not a fact
144
+ * about the account — and `derive_prefix` IS NOT INJECTIVE. The factory says
145
+ * so in capitals, and `derive_prefix_collides_between_a_dot_and_a_hyphen`
146
+ * asserts it: `a.b.testnet` and `a-b.testnet` both land on `a-b-testnet`,
147
+ * because replacing the dot merges it into an alphabet that already contains
148
+ * the hyphen. Whoever creates first takes the id.
149
+ *
150
+ * Without reading the vault back, this tool would tell the LOSER of that
151
+ * collision to point their agent at the WINNER's vault. Narrow, but the
152
+ * failure is "you are now configured against someone else's money".
153
+ *
154
+ * `null` means the read failed, or the account is not a Float vault at all.
155
+ * That is a THIRD outcome and must not be flattened into either of the
156
+ * others — "I could not check" is not "it is yours".
157
+ */
158
+ existingOwner?: string | null;
159
+ /**
160
+ * The agent public key to bind at creation, if any.
161
+ *
162
+ * Only affects the QUOTE here — the server neither generates nor holds the
163
+ * secret half, and the page still asks the user to confirm the key before
164
+ * anything is signed.
165
+ */
166
+ agentPk?: string | null;
167
+ }
168
+ export interface CreatePlan {
169
+ owner: string | null;
170
+ /** Null when the owner is unknown — the wallet decides it at signing time. */
171
+ vault: string | null;
172
+ factory: string;
173
+ network: string;
174
+ depositYocto: string;
175
+ depositNear: string;
176
+ gasTgas: number;
177
+ gasReservationNear: string;
178
+ totalRequiredYocto: string;
179
+ totalRequiredNear: string;
180
+ url: string;
181
+ warnings: string[];
182
+ note: string;
183
+ }
184
+ /** yoctoNEAR -> a NEAR string, without floating point. */
185
+ export declare function yoctoToNear(y: bigint, dp?: number): string;
186
+ export declare function buildCreatePlan(i: CreatePlanInput): CreatePlan;
package/dist/create.js ADDED
@@ -0,0 +1,240 @@
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
+ * 298,819 -> 298,920 on 2026-11-08, when `new` began giving a creation-time
28
+ * agent its OWN child node instead of binding it to root. +101 bytes, about
29
+ * 0.00101 NEAR more per vault. The pin caught this one too, on the first gate
30
+ * run after the rebuild.
31
+ *
32
+ * Mirrors `packages/create-float/res/vault.wasm`, and `create.pins.test.ts`
33
+ * reads that file and fails if the two disagree — so a contract rebuild cannot
34
+ * silently leave this quoting a deposit that is too small. Too small is not a
35
+ * clean failure: CreateAccount succeeds and the vault sits below its own
36
+ * storage requirement, bricking on the first write.
37
+ */
38
+ export const CONTRACT_BYTES = 298920;
39
+ /** NEAR charges 1e19 yoctoNEAR per byte of storage. */
40
+ export const STORAGE_COST_PER_BYTE = 10n ** 19n;
41
+ /**
42
+ * What `web/index.html` adds on top of the code's storage stake.
43
+ *
44
+ * THE FACTORY'S FLOOR IS NOT WHAT THE USER IS ASKED FOR, and conflating the
45
+ * two quoted a deposit 0.26 NEAR too low. `begin()` in
46
+ * contracts/factory/src/lib.rs rejects below `required_deposit + bytes * 1e19`
47
+ * — a real floor, 3.037280 on testnet today. But the page attaches
48
+ * `stake + 0.36` instead, as headroom for the root node's state, and a wallet
49
+ * checks the balance against what the PAGE attaches.
50
+ *
51
+ * Measured 2026-11-03: a vault created through the page,
52
+ * tinyrice9138-testnet.fac.floatv4.testnet, holds exactly 3.297280 — the
53
+ * page's figure to the yoctoNEAR. A user funded to the old 3.237280 quote is
54
+ * rejected by their wallet needing 3.497280, which is precisely the failure
55
+ * GAS_RESERVATION_YOCTO below was written to prevent, arriving through the
56
+ * other term.
57
+ *
58
+ * This is a COPY of a value in a file that cannot import from here — the page
59
+ * is standalone HTML served to a browser with no build step. `create.pins.
60
+ * test.ts` reads that file and fails if the two disagree, the same way
61
+ * CONTRACT_BYTES and CREATE_GAS_TGAS are pinned. A copy nothing checks is a
62
+ * copy that drifts.
63
+ */
64
+ export const PAGE_HEADROOM_YOCTO = 36n * 10n ** 22n;
65
+ /**
66
+ * Funding for an agent's gas key, when one is bound at creation.
67
+ *
68
+ * A NEP-611 gas key's BALANCE is its spending cap — `allowance` is null by
69
+ * design — so this is the only thing bounding how much gas the agent burns.
70
+ * 0.12 NEAR ≈ 50 payments at the vault's 50-TGas `MIN_GAS_FOR_DISPATCH`
71
+ * floor, matching `gasKeyFunding({tgas:60, payments:50})` in create-float.
72
+ *
73
+ * Charged ONLY when an agent is bound. The factory raises its own on-chain
74
+ * deposit floor by exactly this amount in that case, so a quote that omits it
75
+ * is refused with DEPOSIT_TOO_SMALL — and a quote that always includes it
76
+ * overcharges everyone who creates a bare vault.
77
+ *
78
+ * A COPY of `AGENT_GAS_KEY_FUNDING` in contracts/factory/src/lib.rs and
79
+ * `AGENT_GAS` in web/index.html. Three files, one number, which is exactly
80
+ * the shape that already shipped a 0.26 NEAR underquote here — so
81
+ * `create.pins.test.ts` reads both of the others and fails if any disagree.
82
+ */
83
+ export const AGENT_GAS_KEY_FUNDING_YOCTO = 12n * 10n ** 22n;
84
+ /**
85
+ * Gas the create page attaches. MEASURED commitment is 124.88 TGas:
86
+ * 42.79 to deliver and decode the 401 KB base64 argument (measured on chain),
87
+ * plus CreateAccount 7.70, Transfer 0.23, DeployContract 34.16, and the
88
+ * factory's forwarded 30 + 10. 200 keeps ~60% headroom.
89
+ *
90
+ * Must match `web/index.html`. It is not a free choice — see
91
+ * `GAS_RESERVATION_YOCTO`.
92
+ */
93
+ export const CREATE_GAS_TGAS = 200n;
94
+ /**
95
+ * What the user must HOLD on top of the deposit, at signing time.
96
+ *
97
+ * A transaction reserves `prepaid_gas * 1e9` — the MAXIMUM gas price — for the
98
+ * duration of the call, and refunds the unused part a receipt later. Almost
99
+ * none of it is burned (~0.004 in practice), but the wallet checks the balance
100
+ * against the reservation and refuses below it. Quoting only the deposit is
101
+ * what produced:
102
+ *
103
+ * Sender work12.testnet does not have enough balance 3.5234
104
+ * for operation costing 3.674061500357969
105
+ *
106
+ * so the quoted total includes it.
107
+ */
108
+ export const GAS_RESERVATION_YOCTO = CREATE_GAS_TGAS * 10n ** 12n * 10n ** 9n;
109
+ /**
110
+ * Known factories.
111
+ *
112
+ * MAINNET IS DELIBERATELY ABSENT until one is deployed and verified. A wrong
113
+ * default here does not fail safely: it sends a real user with real NEAR to
114
+ * sign a 3.4 NEAR deposit into an account we do not control. Absent means the
115
+ * tool asks, which is the correct behaviour for a value nobody has measured.
116
+ */
117
+ export const FACTORY_BY_NETWORK = {
118
+ testnet: "fac.floatv4.testnet",
119
+ mainnet: undefined,
120
+ };
121
+ /** yoctoNEAR -> a NEAR string, without floating point. */
122
+ export function yoctoToNear(y, dp = 5) {
123
+ const neg = y < 0n;
124
+ const v = neg ? -y : y;
125
+ const whole = v / 10n ** 24n;
126
+ const frac = (v % 10n ** 24n).toString().padStart(24, "0").slice(0, dp);
127
+ return `${neg ? "-" : ""}${whole}.${frac}`;
128
+ }
129
+ export function buildCreatePlan(i) {
130
+ if (i.paused) {
131
+ throw new Error(`The factory ${i.factory} is paused — vault creation is disabled. ` +
132
+ `This is the operator's kill switch; try again later.`);
133
+ }
134
+ // UNKNOWN OWNER IS A VALID CASE, not a missing argument. Everything below
135
+ // that depends on the owner is skipped rather than guessed: no derived
136
+ // address, no existence check, and no `owner` on the url — the page refuses
137
+ // when that parameter disagrees with the connected wallet, so a blank or
138
+ // invented one would turn every link into a refusal.
139
+ const known = i.ownerKnown !== false && !!i.owner;
140
+ if (known && !i.vaultId) {
141
+ throw new Error(`${i.owner} has no legal vault id under ${i.factory}. ` +
142
+ `An implicit account (a 64-character hex id) cannot have one: the derived ` +
143
+ `prefix is already 64 characters, so "<prefix>.${i.factory}" exceeds NEAR's ` +
144
+ `64-character limit. Use a named account.`);
145
+ }
146
+ if (known && i.exists) {
147
+ // Three outcomes, deliberately not two. The old message asserted the
148
+ // first one for all three.
149
+ if (i.existingOwner && i.existingOwner === i.owner) {
150
+ throw new Error(`${i.vaultId} already exists and ${i.owner} owns it — confirmed by reading ` +
151
+ `its get_config. Connect to it with NEAR_VAULT_CONTRACT=${i.vaultId} ` +
152
+ `rather than creating another.`);
153
+ }
154
+ if (i.existingOwner) {
155
+ throw new Error(`${i.vaultId} already exists and is owned by ${i.existingOwner}, NOT by ` +
156
+ `${i.owner}. Do NOT point an agent at it.\n\n` +
157
+ `The factory derives a vault id by replacing dots with hyphens, and that ` +
158
+ `is not injective: "a.b.near" and "a-b.near" both derive "a-b-near", and ` +
159
+ `whoever created first holds the id. Create yours under a label instead, ` +
160
+ `which puts it at a different address.`);
161
+ }
162
+ throw new Error(`${i.vaultId} already exists, but its owner could NOT be read, so whether it ` +
163
+ `is yours is unknown — not assumed. It may not be a Float vault at all. ` +
164
+ `Check it before pointing anything at it:\n` +
165
+ ` near view ${i.vaultId} get_config`);
166
+ }
167
+ // Two different floors, and the quote must clear BOTH.
168
+ //
169
+ // pageAttaches what the wallet is actually asked to approve
170
+ // factoryFloor what `begin()` rejects below, on chain
171
+ //
172
+ // They are independent: the page's headroom is a fixed constant while the
173
+ // factory's floor moves with `set_required_deposit`. Quoting either one
174
+ // alone is what broke — the old code quoted the floor, and the page then
175
+ // asked for 0.26 more.
176
+ const stake = BigInt(CONTRACT_BYTES) * STORAGE_COST_PER_BYTE;
177
+ // Both floors move by the gas-key funding when an agent is bound: the page
178
+ // attaches it, and the factory requires it.
179
+ const agentFunding = i.agentPk ? AGENT_GAS_KEY_FUNDING_YOCTO : 0n;
180
+ const pageAttaches = stake + PAGE_HEADROOM_YOCTO + agentFunding;
181
+ const factoryFloor = BigInt(i.requiredDepositYocto) + stake + agentFunding;
182
+ const deposit = pageAttaches > factoryFloor ? pageAttaches : factoryFloor;
183
+ const total = deposit + GAS_RESERVATION_YOCTO;
184
+ // If the operator raises `required_deposit` past the page's headroom, the
185
+ // page under-attaches and the create is rejected ON CHAIN with
186
+ // DEPOSIT_TOO_SMALL. Quoting the higher figure does not rescue that — the
187
+ // user would hold enough and still fail, because the page attaches the
188
+ // lesser amount regardless of what they hold. Say so rather than quoting a
189
+ // number that cannot help.
190
+ const underAttachWarning = factoryFloor > pageAttaches
191
+ ? [
192
+ `WARNING: ${i.factory} now requires ${yoctoToNear(factoryFloor)} NEAR, but the ` +
193
+ `create page attaches only ${yoctoToNear(pageAttaches)}. The transaction will be ` +
194
+ `rejected on chain with DEPOSIT_TOO_SMALL no matter how much you hold. The page's ` +
195
+ `headroom constant needs raising — this is an operator bug, not a funding problem.`,
196
+ ]
197
+ : [];
198
+ const url = new URL(i.appUrl);
199
+ if (known)
200
+ url.searchParams.set("owner", i.owner);
201
+ if (i.agentPk)
202
+ url.searchParams.set("agent_pk", i.agentPk);
203
+ url.searchParams.set("factory", i.factory);
204
+ url.searchParams.set("network", i.network);
205
+ const unknownOwnerWarnings = known
206
+ ? []
207
+ : [
208
+ `The vault address is not known yet — it is derived from whichever wallet ` +
209
+ `you connect, and the page will show it before you approve anything.`,
210
+ `Nothing has checked whether you already have a vault. If you do, the page ` +
211
+ `will say so rather than creating a second one.`,
212
+ ];
213
+ return {
214
+ owner: known ? i.owner : null,
215
+ vault: known ? i.vaultId : null,
216
+ factory: i.factory,
217
+ network: i.network,
218
+ depositYocto: deposit.toString(),
219
+ depositNear: yoctoToNear(deposit),
220
+ gasTgas: Number(CREATE_GAS_TGAS),
221
+ gasReservationNear: yoctoToNear(GAS_RESERVATION_YOCTO),
222
+ totalRequiredYocto: total.toString(),
223
+ totalRequiredNear: yoctoToNear(total),
224
+ url: url.toString(),
225
+ warnings: [
226
+ ...underAttachWarning,
227
+ ...unknownOwnerWarnings,
228
+ `Almost all of the ${yoctoToNear(deposit)} NEAR is a refundable storage ` +
229
+ `deposit held by your vault, not a fee.`,
230
+ `You must HOLD ${yoctoToNear(total)} NEAR at signing time: the deposit plus a ` +
231
+ `${yoctoToNear(GAS_RESERVATION_YOCTO)} gas reservation. The reservation is ` +
232
+ `refunded moments later — only about 0.004 is actually spent — but a wallet ` +
233
+ `refuses the transaction if the balance is below it.`,
234
+ `Nothing can spend from this vault until you add an agent and give it a budget.`,
235
+ ],
236
+ note: `This server never sees or holds a key. Open the URL and approve the ` +
237
+ `transaction in your own wallet; the contract travels as an argument to ` +
238
+ `one ordinary function call, and the factory deploys it for you.`,
239
+ };
240
+ }