@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.
package/dist/map.d.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Maps NEAR vault views onto the shapes the EXISTING Float dashboard already
3
+ * reads, so that UI runs against the NEAR hierarchical vault unchanged — same
4
+ * components, same logo, same styles, same screens.
5
+ *
6
+ * Nothing here invents a field. Where Solana-era Float had a concept the NEAR
7
+ * vault does not (x402 rails, per-agent token balances, auto-top-up), the
8
+ * field is OMITTED rather than filled with a plausible default: every one of
9
+ * those is optional in the UI's types, and a made-up value would render as
10
+ * fact.
11
+ */
12
+ export interface VaultTokenLimit {
13
+ token: string;
14
+ daily_cap: string;
15
+ per_tx_cap: string;
16
+ }
17
+ export interface VaultNode {
18
+ id: number;
19
+ parent: number | null;
20
+ path: number[];
21
+ label: string;
22
+ account: string | null;
23
+ limits: VaultTokenLimit[];
24
+ child_cap_sum: [string, string][];
25
+ child_count: number;
26
+ cooldown_sec: number;
27
+ frozen: boolean;
28
+ recipients: string[] | null;
29
+ }
30
+ export interface VaultRequest {
31
+ request_id: string;
32
+ node_id: number;
33
+ token: string;
34
+ amount: string;
35
+ recipient: string;
36
+ memo: string;
37
+ reserved_chain: number[];
38
+ reserved_day: number;
39
+ /**
40
+ * Absent on requests stored before routes existed.
41
+ *
42
+ * `base_usdc` is retired along with the MPC rail, but it is still listed
43
+ * here: a vault deployed before the change has those requests in its
44
+ * state, and `list_requests` will keep returning them for as long as they
45
+ * are not purged. Dropping it from the type would make `railOf` report
46
+ * those as NEAR — quietly relabelling a cross-chain payment as a local one.
47
+ */
48
+ route?: "near_direct" | {
49
+ base_usdc: unknown;
50
+ } | {
51
+ intents: unknown;
52
+ } | null;
53
+ status: string;
54
+ reason_code: string;
55
+ created_at_ns: string;
56
+ updated_at_ns: string;
57
+ }
58
+ export interface VaultOutcome {
59
+ request_id: string;
60
+ node_id: number;
61
+ status: string;
62
+ reason_code: string;
63
+ }
64
+ export interface DayUsage {
65
+ executed: string;
66
+ pending: string;
67
+ }
68
+ /** The dashboard's ApiAgent, plus the two fields it reads off the policy. */
69
+ export interface ApiAgent {
70
+ agentId: string;
71
+ walletAddress: string;
72
+ createdAt: string;
73
+ status: "active" | "killed";
74
+ displayName: string;
75
+ lifetimeSpent?: string;
76
+ lifetimePayments?: number;
77
+ walletBalance?: string | null;
78
+ dailySpent?: string;
79
+ dailyBudgetUsedPercent?: number;
80
+ policy: {
81
+ dailyBudget?: string;
82
+ perTxLimit?: string;
83
+ cooldownSeconds?: number;
84
+ blockUnknown?: boolean;
85
+ allowedAddresses?: string[];
86
+ } | null;
87
+ }
88
+ export interface Payment {
89
+ id: string;
90
+ agentId: string;
91
+ agentName: string;
92
+ amount: string;
93
+ currency: string;
94
+ recipientAddress: string | null;
95
+ venue: string;
96
+ rail: string;
97
+ status: string;
98
+ txSignature: string | null;
99
+ createdAt: string;
100
+ prompt: string | null;
101
+ resultUrl: string | null;
102
+ blockReason?: string | null;
103
+ blockMessage?: string | null;
104
+ }
105
+ export declare function nodeToApiAgent(n: VaultNode, usage: DayUsage, decimals: number): ApiAgent;
106
+ /**
107
+ * Tree order, root-first, with the depth drawn into the display name.
108
+ *
109
+ * The Float agents table is flat. Indenting the label is the minimal way to
110
+ * show root -> team -> agent in it without redesigning the screen.
111
+ */
112
+ export declare function treeToApiAgents(tree: VaultNode[], budgets: Map<number, DayUsage>, decimals: number, lifetime?: Map<number, {
113
+ spent: bigint;
114
+ count: number;
115
+ }>): ApiAgent[];
116
+ /**
117
+ * Depth prefix using NON-BREAKING spaces.
118
+ *
119
+ * Plain spaces look right in a test assertion and vanish on screen: HTML
120
+ * collapses leading whitespace, so depth-2 rendered identically to depth-1 and
121
+ * the hierarchy — the entire point of the view — was invisible. The unit test
122
+ * was green while the screen was wrong, which is why this is asserted against
123
+ * the rendered character and not a trimmed string.
124
+ */
125
+ export declare function indent(depth: number, label: string): string;
126
+ /** Mirrors the contract's `effective_recipients`: nearest ancestor with a list. */
127
+ export declare function effectiveRecipients(n: VaultNode, byId: Map<number, VaultNode>): string[] | undefined;
128
+ /** Settled spend per node, within RETAINED history (purge_settled removes rows). */
129
+ export declare function lifetimeByNode(requests: VaultRequest[]): Map<number, {
130
+ spent: bigint;
131
+ count: number;
132
+ }>;
133
+ /**
134
+ * Which rail the dashboard shows.
135
+ *
136
+ * NOT hardcoded to "near": a cross-chain payment rendered as a NEAR one is the
137
+ * same class of bug as the "Open x402" badge — the UI stating something false
138
+ * about where the money went, while every test passes. An unrecognised or
139
+ * absent route reports "near", because requests stored before routes existed
140
+ * have no route field and were all NEAR.
141
+ */
142
+ export declare function railOf(route: VaultRequest["route"]): string;
143
+ /** A Base delivery the relayer observed and recorded off-chain. */
144
+ export interface Delivery {
145
+ baseTxHash: string;
146
+ mode: "vault" | "operator";
147
+ }
148
+ export declare function requestToPayment(r: VaultRequest, nodeLabels: Record<number, string>, decimals: number, delivery?: Delivery): Payment;
149
+ /**
150
+ * Plain-English for every reason code the hot path can return.
151
+ *
152
+ * `PARENT_DAILY_BUDGET` and `DAILY_BUDGET` get deliberately DIFFERENT
153
+ * sentences. Those two codes are the entire point of the hierarchy — one says
154
+ * the agent is out of budget, the other says the pool it shares is — and
155
+ * rendering the same text for both would make the product's distinguishing
156
+ * behaviour invisible to the person looking at the screen.
157
+ */
158
+ export declare const REASONS: Record<string, string>;
159
+ export declare function blockedToPayment(o: VaultOutcome, nodeLabels: Record<number, string>, amountHuman: string, recipient: string, createdAtIso: string): Payment;
package/dist/map.js ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Maps NEAR vault views onto the shapes the EXISTING Float dashboard already
3
+ * reads, so that UI runs against the NEAR hierarchical vault unchanged — same
4
+ * components, same logo, same styles, same screens.
5
+ *
6
+ * Nothing here invents a field. Where Solana-era Float had a concept the NEAR
7
+ * vault does not (x402 rails, per-agent token balances, auto-top-up), the
8
+ * field is OMITTED rather than filled with a plausible default: every one of
9
+ * those is optional in the UI's types, and a made-up value would render as
10
+ * fact.
11
+ */
12
+ import { fromBaseUnits } from "@floatonnear/mcp/amounts";
13
+ export function nodeToApiAgent(n, usage, decimals) {
14
+ const lim = n.limits[0];
15
+ const cap = lim ? BigInt(lim.daily_cap) : 0n;
16
+ // executed + pending, exactly as the contract computes `available`. A
17
+ // dashboard that ignored in-flight spend would advertise headroom the
18
+ // contract is about to refuse.
19
+ const used = BigInt(usage.executed) + BigInt(usage.pending);
20
+ return {
21
+ // An internal (team) node has no account, but it does hold a real budget,
22
+ // so it still gets a row. Dropping it would hide the parent pool, which is
23
+ // the thing this product exists to make visible.
24
+ agentId: n.account ?? `node-${n.id}`,
25
+ walletAddress: n.account ?? "",
26
+ createdAt: new Date(0).toISOString(),
27
+ status: n.frozen ? "killed" : "active",
28
+ displayName: n.label,
29
+ dailySpent: fromBaseUnits(used, decimals),
30
+ dailyBudgetUsedPercent: cap > 0n ? Number((used * 100n) / cap) : 0,
31
+ walletBalance: null, // the VAULT holds the tokens; an agent account holds none
32
+ policy: {
33
+ dailyBudget: lim ? fromBaseUnits(cap, decimals) : undefined,
34
+ perTxLimit: lim ? fromBaseUnits(BigInt(lim.per_tx_cap), decimals) : undefined,
35
+ cooldownSeconds: n.cooldown_sec,
36
+ // Not a toggle here: the payee allowlist is always enforced on-chain, so
37
+ // reporting false would misdescribe the contract.
38
+ blockUnknown: true,
39
+ allowedAddresses: n.recipients ?? undefined,
40
+ },
41
+ };
42
+ }
43
+ /**
44
+ * Tree order, root-first, with the depth drawn into the display name.
45
+ *
46
+ * The Float agents table is flat. Indenting the label is the minimal way to
47
+ * show root -> team -> agent in it without redesigning the screen.
48
+ */
49
+ export function treeToApiAgents(tree, budgets, decimals, lifetime = new Map()) {
50
+ const byId = new Map(tree.map((n) => [n.id, n]));
51
+ return tree.map((n) => {
52
+ const a = nodeToApiAgent(n, budgets.get(n.id) ?? { executed: "0", pending: "0" }, decimals);
53
+ a.displayName = indent(n.path.length, n.label);
54
+ // The EFFECTIVE allowlist, resolved the way the contract resolves it.
55
+ // Reporting the raw `recipients` field made every inheriting node look
56
+ // unrestricted in the UI ("Open x402"), which is the opposite of true:
57
+ // they inherit the root's allowlist and the contract enforces it.
58
+ a.policy = { ...a.policy, allowedAddresses: effectiveRecipients(n, byId) };
59
+ const lt = lifetime.get(n.id);
60
+ if (lt) {
61
+ a.lifetimeSpent = fromBaseUnits(lt.spent, decimals);
62
+ a.lifetimePayments = lt.count;
63
+ }
64
+ return a;
65
+ });
66
+ }
67
+ /**
68
+ * Depth prefix using NON-BREAKING spaces.
69
+ *
70
+ * Plain spaces look right in a test assertion and vanish on screen: HTML
71
+ * collapses leading whitespace, so depth-2 rendered identically to depth-1 and
72
+ * the hierarchy — the entire point of the view — was invisible. The unit test
73
+ * was green while the screen was wrong, which is why this is asserted against
74
+ * the rendered character and not a trimmed string.
75
+ */
76
+ export function indent(depth, label) {
77
+ if (depth === 0)
78
+ return label;
79
+ return `${"\u00a0".repeat(3 * (depth - 1))}└─ ${label}`;
80
+ }
81
+ /** Mirrors the contract's `effective_recipients`: nearest ancestor with a list. */
82
+ export function effectiveRecipients(n, byId) {
83
+ const chain = [...n.path, n.id].reverse(); // self first, then upward
84
+ for (const id of chain) {
85
+ const node = byId.get(id);
86
+ if (node?.recipients && node.recipients.length)
87
+ return node.recipients;
88
+ }
89
+ return undefined;
90
+ }
91
+ /** Settled spend per node, within RETAINED history (purge_settled removes rows). */
92
+ export function lifetimeByNode(requests) {
93
+ const m = new Map();
94
+ for (const r of requests) {
95
+ if (r.status !== "SETTLED")
96
+ continue;
97
+ const cur = m.get(r.node_id) ?? { spent: 0n, count: 0 };
98
+ m.set(r.node_id, { spent: cur.spent + BigInt(r.amount), count: cur.count + 1 });
99
+ }
100
+ return m;
101
+ }
102
+ function nsToIso(ns) {
103
+ // Nanoseconds. Dividing by 1e6 for milliseconds, via BigInt so a 19-digit
104
+ // value does not lose its low digits on the way through a float.
105
+ return new Date(Number(BigInt(ns) / 1000000n)).toISOString();
106
+ }
107
+ /**
108
+ * Which rail the dashboard shows.
109
+ *
110
+ * NOT hardcoded to "near": a cross-chain payment rendered as a NEAR one is the
111
+ * same class of bug as the "Open x402" badge — the UI stating something false
112
+ * about where the money went, while every test passes. An unrecognised or
113
+ * absent route reports "near", because requests stored before routes existed
114
+ * have no route field and were all NEAR.
115
+ */
116
+ export function railOf(route) {
117
+ if (route && typeof route === "object") {
118
+ if ("intents" in route)
119
+ return "intents";
120
+ // Retired rail, still present in older stored requests. Reporting these
121
+ // as "near" would say a cross-chain payment settled locally.
122
+ if ("base_usdc" in route)
123
+ return "base";
124
+ }
125
+ return "near";
126
+ }
127
+ const STATUS = {
128
+ SETTLED: "success",
129
+ TRANSFER_FAILED: "failed",
130
+ TRANSFER_PENDING: "pending",
131
+ BLOCKED: "blocked",
132
+ };
133
+ export function requestToPayment(r, nodeLabels, decimals, delivery) {
134
+ return {
135
+ id: r.request_id,
136
+ agentId: String(r.node_id),
137
+ agentName: nodeLabels[r.node_id] ?? `node-${r.node_id}`,
138
+ amount: fromBaseUnits(BigInt(r.amount), decimals),
139
+ currency: "USDC",
140
+ recipientAddress: r.recipient,
141
+ // What the agent paid FOR, which is what the memo carries. The recipient
142
+ // is already its own column, so echoing it adds nothing.
143
+ venue: r.memo || r.recipient,
144
+ rail: railOf(r.route),
145
+ // A Base payment is NOT "success". The contract proved it obtained a
146
+ // signature; it cannot see whether the transaction landed, and nothing in
147
+ // this repo relays it. Rendering SETTLED as "success" for that rail would
148
+ // tell the reader the money arrived, which is the one claim the whole
149
+ // design is careful never to make. The NEAR rail is unaffected: there,
150
+ // SETTLED really does mean the tokens moved.
151
+ // signed -> delivered, but ONLY on evidence. A Base payment is "signed"
152
+ // when the vault proved it authorized the transfer, and "delivered" only
153
+ // once a relayer reports a Base transaction hash for it. The chain cannot
154
+ // attest the second step, so it is never inferred — no hash, no claim.
155
+ status: railOf(r.route) === "base" && r.reason_code === "SIGNED"
156
+ ? delivery
157
+ ? "delivered"
158
+ : "signed"
159
+ : STATUS[r.status] ?? r.status.toLowerCase(),
160
+ // The BASE transaction hash, when one exists. Null on the NEAR rail and on
161
+ // anything undelivered.
162
+ txSignature: delivery?.baseTxHash ?? null,
163
+ createdAt: nsToIso(r.created_at_ns),
164
+ prompt: r.memo || null,
165
+ resultUrl: null,
166
+ // Carried for terminal failures too, so a failed row can say WHY.
167
+ blockReason: r.status === "SETTLED" ? null : r.reason_code,
168
+ blockMessage: r.status === "SETTLED" ? null : REASONS[r.reason_code] ?? null,
169
+ };
170
+ }
171
+ /**
172
+ * Plain-English for every reason code the hot path can return.
173
+ *
174
+ * `PARENT_DAILY_BUDGET` and `DAILY_BUDGET` get deliberately DIFFERENT
175
+ * sentences. Those two codes are the entire point of the hierarchy — one says
176
+ * the agent is out of budget, the other says the pool it shares is — and
177
+ * rendering the same text for both would make the product's distinguishing
178
+ * behaviour invisible to the person looking at the screen.
179
+ */
180
+ export const REASONS = {
181
+ VAULT_PAUSED: "The vault is paused, so no agent can pay.",
182
+ NODE_FROZEN: "This agent, or a team above it, is frozen.",
183
+ NOT_AGENT: "That account is not bound to any budget node.",
184
+ DUPLICATE_REQUEST: "A payment with this request id already exists.",
185
+ INVALID_REQUEST_ID: "The request id is empty or uses characters the vault rejects.",
186
+ INVALID_AMOUNT: "The amount must be greater than zero.",
187
+ INVALID_MEMO: "The memo is too long or contains control characters.",
188
+ TOKEN_NOT_ALLOWED: "This node has no limit set for that token.",
189
+ RECIPIENT_NOT_ALLOWED: "That payee is not on this node's allowlist.",
190
+ COOLDOWN: "Too soon after the last payment from this agent.",
191
+ PER_TX_LIMIT: "Over this agent's own per-payment cap.",
192
+ PARENT_PER_TX_LIMIT: "Over a per-payment cap set higher up the tree.",
193
+ DAILY_BUDGET: "This agent has spent its own daily budget.",
194
+ PARENT_DAILY_BUDGET: "The shared pool is empty. This agent still has its own limit left, but the team " +
195
+ "or root budget it draws from does not.",
196
+ TRANSFER_FAILED: "The token transfer failed, so the reservation was released.",
197
+ INSUFFICIENT_GAS: "Not enough gas was attached to dispatch the transfer.",
198
+ };
199
+ export function blockedToPayment(o, nodeLabels, amountHuman, recipient, createdAtIso) {
200
+ return {
201
+ id: o.request_id,
202
+ agentId: String(o.node_id),
203
+ agentName: nodeLabels[o.node_id] ?? `node-${o.node_id}`,
204
+ amount: amountHuman,
205
+ currency: "USDC",
206
+ recipientAddress: recipient,
207
+ venue: recipient,
208
+ // The rail and status the existing Float UI already filters blocked rows
209
+ // on — no UI change needed to render these.
210
+ rail: "policy",
211
+ status: "blocked",
212
+ txSignature: null,
213
+ createdAt: createdAtIso,
214
+ prompt: null,
215
+ resultUrl: null,
216
+ blockReason: o.reason_code,
217
+ blockMessage: REASONS[o.reason_code] ?? `Blocked by vault policy (${o.reason_code}).`,
218
+ };
219
+ }
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Serves the existing Float dashboard's /v1 API from NEAR vault views.
4
+ *
5
+ * The point of this process: the Float dashboard runs UNCHANGED. Same
6
+ * components, same logo, same styles, same screens — only
7
+ * NEXT_PUBLIC_FLOAT_API_URL points here instead of at the Solana backend.
8
+ *
9
+ * node:http with no framework, because the whole job is four JSON endpoints.
10
+ *
11
+ * NEAR_VAULT_CONTRACT required
12
+ * NEAR_VAULT_RPC_URL default https://test.rpc.fastnear.com
13
+ * NEAR_VAULT_TOKEN required (the NEP-141 the vault pays in)
14
+ * NEAR_VAULT_TOKEN_DECIMALS default 6
15
+ * NEAR_VAULT_AGENT_ID only needed for POST /v1/pay
16
+ * NEAR_VAULT_AGENT_KEY only needed for POST /v1/pay (the restricted key)
17
+ * PORT default 8787 (Float's dashboard default)
18
+ */
19
+ export {};