@floatonnear/float-adapter 0.0.0-stage → 0.1.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,210 @@
1
+ /**
2
+ * The /v1 endpoints the existing Float dashboard already calls, served from
3
+ * NEAR vault views.
4
+ *
5
+ * `view` is injected so every handler is testable without a chain — the
6
+ * mapping and the shape of what the UI receives are exactly where a silent
7
+ * wrong-but-plausible number would come from.
8
+ */
9
+ import { type ApiAgent, type Payment, type VaultOutcome } from "./map.js";
10
+ export type ViewFn = <T>(method: string, args?: unknown) => Promise<T>;
11
+ /** What the relayer recorded about a Base delivery. */
12
+ export interface DeliveryRecord {
13
+ mode: "vault" | "operator";
14
+ baseTxHash: string;
15
+ payee: string;
16
+ amount: string;
17
+ at: string;
18
+ }
19
+ export interface AdapterConfig {
20
+ token: string;
21
+ decimals: number;
22
+ /**
23
+ * Base deliveries, keyed by request id, as the relayer recorded them.
24
+ *
25
+ * OFF-CHAIN ON PURPOSE. The NEAR budget already moved when the vault signed;
26
+ * delivery on Base is something an operator observed, not something the
27
+ * contract can attest. Keeping it in a separate map means a missing or lying
28
+ * ledger can only ever UNDERSTATE delivery — it can never invent budget or
29
+ * contradict the chain.
30
+ */
31
+ deliveries?: Record<string, DeliveryRecord>;
32
+ /** Bounded, in-memory. See `recordBlocked`. */
33
+ blockedRing: Payment[];
34
+ maxBlocked: number;
35
+ /**
36
+ * Refusals recovered FROM CHAIN by scripts/scan-blocked.mjs, keyed by
37
+ * request id.
38
+ *
39
+ * The in-memory ring above only held refusals this process personally
40
+ * witnessed, so the most important thing the vault does — refusing a payment
41
+ * because a SHARED pool is empty — vanished on restart and was invisible for
42
+ * any payment made by another client. A refused request is deliberately not
43
+ * stored in contract state (that is the storage griefing vector of §2), but
44
+ * the NEP-297 event survives in the receipt and can be read back. These rows
45
+ * carry a tx hash and are therefore independently checkable. The ring's are
46
+ * not.
47
+ */
48
+ chainBlocked?: Record<string, ChainBlocked>;
49
+ /**
50
+ * The block range the scanner has actually covered. Without it, "no
51
+ * refusals happened" and "we have not looked there" render identically —
52
+ * which is the confusion this feature exists to remove.
53
+ */
54
+ blockedScan?: {
55
+ from: number;
56
+ to: number;
57
+ /** Spans inside from..to that were never read. Unknown, not empty. */
58
+ holes?: Array<{
59
+ from: number;
60
+ to: number;
61
+ }>;
62
+ } | null;
63
+ }
64
+ /** One refusal, as recovered from a receipt outcome. */
65
+ export interface ChainBlocked {
66
+ requestId: string;
67
+ nodeId: number;
68
+ reasonCode: string;
69
+ agent: string;
70
+ txHash: string;
71
+ blockHeight: number;
72
+ blockTimestampNs: string | number;
73
+ }
74
+ /**
75
+ * Blocked attempts are NOT stored on-chain — the hot path returns them as a
76
+ * value and emits an event, deliberately, so an agent cannot grow the vault's
77
+ * storage bill by spamming rejects.
78
+ *
79
+ * So the adapter remembers the ones it SEES, in a bounded in-memory ring. That
80
+ * covers blocks made through this product, which is what the dashboard is
81
+ * showing. Blocks made by another client, or before this process started, are
82
+ * not here and cannot be — the dashboard says so rather than implying none
83
+ * happened.
84
+ */
85
+ /**
86
+ * Chain-recovered refusals as Payment rows.
87
+ *
88
+ * Deduped against the in-memory ring by request id, preferring the chain copy
89
+ * — it is the one that carries a tx hash a reader can check for themselves.
90
+ */
91
+ export declare function chainBlockedPayments(cfg: AdapterConfig, nodeLabels: Record<number, string>): Payment[];
92
+ export declare function recordBlocked(cfg: AdapterConfig, p: Payment): void;
93
+ /** Where this vault lives, so the UI never has to hardcode a chain. */
94
+ export interface VaultInfo {
95
+ vaultId: string;
96
+ tokenId: string;
97
+ tokenDecimals: number;
98
+ network: string;
99
+ /** NEAR Explorer base for the network the vault is actually on. */
100
+ explorerBase: string;
101
+ owner: string;
102
+ paused: boolean;
103
+ clockMode: string;
104
+ strictAllocation: boolean;
105
+ /**
106
+ * The vault's ACTUAL NEP-141 balance, base units.
107
+ *
108
+ * Deliberately separate from `treasury.amount` on /v1/dashboard, which is the
109
+ * root node's remaining DAILY BUDGET. They are different quantities and the
110
+ * funding page needs this one: fund 10,000 USDC against a 100/day root cap
111
+ * and the budget figure still reads 40, which would look like the deposit
112
+ * never arrived. Null when the token contract cannot be read.
113
+ */
114
+ vaultTokenBalance: string | null;
115
+ }
116
+ /**
117
+ * `near` / `testnet` / anything else. Derived from the network the contract
118
+ * reports rather than from an env var, so a UI pointed at testnet cannot link
119
+ * to a mainnet explorer and show "not found".
120
+ */
121
+ export declare function explorerBaseFor(network: string): string;
122
+ /** A view against an arbitrary contract — the token, not the vault. */
123
+ export type ViewOnFn = (contractId: string, method: string, args?: unknown) => Promise<string | null>;
124
+ export declare function getVaultInfo(view: ViewFn, cfg: AdapterConfig, viewOn: ViewOnFn): Promise<VaultInfo>;
125
+ export interface AgentKeyView {
126
+ accountId: string;
127
+ nodeId: number;
128
+ label: string;
129
+ frozen: boolean;
130
+ keys: {
131
+ publicKey: string;
132
+ /** True only if the key really is a function-call key. */
133
+ restricted: boolean;
134
+ receiverId: string | null;
135
+ methodNames: string[];
136
+ allowanceYocto: string | null;
137
+ }[];
138
+ /** Set when the key list could not be read, so the UI can say so. */
139
+ error?: string;
140
+ }
141
+ export type KeyFn = (accountId: string) => Promise<{
142
+ public_key: string;
143
+ access_key: {
144
+ permission: "FullAccess" | {
145
+ FunctionCall: {
146
+ allowance: string | null;
147
+ receiver_id: string;
148
+ method_names: string[];
149
+ };
150
+ };
151
+ };
152
+ }[]>;
153
+ /**
154
+ * The bound agent accounts, with their access keys read FROM THE CHAIN.
155
+ *
156
+ * The restriction is the product's central security claim, so the page that
157
+ * states it must not state it from a constant. A hardcoded "restricted to
158
+ * request_payment" would keep rendering happily if someone added a full-access
159
+ * key to an agent account — exactly the regression a reader of that page is
160
+ * trying to rule out. `restricted` here is computed from the permission NEAR
161
+ * reports, and a FullAccess key makes it false.
162
+ */
163
+ export declare function getAgentKeys(view: ViewFn, keys: KeyFn, _cfg: AdapterConfig): Promise<AgentKeyView[]>;
164
+ export declare function getDashboard(view: ViewFn, cfg: AdapterConfig): Promise<{
165
+ owner: {
166
+ authMethod: string;
167
+ walletAddress: string;
168
+ displayName: string;
169
+ };
170
+ treasury: {
171
+ ownerId: string;
172
+ walletAddress: string;
173
+ createdAt: string;
174
+ amount: string;
175
+ currency: string;
176
+ decimals: number;
177
+ };
178
+ agents: ApiAgent[];
179
+ blockedCoverage: {
180
+ from: number;
181
+ to: number;
182
+ /** Spans inside from..to that were never read. Unknown, not empty. */
183
+ holes?: Array<{
184
+ from: number;
185
+ to: number;
186
+ }>;
187
+ } | null | undefined;
188
+ transactionStats: {
189
+ total: number;
190
+ approved: number;
191
+ blocked: number;
192
+ failed: number;
193
+ other: number;
194
+ };
195
+ recentActivity: {
196
+ id: string;
197
+ agent: string;
198
+ venue: string;
199
+ amount: string;
200
+ rail: string;
201
+ time: string;
202
+ status: string;
203
+ }[];
204
+ paused: boolean;
205
+ }>;
206
+ export declare function getSpendHistory(view: ViewFn, cfg: AdapterConfig, limit: number, offset: number): Promise<{
207
+ payments: Payment[];
208
+ }>;
209
+ /** Called by the pay endpoint so a block the product caused shows up as a row. */
210
+ export declare function paymentFromOutcome(cfg: AdapterConfig, outcome: VaultOutcome, labels: Record<number, string>, amountHuman: string, recipient: string): Payment | null;
@@ -0,0 +1,246 @@
1
+ /**
2
+ * The /v1 endpoints the existing Float dashboard already calls, served from
3
+ * NEAR vault views.
4
+ *
5
+ * `view` is injected so every handler is testable without a chain — the
6
+ * mapping and the shape of what the UI receives are exactly where a silent
7
+ * wrong-but-plausible number would come from.
8
+ */
9
+ import { blockedToPayment, lifetimeByNode, requestToPayment, REASONS, treeToApiAgents, } from "./map.js";
10
+ /**
11
+ * Blocked attempts are NOT stored on-chain — the hot path returns them as a
12
+ * value and emits an event, deliberately, so an agent cannot grow the vault's
13
+ * storage bill by spamming rejects.
14
+ *
15
+ * So the adapter remembers the ones it SEES, in a bounded in-memory ring. That
16
+ * covers blocks made through this product, which is what the dashboard is
17
+ * showing. Blocks made by another client, or before this process started, are
18
+ * not here and cannot be — the dashboard says so rather than implying none
19
+ * happened.
20
+ */
21
+ /**
22
+ * Chain-recovered refusals as Payment rows.
23
+ *
24
+ * Deduped against the in-memory ring by request id, preferring the chain copy
25
+ * — it is the one that carries a tx hash a reader can check for themselves.
26
+ */
27
+ export function chainBlockedPayments(cfg, nodeLabels) {
28
+ const seen = new Set(cfg.blockedRing.map((p) => p.id));
29
+ return Object.values(cfg.chainBlocked ?? {})
30
+ .filter((b) => !seen.has(b.requestId))
31
+ .map((b) => ({
32
+ id: b.requestId,
33
+ agentId: String(b.nodeId),
34
+ agentName: nodeLabels[b.nodeId] ?? `node-${b.nodeId}`,
35
+ // The amount is NOT in the event, and inventing one would be worse than
36
+ // showing none: a refused payment moved nothing, and a plausible figure
37
+ // would read as fact.
38
+ amount: "0",
39
+ currency: "USDC",
40
+ recipientAddress: "",
41
+ venue: "",
42
+ rail: "policy",
43
+ status: "blocked",
44
+ txSignature: b.txHash,
45
+ createdAt: new Date(Number(BigInt(b.blockTimestampNs) / 1000000n)).toISOString(),
46
+ blockReason: b.reasonCode,
47
+ blockMessage: REASONS[b.reasonCode] ?? b.reasonCode,
48
+ prompt: null,
49
+ resultUrl: null,
50
+ }));
51
+ }
52
+ export function recordBlocked(cfg, p) {
53
+ cfg.blockedRing.unshift(p);
54
+ if (cfg.blockedRing.length > cfg.maxBlocked)
55
+ cfg.blockedRing.length = cfg.maxBlocked;
56
+ }
57
+ async function loadTreeAndUsage(view, cfg) {
58
+ const tree = await view("get_tree", {});
59
+ const budgets = new Map();
60
+ const labels = {};
61
+ // CONCURRENT, not sequential. One view per node is bounded by MAX_NODES (64),
62
+ // but issuing them in series costs a full testnet round-trip EACH: measured
63
+ // at 30s for a five-node tree, which is not a dashboard, it is a progress
64
+ // bar. Only `get_tree` is a real dependency; the per-node budgets depend on
65
+ // nothing but it. Pinned by the concurrency test in handlers.test.ts.
66
+ for (const n of tree)
67
+ labels[n.id] = n.label;
68
+ const fetched = await Promise.all(tree.map((n) => view("get_node_budget", {
69
+ node_id: n.id,
70
+ token: cfg.token,
71
+ day: null,
72
+ })));
73
+ tree.forEach((n, i) => {
74
+ const b = fetched[i];
75
+ budgets.set(n.id, b ? { executed: b.executed, pending: b.pending } : { executed: "0", pending: "0" });
76
+ });
77
+ return { tree, budgets, labels };
78
+ }
79
+ /**
80
+ * `near` / `testnet` / anything else. Derived from the network the contract
81
+ * reports rather than from an env var, so a UI pointed at testnet cannot link
82
+ * to a mainnet explorer and show "not found".
83
+ */
84
+ export function explorerBaseFor(network) {
85
+ return network === "mainnet" || network === "near"
86
+ ? "https://nearblocks.io"
87
+ : "https://testnet.nearblocks.io";
88
+ }
89
+ export async function getVaultInfo(view, cfg, viewOn) {
90
+ // `get_base_config` used to be read here alongside `get_config`. The
91
+ // MPC/Base rail and its config were removed from the contract on
92
+ // 2026-10-28, so there is no method left to call and no rail to report.
93
+ const c = await view("get_config", {});
94
+ // Read off the TOKEN contract, not the vault — so it is the real balance and
95
+ // not an echo of the vault's own bookkeeping. Null rather than "0" on
96
+ // failure: zero would read as "the deposit is not here", which is a
97
+ // different claim from "we could not look".
98
+ const vaultTokenBalance = await viewOn(cfg.token, "ft_balance_of", {
99
+ account_id: c.vault_id,
100
+ }).catch(() => null);
101
+ return {
102
+ vaultId: c.vault_id,
103
+ tokenId: cfg.token,
104
+ tokenDecimals: cfg.decimals,
105
+ network: c.network,
106
+ explorerBase: explorerBaseFor(c.network),
107
+ owner: c.owner,
108
+ paused: c.paused,
109
+ clockMode: c.clock_mode,
110
+ strictAllocation: c.strict_allocation,
111
+ vaultTokenBalance,
112
+ };
113
+ }
114
+ /**
115
+ * The bound agent accounts, with their access keys read FROM THE CHAIN.
116
+ *
117
+ * The restriction is the product's central security claim, so the page that
118
+ * states it must not state it from a constant. A hardcoded "restricted to
119
+ * request_payment" would keep rendering happily if someone added a full-access
120
+ * key to an agent account — exactly the regression a reader of that page is
121
+ * trying to rule out. `restricted` here is computed from the permission NEAR
122
+ * reports, and a FullAccess key makes it false.
123
+ */
124
+ export async function getAgentKeys(view, keys, _cfg) {
125
+ const tree = await view("get_tree", {});
126
+ const bound = tree.filter((n) => n.account);
127
+ return Promise.all(bound.map(async (n) => {
128
+ const base = {
129
+ accountId: n.account,
130
+ nodeId: n.id,
131
+ label: n.label,
132
+ frozen: n.frozen,
133
+ };
134
+ try {
135
+ const list = await keys(n.account);
136
+ return {
137
+ ...base,
138
+ keys: list.map((k) => {
139
+ const p = k.access_key.permission;
140
+ const fc = typeof p === "object" && "FunctionCall" in p ? p.FunctionCall : null;
141
+ return {
142
+ publicKey: k.public_key,
143
+ restricted: fc !== null,
144
+ receiverId: fc?.receiver_id ?? null,
145
+ methodNames: fc?.method_names ?? [],
146
+ allowanceYocto: fc?.allowance ?? null,
147
+ };
148
+ }),
149
+ };
150
+ }
151
+ catch (e) {
152
+ // An unreadable key list is reported, never silently rendered as "no
153
+ // keys" — that would read as "nothing is authorised here", the
154
+ // opposite of unknown.
155
+ return { ...base, keys: [], error: e instanceof Error ? e.message : String(e) };
156
+ }
157
+ }));
158
+ }
159
+ export async function getDashboard(view, cfg) {
160
+ const { tree, budgets, labels } = await loadTreeAndUsage(view, cfg);
161
+ // Also concurrent: neither depends on the other.
162
+ const [requests, cfgView] = await Promise.all([
163
+ view("list_requests", { from_index: 0, limit: 20 }),
164
+ view("get_config", {}),
165
+ ]);
166
+ const agents = treeToApiAgents(tree, budgets, cfg.decimals, lifetimeByNode(requests));
167
+ const onchain = requests.map((r) => requestToPayment(r, labels, cfg.decimals, cfg.deliveries?.[r.request_id]));
168
+ const recent = [...chainBlockedPayments(cfg, labels), ...cfg.blockedRing, ...onchain]
169
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
170
+ .slice(0, 20);
171
+ const root = tree.find((n) => n.parent === null);
172
+ const rootUsage = root ? budgets.get(root.id) : undefined;
173
+ return {
174
+ owner: { authMethod: "wallet", walletAddress: cfgView.owner, displayName: cfgView.owner },
175
+ treasury: {
176
+ ownerId: cfgView.owner,
177
+ walletAddress: cfgView.vault_id,
178
+ createdAt: new Date(0).toISOString(),
179
+ // The vault holds the tokens; the figure the dashboard cares about is
180
+ // how much of the ROOT budget is still available today.
181
+ amount: root?.limits[0]
182
+ ? (BigInt(root.limits[0].daily_cap) -
183
+ BigInt(rootUsage?.executed ?? "0") -
184
+ BigInt(rootUsage?.pending ?? "0")).toString()
185
+ : "0",
186
+ currency: "USDC",
187
+ decimals: cfg.decimals,
188
+ },
189
+ agents,
190
+ // What the refusal history actually covers. A UI that shows "0 blocked"
191
+ // without this cannot distinguish an honest zero from an unscanned range.
192
+ blockedCoverage: cfg.blockedScan,
193
+ transactionStats: {
194
+ total: onchain.length + cfg.blockedRing.length + Object.keys(cfg.chainBlocked ?? {}).length,
195
+ // The Base rail's terminal states count as approved. "success" alone was
196
+ // written when NEAR was the only rail; once Base payments report
197
+ // "signed"/"delivered" it rendered "4 transactions, 0 approved /
198
+ // 0 blocked" — every payment the vault had authorized vanished from the
199
+ // count. The UI's own list and this total disagreed on screen.
200
+ approved: onchain.filter((p) => ["success", "signed", "delivered"].includes(p.status)).length,
201
+ blocked: cfg.blockedRing.length + Object.keys(cfg.chainBlocked ?? {}).length,
202
+ failed: onchain.filter((p) => p.status === "failed").length,
203
+ other: onchain.filter((p) => p.status === "pending").length,
204
+ },
205
+ recentActivity: recent.map((p) => ({
206
+ id: p.id,
207
+ agent: p.agentName,
208
+ venue: p.venue,
209
+ amount: p.amount,
210
+ rail: p.rail,
211
+ time: p.createdAt,
212
+ status: p.status,
213
+ })),
214
+ paused: cfgView.paused,
215
+ };
216
+ }
217
+ export async function getSpendHistory(view, cfg, limit, offset) {
218
+ const tree = await view("get_tree", {});
219
+ const labels = {};
220
+ for (const n of tree)
221
+ labels[n.id] = n.label;
222
+ const requests = await view("list_requests", {
223
+ from_index: offset,
224
+ limit,
225
+ });
226
+ const onchain = requests.map((r) => requestToPayment(r, labels, cfg.decimals, cfg.deliveries?.[r.request_id]));
227
+ // Blocked rows only belong on the FIRST page: they are not part of the
228
+ // contract's paged list, so splicing them into later pages would duplicate
229
+ // them as the UI scrolls.
230
+ //
231
+ // Sorted by time rather than concatenated, because chain-recovered refusals
232
+ // can be older than the in-memory ones and a bare prepend would show them
233
+ // above newer activity.
234
+ const merged = offset === 0
235
+ ? [...chainBlockedPayments(cfg, labels), ...cfg.blockedRing, ...onchain].sort((a, b) => a.createdAt < b.createdAt ? 1 : -1)
236
+ : onchain;
237
+ return { payments: merged.slice(0, limit) };
238
+ }
239
+ /** Called by the pay endpoint so a block the product caused shows up as a row. */
240
+ export function paymentFromOutcome(cfg, outcome, labels, amountHuman, recipient) {
241
+ if (outcome.status !== "BLOCKED")
242
+ return null;
243
+ const p = blockedToPayment(outcome, labels, amountHuman, recipient, new Date().toISOString());
244
+ recordBlocked(cfg, p);
245
+ return p;
246
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Which agent signs a payment.
3
+ *
4
+ * The adapter used to hold exactly one agent credential, in
5
+ * NEAR_VAULT_AGENT_ID / NEAR_VAULT_AGENT_KEY, and `POST /v1/pay` always signed
6
+ * as that agent. The dashboard meanwhile renders the whole tree. So a vault
7
+ * with sixteen agents showed sixteen rows and could act as one of them — and
8
+ * the sixteen-agent demo had to bypass the product entirely and drive the CLI
9
+ * instead.
10
+ *
11
+ * This matters more than it looks, because the contract resolves the spending
12
+ * node from the SIGNER (`account_to_node`), not from anything in the request
13
+ * body. Which key signs *is* which budget is charged. A single-key adapter
14
+ * cannot exercise a hierarchy at all; it can only ever spend one leaf.
15
+ *
16
+ * On holding several keys at once: each is the restricted key from
17
+ * `create_agent_account`, which can call only `request_payment` on its own
18
+ * vault, cannot call other contracts, and cannot attach a deposit. The vault
19
+ * enforces every cap regardless of who holds the key, so the blast radius of
20
+ * the whole keyring is bounded by the tree's own limits. That is the same
21
+ * bound as for one key, applied more times.
22
+ */
23
+ export interface AgentCredential {
24
+ agentId: string;
25
+ secret: string;
26
+ /** Present when known; lets a caller select by node instead of account id. */
27
+ nodeId?: number;
28
+ }
29
+ export interface Keyring {
30
+ /** agent account id -> credential */
31
+ byAccount: Map<string, AgentCredential>;
32
+ /** node id -> credential */
33
+ byNode: Map<number, AgentCredential>;
34
+ /**
35
+ * Used when a request names no agent. Preserves the previous single-agent
36
+ * behaviour exactly, so existing deployments and the dashboard's own calls
37
+ * keep working untouched.
38
+ */
39
+ fallback?: AgentCredential;
40
+ }
41
+ export declare function emptyKeyring(): Keyring;
42
+ /**
43
+ * Build a keyring from the singular env vars plus, optionally, the JSON file
44
+ * `create-float` writes at `.float/agent-keys.json`:
45
+ *
46
+ * [{ "nodeId": 3, "agentId": "r1.float.me.testnet", "secret": "ed25519:..." }]
47
+ *
48
+ * The singular env pair stays authoritative for the fallback, so adding a file
49
+ * can never silently change which agent an un-targeted payment comes from.
50
+ */
51
+ export declare function loadKeyring(env: {
52
+ agentId?: string;
53
+ agentKey?: string;
54
+ agentKeysPath?: string;
55
+ }): Keyring;
56
+ export declare class AgentSelectionError extends Error {
57
+ }
58
+ /**
59
+ * Pick the credential for a payment request.
60
+ *
61
+ * Errors name every agent that IS available. A selection failure is almost
62
+ * always a typo or a stale node id, and an error that says only "not found"
63
+ * costs the caller another round trip to discover what the valid options were.
64
+ */
65
+ export declare function selectAgent(ring: Keyring, want?: {
66
+ agent?: string;
67
+ nodeId?: number;
68
+ }): AgentCredential;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Which agent signs a payment.
3
+ *
4
+ * The adapter used to hold exactly one agent credential, in
5
+ * NEAR_VAULT_AGENT_ID / NEAR_VAULT_AGENT_KEY, and `POST /v1/pay` always signed
6
+ * as that agent. The dashboard meanwhile renders the whole tree. So a vault
7
+ * with sixteen agents showed sixteen rows and could act as one of them — and
8
+ * the sixteen-agent demo had to bypass the product entirely and drive the CLI
9
+ * instead.
10
+ *
11
+ * This matters more than it looks, because the contract resolves the spending
12
+ * node from the SIGNER (`account_to_node`), not from anything in the request
13
+ * body. Which key signs *is* which budget is charged. A single-key adapter
14
+ * cannot exercise a hierarchy at all; it can only ever spend one leaf.
15
+ *
16
+ * On holding several keys at once: each is the restricted key from
17
+ * `create_agent_account`, which can call only `request_payment` on its own
18
+ * vault, cannot call other contracts, and cannot attach a deposit. The vault
19
+ * enforces every cap regardless of who holds the key, so the blast radius of
20
+ * the whole keyring is bounded by the tree's own limits. That is the same
21
+ * bound as for one key, applied more times.
22
+ */
23
+ import { readFileSync, existsSync } from "node:fs";
24
+ export function emptyKeyring() {
25
+ return { byAccount: new Map(), byNode: new Map() };
26
+ }
27
+ /**
28
+ * Build a keyring from the singular env vars plus, optionally, the JSON file
29
+ * `create-float` writes at `.float/agent-keys.json`:
30
+ *
31
+ * [{ "nodeId": 3, "agentId": "r1.float.me.testnet", "secret": "ed25519:..." }]
32
+ *
33
+ * The singular env pair stays authoritative for the fallback, so adding a file
34
+ * can never silently change which agent an un-targeted payment comes from.
35
+ */
36
+ export function loadKeyring(env) {
37
+ const ring = emptyKeyring();
38
+ const add = (c) => {
39
+ ring.byAccount.set(c.agentId, c);
40
+ if (typeof c.nodeId === "number")
41
+ ring.byNode.set(c.nodeId, c);
42
+ };
43
+ if (env.agentKeysPath && existsSync(env.agentKeysPath)) {
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(readFileSync(env.agentKeysPath, "utf8"));
47
+ }
48
+ catch {
49
+ // A malformed keyring must not take the server down, and must not be
50
+ // mistaken for an empty one. Paying with an unknown agent will fail
51
+ // loudly at selection time with a message that lists what IS loaded.
52
+ parsed = null;
53
+ }
54
+ if (Array.isArray(parsed)) {
55
+ for (const e of parsed) {
56
+ if (e && typeof e.agentId === "string" && typeof e.secret === "string") {
57
+ add({
58
+ agentId: e.agentId,
59
+ secret: e.secret,
60
+ nodeId: typeof e.nodeId === "number" ? e.nodeId : undefined,
61
+ });
62
+ }
63
+ }
64
+ }
65
+ }
66
+ if (env.agentId && env.agentKey) {
67
+ const c = { agentId: env.agentId, secret: env.agentKey };
68
+ add(c);
69
+ ring.fallback = c;
70
+ }
71
+ else if (ring.byAccount.size === 1) {
72
+ // Exactly one key in the file and no env pair: unambiguous, so use it.
73
+ // More than one with no env pair is deliberately NOT guessed at — see
74
+ // selectAgent.
75
+ ring.fallback = [...ring.byAccount.values()][0];
76
+ }
77
+ return ring;
78
+ }
79
+ export class AgentSelectionError extends Error {
80
+ }
81
+ /**
82
+ * Pick the credential for a payment request.
83
+ *
84
+ * Errors name every agent that IS available. A selection failure is almost
85
+ * always a typo or a stale node id, and an error that says only "not found"
86
+ * costs the caller another round trip to discover what the valid options were.
87
+ */
88
+ export function selectAgent(ring, want = {}) {
89
+ const known = () => {
90
+ const list = [...ring.byAccount.values()].map((c) => `${c.agentId}${typeof c.nodeId === "number" ? ` (node ${c.nodeId})` : ""}`);
91
+ return list.length ? list.join(", ") : "none";
92
+ };
93
+ if (want.agent !== undefined) {
94
+ const c = ring.byAccount.get(want.agent);
95
+ if (!c) {
96
+ throw new AgentSelectionError(`no key for agent '${want.agent}'. Loaded agents: ${known()}.`);
97
+ }
98
+ return c;
99
+ }
100
+ if (want.nodeId !== undefined) {
101
+ const c = ring.byNode.get(want.nodeId);
102
+ if (!c) {
103
+ throw new AgentSelectionError(`no key bound to node ${want.nodeId}. Loaded agents: ${known()}. ` +
104
+ `Node ids come from the keyring file; a key added via ` +
105
+ `NEAR_VAULT_AGENT_ID alone has no node id attached.`);
106
+ }
107
+ return c;
108
+ }
109
+ if (ring.fallback)
110
+ return ring.fallback;
111
+ if (ring.byAccount.size > 1) {
112
+ // Refuse rather than guess. Picking arbitrarily would charge a budget the
113
+ // caller did not choose, and the payment would succeed — so the mistake
114
+ // would surface later as a confusing balance, not as an error.
115
+ throw new AgentSelectionError(`several agents are loaded and the request named none, so there is no ` +
116
+ `safe default. Pass "agent" or "nodeId". Loaded agents: ${known()}.`);
117
+ }
118
+ throw new AgentSelectionError("no agent credentials are configured. Set NEAR_VAULT_AGENT_ID and " +
119
+ "NEAR_VAULT_AGENT_KEY, or point NEAR_VAULT_AGENT_KEYS at the " +
120
+ "agent-keys.json written by create-float.");
121
+ }