@floatonnear/float-adapter 0.0.0-stage → 0.1.1
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/blocked-scan.d.ts +153 -0
- package/dist/blocked-scan.js +274 -0
- package/dist/env-file.d.ts +45 -0
- package/dist/env-file.js +82 -0
- package/dist/gate.d.ts +81 -0
- package/dist/gate.js +139 -0
- package/dist/handlers.d.ts +210 -0
- package/dist/handlers.js +246 -0
- package/dist/keyring.d.ts +68 -0
- package/dist/keyring.js +121 -0
- package/dist/map.d.ts +159 -0
- package/dist/map.js +219 -0
- package/dist/server.d.ts +19 -0
- package/dist/server.js +434 -0
- package/package.json +26 -4
- package/README.md +0 -3
package/dist/gate.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP gate: where to bind, who may POST, and which origin may ask.
|
|
3
|
+
*
|
|
4
|
+
* This layer had no tests, which is why it shipped open. Proven by PoC on
|
|
5
|
+
* 2026-10-29 against the running adapter (see docs/SELF-REVIEW-2026-10-29.md):
|
|
6
|
+
*
|
|
7
|
+
* - `server.listen(port)` with no host bound `0.0.0.0` AND `[::]` — every
|
|
8
|
+
* interface, v4 and v6 — while the startup log claimed `127.0.0.1`.
|
|
9
|
+
* - An OPTIONS preflight carrying `Origin: https://evil.example` returned
|
|
10
|
+
* `204` with `access-control-allow-origin: *`, so a browser proceeds to the
|
|
11
|
+
* real request.
|
|
12
|
+
* - `POST /v1/pay` returned `400`, not `401`: it reached body validation
|
|
13
|
+
* because there was no auth gate to fail at. That route signs with the
|
|
14
|
+
* agent key.
|
|
15
|
+
*
|
|
16
|
+
* Split out from server.ts so each decision is a pure function with a test,
|
|
17
|
+
* rather than three lines buried in a request handler where nothing could see
|
|
18
|
+
* them.
|
|
19
|
+
*/
|
|
20
|
+
import { timingSafeEqual } from "node:crypto";
|
|
21
|
+
/**
|
|
22
|
+
* Loopback by default. Exposure is opted into, never inherited.
|
|
23
|
+
*
|
|
24
|
+
* `listen(port)` with no host is the dangerous default — it binds every
|
|
25
|
+
* interface. Passing a host explicitly is the whole fix, and defaulting it to
|
|
26
|
+
* loopback means a deployment has to SAY it wants to be reachable.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveHost(host) {
|
|
29
|
+
return host && host.trim() ? host.trim() : "127.0.0.1";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* CORS headers, echoing only an exact-match origin.
|
|
33
|
+
*
|
|
34
|
+
* A wildcard plus an approving preflight is what made this drive-by reachable:
|
|
35
|
+
* any page the operator had open could POST JSON here. Returning no
|
|
36
|
+
* `allow-origin` for an unknown origin makes the browser block the response and
|
|
37
|
+
* — for a preflighted `application/json` POST — never send the request at all.
|
|
38
|
+
*
|
|
39
|
+
* The header is omitted entirely when no `Origin` was sent, which is the
|
|
40
|
+
* curl/server-to-server case and needs no CORS at all.
|
|
41
|
+
*/
|
|
42
|
+
export function corsHeaders(allowedOrigin, requestOrigin) {
|
|
43
|
+
const base = {
|
|
44
|
+
"access-control-allow-headers": "content-type, authorization",
|
|
45
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
46
|
+
vary: "Origin",
|
|
47
|
+
};
|
|
48
|
+
if (requestOrigin && requestOrigin === allowedOrigin) {
|
|
49
|
+
base["access-control-allow-origin"] = requestOrigin;
|
|
50
|
+
}
|
|
51
|
+
return base;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Only mutating requests need the token.
|
|
55
|
+
*
|
|
56
|
+
* GETs are read-only and the bind is loopback, so they are reachable only from
|
|
57
|
+
* the machine itself. OPTIONS must pass or the browser never reaches the real
|
|
58
|
+
* request and the dashboard breaks for a reason nobody can see.
|
|
59
|
+
*
|
|
60
|
+
* A MISSING token is 503, not 401. They are different problems: 401 tells a
|
|
61
|
+
* caller to go and find a credential, and here there is none to find — the
|
|
62
|
+
* operator has not configured one. Conflating them sends people hunting for a
|
|
63
|
+
* secret that does not exist.
|
|
64
|
+
*/
|
|
65
|
+
export function authorize({ method, authHeader, token, }) {
|
|
66
|
+
if (method === "GET" || method === "OPTIONS" || method === "HEAD")
|
|
67
|
+
return { ok: true };
|
|
68
|
+
if (!token) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
status: 503,
|
|
72
|
+
error: "this route signs with the agent key and is disabled until FLOAT_API_TOKEN is set. " +
|
|
73
|
+
"Set it in .float/adapter.env and send it as `Authorization: Bearer <token>`.",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const prefix = "Bearer ";
|
|
77
|
+
if (!authHeader || !authHeader.startsWith(prefix)) {
|
|
78
|
+
return { ok: false, status: 401, error: "missing bearer token" };
|
|
79
|
+
}
|
|
80
|
+
const provided = authHeader.slice(prefix.length);
|
|
81
|
+
// Length check first — timingSafeEqual throws on a length mismatch, and the
|
|
82
|
+
// length of a secret is not the part worth hiding.
|
|
83
|
+
if (provided.length !== token.length) {
|
|
84
|
+
return { ok: false, status: 401, error: "invalid token" };
|
|
85
|
+
}
|
|
86
|
+
const a = Buffer.from(provided);
|
|
87
|
+
const b = Buffer.from(token);
|
|
88
|
+
if (!timingSafeEqual(a, b)) {
|
|
89
|
+
return { ok: false, status: 401, error: "invalid token" };
|
|
90
|
+
}
|
|
91
|
+
return { ok: true };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* An error caused by the CALLER, not by this server.
|
|
95
|
+
*
|
|
96
|
+
* The catch-all returned 500 for everything, so `{"amount":"abc"}` came back as
|
|
97
|
+
* `500 invalid amount "abc"` — verified live against the running adapter.
|
|
98
|
+
*
|
|
99
|
+
* That is not a cosmetic status-code quibble on an agent-facing API. 500 means
|
|
100
|
+
* "the server broke, retrying may help"; 400 means "your request is wrong, fix
|
|
101
|
+
* it". An agent reading 500 on a malformed amount retries the same malformed
|
|
102
|
+
* amount forever. SKILL.md teaches agents exactly this distinction for the
|
|
103
|
+
* contract's reason codes, and the HTTP layer was contradicting it.
|
|
104
|
+
*/
|
|
105
|
+
export class BadRequest extends Error {
|
|
106
|
+
constructor(message) {
|
|
107
|
+
super(message);
|
|
108
|
+
this.name = "BadRequest";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Map a thrown value to a response.
|
|
113
|
+
*
|
|
114
|
+
* Takes `unknown` rather than `Error` on purpose: `throw "string"` is legal,
|
|
115
|
+
* and a catch-all that assumes `.message` exists turns a small bug into an
|
|
116
|
+
* unhandled one. Only the message is returned — never a stack, which would
|
|
117
|
+
* hand a caller the server's file paths.
|
|
118
|
+
*/
|
|
119
|
+
export function classifyError(e) {
|
|
120
|
+
if (e instanceof BadRequest)
|
|
121
|
+
return { status: 400, body: { error: e.message } };
|
|
122
|
+
const message = e instanceof Error ? e.message : "internal error";
|
|
123
|
+
return { status: 500, body: { error: message } };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Run caller-supplied parsing, re-labelling any failure as the caller's.
|
|
127
|
+
*
|
|
128
|
+
* Wraps the validation helpers that throw plain `Error` — `toBaseUnits` and
|
|
129
|
+
* friends — so their failures reach the catch-all already classified, instead
|
|
130
|
+
* of being indistinguishable from an RPC outage.
|
|
131
|
+
*/
|
|
132
|
+
export function fromCaller(fn) {
|
|
133
|
+
try {
|
|
134
|
+
return fn();
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
throw new BadRequest(e instanceof Error ? e.message : String(e));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -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;
|
package/dist/handlers.js
ADDED
|
@@ -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;
|