@floatonnear/float-adapter 0.2.1 → 0.3.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/blocked-scan.d.ts +78 -0
- package/dist/blocked-scan.js +162 -8
- package/dist/env-file.d.ts +16 -0
- package/dist/env-file.js +16 -0
- package/dist/gate.d.ts +9 -5
- package/dist/gate.js +32 -5
- package/dist/handlers.d.ts +176 -3
- package/dist/handlers.js +262 -29
- package/dist/keyring.d.ts +81 -2
- package/dist/keyring.js +80 -10
- package/dist/map.d.ts +56 -11
- package/dist/map.js +43 -21
- package/dist/owner-plan.d.ts +112 -0
- package/dist/owner-plan.js +112 -0
- package/dist/server.d.ts +10 -1
- package/dist/server.js +461 -53
- package/dist/unavailable.d.ts +31 -0
- package/dist/unavailable.js +90 -0
- package/package.json +2 -2
package/dist/handlers.d.ts
CHANGED
|
@@ -19,6 +19,12 @@ export interface DeliveryRecord {
|
|
|
19
19
|
export interface AdapterConfig {
|
|
20
20
|
token: string;
|
|
21
21
|
decimals: number;
|
|
22
|
+
/**
|
|
23
|
+
* The vault account. Needed to enumerate VIRTUAL agents, whose keys live on
|
|
24
|
+
* the vault itself. Optional so a caller that only wants account-bound
|
|
25
|
+
* agents still works rather than being forced to supply it.
|
|
26
|
+
*/
|
|
27
|
+
vault?: string;
|
|
22
28
|
/**
|
|
23
29
|
* Base deliveries, keyed by request id, as the relayer recorded them.
|
|
24
30
|
*
|
|
@@ -112,6 +118,11 @@ export interface VaultInfo {
|
|
|
112
118
|
* never arrived. Null when the token contract cannot be read.
|
|
113
119
|
*/
|
|
114
120
|
vaultTokenBalance: string | null;
|
|
121
|
+
/**
|
|
122
|
+
* The contract's own UTC day counter, for building a `purge_settled`
|
|
123
|
+
* cutoff. Null when it could not be read — never 0, which is a real day.
|
|
124
|
+
*/
|
|
125
|
+
currentDay: number | null;
|
|
115
126
|
}
|
|
116
127
|
/**
|
|
117
128
|
* `near` / `testnet` / anything else. Derived from the network the contract
|
|
@@ -129,12 +140,20 @@ export interface AgentKeyView {
|
|
|
129
140
|
frozen: boolean;
|
|
130
141
|
keys: {
|
|
131
142
|
publicKey: string;
|
|
132
|
-
/** True
|
|
143
|
+
/** True if the key names specific methods — a function-call OR gas key. */
|
|
133
144
|
restricted: boolean;
|
|
134
145
|
receiverId: string | null;
|
|
135
146
|
methodNames: string[];
|
|
136
147
|
allowanceYocto: string | null;
|
|
148
|
+
/** NEP-611 gas keys only. For these the balance IS the spending cap. */
|
|
149
|
+
gasBalanceYocto: string | null;
|
|
137
150
|
}[];
|
|
151
|
+
/**
|
|
152
|
+
* True for a VIRTUAL agent: a gas key on the vault rather than a bound
|
|
153
|
+
* account. `accountId` is then the vault itself, which is what "virtual"
|
|
154
|
+
* means — there is no separate account.
|
|
155
|
+
*/
|
|
156
|
+
virtual?: boolean;
|
|
138
157
|
/** Set when the key list could not be read, so the UI can say so. */
|
|
139
158
|
error?: string;
|
|
140
159
|
}
|
|
@@ -147,6 +166,14 @@ export type KeyFn = (accountId: string) => Promise<{
|
|
|
147
166
|
receiver_id: string;
|
|
148
167
|
method_names: string[];
|
|
149
168
|
};
|
|
169
|
+
} | {
|
|
170
|
+
GasKeyFunctionCall: {
|
|
171
|
+
allowance: string | null;
|
|
172
|
+
balance: string;
|
|
173
|
+
receiver_id: string;
|
|
174
|
+
method_names: string[];
|
|
175
|
+
num_nonces?: number;
|
|
176
|
+
};
|
|
150
177
|
};
|
|
151
178
|
};
|
|
152
179
|
}[]>;
|
|
@@ -160,7 +187,106 @@ export type KeyFn = (accountId: string) => Promise<{
|
|
|
160
187
|
* trying to rule out. `restricted` here is computed from the permission NEAR
|
|
161
188
|
* reports, and a FullAccess key makes it false.
|
|
162
189
|
*/
|
|
163
|
-
|
|
190
|
+
/** What `coverageNote` returns, so a UI renders a fact rather than composing one. */
|
|
191
|
+
export interface CoverageNote {
|
|
192
|
+
/** False when no scan has ever run. NOT the same as "no refusals". */
|
|
193
|
+
scanned: boolean;
|
|
194
|
+
/** Blocks inside the claimed range that were never actually read. */
|
|
195
|
+
unreadBlocks: number;
|
|
196
|
+
/** A sentence safe to render verbatim. */
|
|
197
|
+
text: string;
|
|
198
|
+
/**
|
|
199
|
+
* The same caveat in one line, for somewhere `text` does not fit.
|
|
200
|
+
*
|
|
201
|
+
* On a 375px screen `text` is a three-sentence amber paragraph that was
|
|
202
|
+
* the largest thing on the activity page — bigger than the payments it
|
|
203
|
+
* qualifies. The obvious fix is for the page to shorten it, and that is
|
|
204
|
+
* the fix this module exists to prevent: a formatter in JSX drifts, and
|
|
205
|
+
* the direction it drifts is toward claiming more coverage than exists.
|
|
206
|
+
*
|
|
207
|
+
* So the short form is built HERE, under the same tests, and it keeps the
|
|
208
|
+
* load-bearing word. "1,500 blocks read" alone is a boast; "outside them,
|
|
209
|
+
* unknown" is the part that stops a reader treating a quiet list as an
|
|
210
|
+
* empty one. The heights are dropped, not the distinction — a height is
|
|
211
|
+
* for checking the claim, and the full `text` is one tap away.
|
|
212
|
+
*/
|
|
213
|
+
short: string;
|
|
214
|
+
/**
|
|
215
|
+
* The state of the refusal history, in words a user can act on.
|
|
216
|
+
*
|
|
217
|
+
* `text` and `short` both lead with block heights. A height is an
|
|
218
|
+
* identifier for checking the claim; it is not something a person reading
|
|
219
|
+
* their payments can do anything with, and leading with it is why the
|
|
220
|
+
* caveat read as noise rather than as information — the owner's words:
|
|
221
|
+
* "i dont understand it and also i dont like it showing up on our page".
|
|
222
|
+
*
|
|
223
|
+
* So this is the version that goes on the page. It states the only thing
|
|
224
|
+
* a reader needs: whether the refusal list can be trusted as complete.
|
|
225
|
+
* The heights stay available behind it.
|
|
226
|
+
*
|
|
227
|
+
* MAY BE vs IS, and the difference is real:
|
|
228
|
+
* nothing scanned — nobody has looked at all
|
|
229
|
+
* unread blocks — IS incomplete; specific blocks are known missing
|
|
230
|
+
* a clean range — MAY BE incomplete, because the range is still a
|
|
231
|
+
* window and outside it is unknown. There is no
|
|
232
|
+
* "complete" state to report: the adapter cannot
|
|
233
|
+
* know one without the vault's creation height and
|
|
234
|
+
* the chain head, neither of which it has.
|
|
235
|
+
*/
|
|
236
|
+
headline: string;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* State what the refusal history actually covers, in words.
|
|
240
|
+
*
|
|
241
|
+
* `blockedCoverage` has been on /v1/dashboard for a while, computed behind a
|
|
242
|
+
* comment reading "A UI that shows 0 blocked without this cannot distinguish
|
|
243
|
+
* an honest zero from an unscanned range" — and no page rendered it. So the
|
|
244
|
+
* activity page showed four payments and zero refusals with nothing saying
|
|
245
|
+
* that 95% of the vault's blocks had never been read.
|
|
246
|
+
*
|
|
247
|
+
* The sentence is built HERE, not in JSX, because this module owns the
|
|
248
|
+
* distinction and has the tests. A formatter duplicated in a component is one
|
|
249
|
+
* that drifts, and the direction it drifts is always toward claiming more
|
|
250
|
+
* coverage than exists.
|
|
251
|
+
*
|
|
252
|
+
* Deliberately NOT expressed as a percentage. That would need the vault's
|
|
253
|
+
* creation height and the chain head, neither of which this handler has, and
|
|
254
|
+
* an invented denominator is exactly the plausible-wrong-number this file
|
|
255
|
+
* warns about. The range and the block count are facts; a ratio would not be.
|
|
256
|
+
*/
|
|
257
|
+
export declare function coverageNote(scan: AdapterConfig["blockedScan"] | null | undefined): CoverageNote;
|
|
258
|
+
/**
|
|
259
|
+
* Every agent that may spend, with its keys read FROM THE CHAIN.
|
|
260
|
+
*
|
|
261
|
+
* The restriction is the product's central security claim, so the page that
|
|
262
|
+
* states it must not state it from a constant. A hardcoded "restricted to
|
|
263
|
+
* request_payment" would keep rendering happily if someone added a full-access
|
|
264
|
+
* key to an agent — exactly the regression a reader of that page is trying to
|
|
265
|
+
* rule out. `restricted` is computed from the permission NEAR reports.
|
|
266
|
+
*
|
|
267
|
+
* TWO RAILS, and this used to see only one:
|
|
268
|
+
*
|
|
269
|
+
* ACCOUNT `bind_agent` sets `node.account`, and the keys live on that
|
|
270
|
+
* account. Kept: it binds an account that already exists and whose
|
|
271
|
+
* owner already controls it, so it strands nothing.
|
|
272
|
+
*
|
|
273
|
+
* VIRTUAL `bind_virtual_agent` maps a public key to a node and leaves
|
|
274
|
+
* `node.account` as None — deliberately, because that field means
|
|
275
|
+
* "a real sub-account is bound here" and a virtual agent is the
|
|
276
|
+
* absence of one. The key is a NEP-611 gas key ON THE VAULT.
|
|
277
|
+
*
|
|
278
|
+
* This filtered on `n.account` alone. Once sub-accounts were retired that is
|
|
279
|
+
* null on every node of every new vault, so the page rendered an EMPTY LIST
|
|
280
|
+
* for a correctly configured vault — which reads as "nothing is authorised
|
|
281
|
+
* here", the exact inversion the `error` field exists to prevent elsewhere.
|
|
282
|
+
*
|
|
283
|
+
* There is no view that lists bound keys, only `get_virtual_agent_node(pk)`.
|
|
284
|
+
* So the enumeration runs the other way: take the vault's own access keys and
|
|
285
|
+
* ask the contract which of them are bound. That also means the owner's
|
|
286
|
+
* full-access key — which the factory now installs — is correctly excluded,
|
|
287
|
+
* because it is not bound to any node.
|
|
288
|
+
*/
|
|
289
|
+
export declare function getAgentKeys(view: ViewFn, keys: KeyFn, cfg: AdapterConfig): Promise<AgentKeyView[]>;
|
|
164
290
|
export declare function getDashboard(view: ViewFn, cfg: AdapterConfig): Promise<{
|
|
165
291
|
owner: {
|
|
166
292
|
authMethod: string;
|
|
@@ -170,7 +296,7 @@ export declare function getDashboard(view: ViewFn, cfg: AdapterConfig): Promise<
|
|
|
170
296
|
treasury: {
|
|
171
297
|
ownerId: string;
|
|
172
298
|
walletAddress: string;
|
|
173
|
-
createdAt:
|
|
299
|
+
createdAt: null;
|
|
174
300
|
amount: string;
|
|
175
301
|
currency: string;
|
|
176
302
|
decimals: number;
|
|
@@ -185,6 +311,7 @@ export declare function getDashboard(view: ViewFn, cfg: AdapterConfig): Promise<
|
|
|
185
311
|
to: number;
|
|
186
312
|
}>;
|
|
187
313
|
} | null | undefined;
|
|
314
|
+
blockedCoverageNote: CoverageNote;
|
|
188
315
|
transactionStats: {
|
|
189
316
|
total: number;
|
|
190
317
|
approved: number;
|
|
@@ -192,14 +319,60 @@ export declare function getDashboard(view: ViewFn, cfg: AdapterConfig): Promise<
|
|
|
192
319
|
failed: number;
|
|
193
320
|
other: number;
|
|
194
321
|
};
|
|
322
|
+
/**
|
|
323
|
+
* RECIPIENT AND MEMO TRAVEL SEPARATELY.
|
|
324
|
+
*
|
|
325
|
+
* `venue` USED to be `memo || recipient`, collapsing two different facts
|
|
326
|
+
* into one string: the Overview table showed "work12.testnet" for one
|
|
327
|
+
* payment and "first payment from a vault created in one signature" for
|
|
328
|
+
* the next, under a single heading, with no way to tell which you were
|
|
329
|
+
* looking at. `recipient` and `memo` were added beside it to separate them.
|
|
330
|
+
*
|
|
331
|
+
* AS OF 2026-11-11 THE MEMO IS REDACTED on this public surface (see
|
|
332
|
+
* map.ts), so `venue` is simply the recipient and `memo` is always null.
|
|
333
|
+
* The dashboard is keyless and multi-tenant — anyone can type a vault name
|
|
334
|
+
* — and a memo is what a company paid FOR.
|
|
335
|
+
*
|
|
336
|
+
* It is NOT secret: the memo is on chain and one view call away. This
|
|
337
|
+
* removes it from the surface that turned "know how to query a NEAR RPC"
|
|
338
|
+
* into "type a name". The agent's own `vault_history` reads the chain
|
|
339
|
+
* directly and still sees everything.
|
|
340
|
+
*/
|
|
195
341
|
recentActivity: {
|
|
196
342
|
id: string;
|
|
197
343
|
agent: string;
|
|
344
|
+
/**
|
|
345
|
+
* THE AGENT'S IDENTITY, not just its label.
|
|
346
|
+
*
|
|
347
|
+
* The Overview's activity rows seeded their avatar from `agent` (the
|
|
348
|
+
* NAME) while the budget tree two panels above seeded from `agentId`.
|
|
349
|
+
* Those hash differently, so one agent wore two colours on one page:
|
|
350
|
+
* "marketing" was yellow-green in the tree and pink in the table.
|
|
351
|
+
*/
|
|
352
|
+
agentId: string;
|
|
198
353
|
venue: string;
|
|
354
|
+
recipient: string | null;
|
|
355
|
+
memo: string | null;
|
|
199
356
|
amount: string;
|
|
200
357
|
rail: string;
|
|
201
358
|
time: string;
|
|
202
359
|
status: string;
|
|
360
|
+
/**
|
|
361
|
+
* THE REASON A PAYMENT WAS REFUSED, which this mapping used to drop.
|
|
362
|
+
*
|
|
363
|
+
* The blocked-row builder above already computes both of these from the
|
|
364
|
+
* contract's `request_blocked` event, and the Activity page renders
|
|
365
|
+
* them — but `recentActivity` listed eleven fields and neither was
|
|
366
|
+
* among them. So the Overview showed "BLOCKED" twice and said nothing
|
|
367
|
+
* about WHY, while the answer sat one object away.
|
|
368
|
+
*
|
|
369
|
+
* That is not cosmetic. The sharpest thing this product does is tell a
|
|
370
|
+
* sibling apart from its parent: `PARENT_DAILY_BUDGET` means someone
|
|
371
|
+
* else spent your pool, `DAILY_BUDGET` means you did. Dropping the code
|
|
372
|
+
* makes those two identical on the page a user actually lands on.
|
|
373
|
+
*/
|
|
374
|
+
blockReason: string | null;
|
|
375
|
+
blockMessage: string | null;
|
|
203
376
|
}[];
|
|
204
377
|
paused: boolean;
|
|
205
378
|
}>;
|
package/dist/handlers.js
CHANGED
|
@@ -98,6 +98,20 @@ export async function getVaultInfo(view, cfg, viewOn) {
|
|
|
98
98
|
const vaultTokenBalance = await viewOn(cfg.token, "ft_balance_of", {
|
|
99
99
|
account_id: c.vault_id,
|
|
100
100
|
}).catch(() => null);
|
|
101
|
+
/**
|
|
102
|
+
* THE CONTRACT'S OWN DAY, not a computed one.
|
|
103
|
+
*
|
|
104
|
+
* `purge_settled(before_day, …)` removes records strictly before the day
|
|
105
|
+
* it is given, so a caller needs today's number to build "everything up to
|
|
106
|
+
* now". Deriving it from the browser clock would be a guess about the
|
|
107
|
+
* contract: the vault has a `clock_mode`, and a test clock does not track
|
|
108
|
+
* UTC. A cutoff that is one day wrong either deletes records that are
|
|
109
|
+
* still live or silently deletes nothing.
|
|
110
|
+
*
|
|
111
|
+
* Null on failure rather than 0: day zero is a real value, and it would
|
|
112
|
+
* mean "purge nothing" while looking like an answer.
|
|
113
|
+
*/
|
|
114
|
+
const currentDay = await view("current_day", {}).catch(() => null);
|
|
101
115
|
return {
|
|
102
116
|
vaultId: c.vault_id,
|
|
103
117
|
tokenId: cfg.token,
|
|
@@ -109,44 +123,128 @@ export async function getVaultInfo(view, cfg, viewOn) {
|
|
|
109
123
|
clockMode: c.clock_mode,
|
|
110
124
|
strictAllocation: c.strict_allocation,
|
|
111
125
|
vaultTokenBalance,
|
|
126
|
+
currentDay,
|
|
112
127
|
};
|
|
113
128
|
}
|
|
114
129
|
/**
|
|
115
|
-
*
|
|
130
|
+
* State what the refusal history actually covers, in words.
|
|
131
|
+
*
|
|
132
|
+
* `blockedCoverage` has been on /v1/dashboard for a while, computed behind a
|
|
133
|
+
* comment reading "A UI that shows 0 blocked without this cannot distinguish
|
|
134
|
+
* an honest zero from an unscanned range" — and no page rendered it. So the
|
|
135
|
+
* activity page showed four payments and zero refusals with nothing saying
|
|
136
|
+
* that 95% of the vault's blocks had never been read.
|
|
137
|
+
*
|
|
138
|
+
* The sentence is built HERE, not in JSX, because this module owns the
|
|
139
|
+
* distinction and has the tests. A formatter duplicated in a component is one
|
|
140
|
+
* that drifts, and the direction it drifts is always toward claiming more
|
|
141
|
+
* coverage than exists.
|
|
142
|
+
*
|
|
143
|
+
* Deliberately NOT expressed as a percentage. That would need the vault's
|
|
144
|
+
* creation height and the chain head, neither of which this handler has, and
|
|
145
|
+
* an invented denominator is exactly the plausible-wrong-number this file
|
|
146
|
+
* warns about. The range and the block count are facts; a ratio would not be.
|
|
147
|
+
*/
|
|
148
|
+
export function coverageNote(scan) {
|
|
149
|
+
if (!scan) {
|
|
150
|
+
return {
|
|
151
|
+
scanned: false,
|
|
152
|
+
unreadBlocks: 0,
|
|
153
|
+
text: "Refusal history has not been scanned for this vault, so refusals are " +
|
|
154
|
+
"UNKNOWN rather than absent. A refusal is never stored on chain — only " +
|
|
155
|
+
"emitted as an event — so nothing can show them until " +
|
|
156
|
+
"scripts/scan-blocked.mjs has read the blocks.",
|
|
157
|
+
short: "Refusals not scanned — unknown, not absent.",
|
|
158
|
+
headline: "refusal history has not been scanned",
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const unreadBlocks = (scan.holes ?? []).reduce((n, h) => n + (h.to - h.from + 1), 0);
|
|
162
|
+
const span = scan.to - scan.from + 1;
|
|
163
|
+
const n = (x) => x.toLocaleString("en-US");
|
|
164
|
+
// HEIGHTS ARE NOT FORMATTED. A block height is an identifier, not a
|
|
165
|
+
// quantity: "275,882,255" cannot be pasted into an explorer or an RPC call,
|
|
166
|
+
// and a reader checking the claim is exactly who this sentence is for. The
|
|
167
|
+
// COUNT is a quantity, so it keeps its separators.
|
|
168
|
+
let text = `Refusals have been read from blocks ${scan.from}–${scan.to} ` +
|
|
169
|
+
`(${n(span)} blocks). Outside that range they are UNKNOWN, not absent.`;
|
|
170
|
+
if (unreadBlocks > 0) {
|
|
171
|
+
text +=
|
|
172
|
+
` ${n(unreadBlocks)} block${unreadBlocks === 1 ? "" : "s"} inside that range ` +
|
|
173
|
+
`could not be read and remain unread.`;
|
|
174
|
+
}
|
|
175
|
+
// The short form keeps the count, the unknown-outside caveat and the hole
|
|
176
|
+
// count, and drops only the heights. See the `short` field's own note for
|
|
177
|
+
// why it is built here and not in a component.
|
|
178
|
+
let short = `${n(span)} blocks read · outside them, unknown`;
|
|
179
|
+
if (unreadBlocks > 0)
|
|
180
|
+
short += ` · ${n(unreadBlocks)} unread inside`;
|
|
181
|
+
// "is" when blocks are known missing, "may be" when the only gap is
|
|
182
|
+
// everything outside the scanned window. See the `headline` field note.
|
|
183
|
+
const headline = unreadBlocks > 0
|
|
184
|
+
? "refusal history is incomplete"
|
|
185
|
+
: "refusal history may be incomplete";
|
|
186
|
+
return { scanned: true, unreadBlocks, text, short, headline };
|
|
187
|
+
}
|
|
188
|
+
/** One access key, flattened, whichever permission variant NEAR reported. */
|
|
189
|
+
function describeKey(k) {
|
|
190
|
+
const p = k.access_key.permission;
|
|
191
|
+
const obj = typeof p === "object" ? p : null;
|
|
192
|
+
// BOTH variants. A gas key is a `GasKeyFunctionCall`, NOT a `FunctionCall`
|
|
193
|
+
// with extra fields, so a check for the latter alone finds nothing on it and
|
|
194
|
+
// falls through to `restricted: false` — reporting a key that can call one
|
|
195
|
+
// method on one contract as if it could do anything.
|
|
196
|
+
const fc = obj && "FunctionCall" in obj ? obj.FunctionCall : null;
|
|
197
|
+
const gk = obj && "GasKeyFunctionCall" in obj ? obj.GasKeyFunctionCall : null;
|
|
198
|
+
const named = fc ?? gk;
|
|
199
|
+
return {
|
|
200
|
+
publicKey: k.public_key,
|
|
201
|
+
restricted: named !== null,
|
|
202
|
+
receiverId: named?.receiver_id ?? null,
|
|
203
|
+
methodNames: named?.method_names ?? [],
|
|
204
|
+
allowanceYocto: named?.allowance ?? null,
|
|
205
|
+
gasBalanceYocto: gk?.balance ?? null,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Every agent that may spend, with its keys read FROM THE CHAIN.
|
|
116
210
|
*
|
|
117
211
|
* The restriction is the product's central security claim, so the page that
|
|
118
212
|
* states it must not state it from a constant. A hardcoded "restricted to
|
|
119
213
|
* request_payment" would keep rendering happily if someone added a full-access
|
|
120
|
-
* key to an agent
|
|
121
|
-
*
|
|
122
|
-
*
|
|
214
|
+
* key to an agent — exactly the regression a reader of that page is trying to
|
|
215
|
+
* rule out. `restricted` is computed from the permission NEAR reports.
|
|
216
|
+
*
|
|
217
|
+
* TWO RAILS, and this used to see only one:
|
|
218
|
+
*
|
|
219
|
+
* ACCOUNT `bind_agent` sets `node.account`, and the keys live on that
|
|
220
|
+
* account. Kept: it binds an account that already exists and whose
|
|
221
|
+
* owner already controls it, so it strands nothing.
|
|
222
|
+
*
|
|
223
|
+
* VIRTUAL `bind_virtual_agent` maps a public key to a node and leaves
|
|
224
|
+
* `node.account` as None — deliberately, because that field means
|
|
225
|
+
* "a real sub-account is bound here" and a virtual agent is the
|
|
226
|
+
* absence of one. The key is a NEP-611 gas key ON THE VAULT.
|
|
227
|
+
*
|
|
228
|
+
* This filtered on `n.account` alone. Once sub-accounts were retired that is
|
|
229
|
+
* null on every node of every new vault, so the page rendered an EMPTY LIST
|
|
230
|
+
* for a correctly configured vault — which reads as "nothing is authorised
|
|
231
|
+
* here", the exact inversion the `error` field exists to prevent elsewhere.
|
|
232
|
+
*
|
|
233
|
+
* There is no view that lists bound keys, only `get_virtual_agent_node(pk)`.
|
|
234
|
+
* So the enumeration runs the other way: take the vault's own access keys and
|
|
235
|
+
* ask the contract which of them are bound. That also means the owner's
|
|
236
|
+
* full-access key — which the factory now installs — is correctly excluded,
|
|
237
|
+
* because it is not bound to any node.
|
|
123
238
|
*/
|
|
124
|
-
export async function getAgentKeys(view, keys,
|
|
239
|
+
export async function getAgentKeys(view, keys, cfg) {
|
|
125
240
|
const tree = await view("get_tree", {});
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
label: n.label,
|
|
132
|
-
frozen: n.frozen,
|
|
133
|
-
};
|
|
241
|
+
const byId = new Map(tree.map((n) => [n.id, n]));
|
|
242
|
+
const accountRows = await Promise.all(tree
|
|
243
|
+
.filter((n) => n.account)
|
|
244
|
+
.map(async (n) => {
|
|
245
|
+
const base = { accountId: n.account, nodeId: n.id, label: n.label, frozen: n.frozen };
|
|
134
246
|
try {
|
|
135
|
-
|
|
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
|
-
};
|
|
247
|
+
return { ...base, keys: (await keys(n.account)).map(describeKey) };
|
|
150
248
|
}
|
|
151
249
|
catch (e) {
|
|
152
250
|
// An unreadable key list is reported, never silently rendered as "no
|
|
@@ -155,6 +253,86 @@ export async function getAgentKeys(view, keys, _cfg) {
|
|
|
155
253
|
return { ...base, keys: [], error: e instanceof Error ? e.message : String(e) };
|
|
156
254
|
}
|
|
157
255
|
}));
|
|
256
|
+
// Without a vault id there is nothing to enumerate. Degrade to account-only
|
|
257
|
+
// rather than guessing an account name.
|
|
258
|
+
if (!cfg.vault)
|
|
259
|
+
return accountRows;
|
|
260
|
+
let onVault;
|
|
261
|
+
try {
|
|
262
|
+
onVault = await keys(cfg.vault);
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
return [
|
|
266
|
+
...accountRows,
|
|
267
|
+
{
|
|
268
|
+
accountId: cfg.vault,
|
|
269
|
+
nodeId: -1,
|
|
270
|
+
label: "virtual agents",
|
|
271
|
+
frozen: false,
|
|
272
|
+
keys: [],
|
|
273
|
+
virtual: true,
|
|
274
|
+
error: e instanceof Error ? e.message : String(e),
|
|
275
|
+
},
|
|
276
|
+
];
|
|
277
|
+
}
|
|
278
|
+
// Ask the contract which keys are bound. Concurrent: independent lookups.
|
|
279
|
+
const bindings = await Promise.all(onVault.map(async (k) => {
|
|
280
|
+
try {
|
|
281
|
+
return {
|
|
282
|
+
key: k,
|
|
283
|
+
nodeId: await view("get_virtual_agent_node", { public_key: k.public_key }),
|
|
284
|
+
error: null,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
return { key: k, nodeId: null, error: e instanceof Error ? e.message : String(e) };
|
|
289
|
+
}
|
|
290
|
+
}));
|
|
291
|
+
// A FAILED LOOKUP IS NOT "NOT AN AGENT". This first used
|
|
292
|
+
// `.catch(() => null)`, collapsing an RPC error into the same value as a
|
|
293
|
+
// genuine "not bound" — so a rate-limited chain renders "no virtual agents"
|
|
294
|
+
// for a vault that has them. That is the exact absent-versus-unreadable
|
|
295
|
+
// inversion the `error` field above exists to prevent, reintroduced while
|
|
296
|
+
// fixing the same class of bug. Surface it instead.
|
|
297
|
+
const unreadable = bindings.filter((b) => b.error !== null);
|
|
298
|
+
const lookupError = unreadable.length
|
|
299
|
+
? `${unreadable.length} of ${bindings.length} key bindings could not be read: ${unreadable[0].error}`
|
|
300
|
+
: null;
|
|
301
|
+
// Group by node: MANY-TO-ONE is the point of virtual agents — several keys
|
|
302
|
+
// may share one node, so a swarm draws on a shared budget instead of
|
|
303
|
+
// consuming one of the 64 MAX_NODES per member.
|
|
304
|
+
const byNode = new Map();
|
|
305
|
+
for (const b of bindings) {
|
|
306
|
+
if (b.nodeId === null || b.nodeId === undefined)
|
|
307
|
+
continue;
|
|
308
|
+
const list = byNode.get(b.nodeId) ?? [];
|
|
309
|
+
list.push(b.key);
|
|
310
|
+
byNode.set(b.nodeId, list);
|
|
311
|
+
}
|
|
312
|
+
const virtualRows = [...byNode.entries()]
|
|
313
|
+
.sort((a, b) => a[0] - b[0])
|
|
314
|
+
.map(([nodeId, list]) => ({
|
|
315
|
+
accountId: cfg.vault,
|
|
316
|
+
nodeId,
|
|
317
|
+
label: byId.get(nodeId)?.label ?? `node ${nodeId}`,
|
|
318
|
+
frozen: byId.get(nodeId)?.frozen ?? false,
|
|
319
|
+
keys: list.map(describeKey),
|
|
320
|
+
virtual: true,
|
|
321
|
+
}));
|
|
322
|
+
// If some bindings were unreadable, say so as its own row rather than
|
|
323
|
+
// letting a short list imply a complete one.
|
|
324
|
+
if (lookupError) {
|
|
325
|
+
virtualRows.push({
|
|
326
|
+
accountId: cfg.vault,
|
|
327
|
+
nodeId: -1,
|
|
328
|
+
label: "virtual agents",
|
|
329
|
+
frozen: false,
|
|
330
|
+
keys: [],
|
|
331
|
+
virtual: true,
|
|
332
|
+
error: lookupError,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
return [...accountRows, ...virtualRows];
|
|
158
336
|
}
|
|
159
337
|
export async function getDashboard(view, cfg) {
|
|
160
338
|
const { tree, budgets, labels } = await loadTreeAndUsage(view, cfg);
|
|
@@ -175,7 +353,10 @@ export async function getDashboard(view, cfg) {
|
|
|
175
353
|
treasury: {
|
|
176
354
|
ownerId: cfgView.owner,
|
|
177
355
|
walletAddress: cfgView.vault_id,
|
|
178
|
-
|
|
356
|
+
// null, not epoch zero: the contract records no vault creation time
|
|
357
|
+
// either. Inventing 1970 makes a missing value look like a known one.
|
|
358
|
+
// See ApiAgent.createdAt in map.ts.
|
|
359
|
+
createdAt: null,
|
|
179
360
|
// The vault holds the tokens; the figure the dashboard cares about is
|
|
180
361
|
// how much of the ROOT budget is still available today.
|
|
181
362
|
amount: root?.limits[0]
|
|
@@ -190,6 +371,10 @@ export async function getDashboard(view, cfg) {
|
|
|
190
371
|
// What the refusal history actually covers. A UI that shows "0 blocked"
|
|
191
372
|
// without this cannot distinguish an honest zero from an unscanned range.
|
|
192
373
|
blockedCoverage: cfg.blockedScan,
|
|
374
|
+
// The same fact as a sentence. Shipped alongside the raw range because the
|
|
375
|
+
// raw range sat here unrendered for weeks — a component had to compose the
|
|
376
|
+
// caveat itself, and none did. Giving it ready-made removes the excuse.
|
|
377
|
+
blockedCoverageNote: coverageNote(cfg.blockedScan),
|
|
193
378
|
transactionStats: {
|
|
194
379
|
total: onchain.length + cfg.blockedRing.length + Object.keys(cfg.chainBlocked ?? {}).length,
|
|
195
380
|
// The Base rail's terminal states count as approved. "success" alone was
|
|
@@ -202,14 +387,62 @@ export async function getDashboard(view, cfg) {
|
|
|
202
387
|
failed: onchain.filter((p) => p.status === "failed").length,
|
|
203
388
|
other: onchain.filter((p) => p.status === "pending").length,
|
|
204
389
|
},
|
|
390
|
+
/**
|
|
391
|
+
* RECIPIENT AND MEMO TRAVEL SEPARATELY.
|
|
392
|
+
*
|
|
393
|
+
* `venue` USED to be `memo || recipient`, collapsing two different facts
|
|
394
|
+
* into one string: the Overview table showed "work12.testnet" for one
|
|
395
|
+
* payment and "first payment from a vault created in one signature" for
|
|
396
|
+
* the next, under a single heading, with no way to tell which you were
|
|
397
|
+
* looking at. `recipient` and `memo` were added beside it to separate them.
|
|
398
|
+
*
|
|
399
|
+
* AS OF 2026-11-11 THE MEMO IS REDACTED on this public surface (see
|
|
400
|
+
* map.ts), so `venue` is simply the recipient and `memo` is always null.
|
|
401
|
+
* The dashboard is keyless and multi-tenant — anyone can type a vault name
|
|
402
|
+
* — and a memo is what a company paid FOR.
|
|
403
|
+
*
|
|
404
|
+
* It is NOT secret: the memo is on chain and one view call away. This
|
|
405
|
+
* removes it from the surface that turned "know how to query a NEAR RPC"
|
|
406
|
+
* into "type a name". The agent's own `vault_history` reads the chain
|
|
407
|
+
* directly and still sees everything.
|
|
408
|
+
*/
|
|
205
409
|
recentActivity: recent.map((p) => ({
|
|
206
410
|
id: p.id,
|
|
207
411
|
agent: p.agentName,
|
|
412
|
+
/**
|
|
413
|
+
* THE AGENT'S IDENTITY, not just its label.
|
|
414
|
+
*
|
|
415
|
+
* The Overview's activity rows seeded their avatar from `agent` (the
|
|
416
|
+
* NAME) while the budget tree two panels above seeded from `agentId`.
|
|
417
|
+
* Those hash differently, so one agent wore two colours on one page:
|
|
418
|
+
* "marketing" was yellow-green in the tree and pink in the table.
|
|
419
|
+
*/
|
|
420
|
+
agentId: p.agentId,
|
|
208
421
|
venue: p.venue,
|
|
422
|
+
recipient: p.recipientAddress,
|
|
423
|
+
// Empty string and "no memo" are the same thing to a reader, so both
|
|
424
|
+
// become null rather than making the UI test for two falsy shapes.
|
|
425
|
+
memo: p.prompt || null,
|
|
209
426
|
amount: p.amount,
|
|
210
427
|
rail: p.rail,
|
|
211
428
|
time: p.createdAt,
|
|
212
429
|
status: p.status,
|
|
430
|
+
/**
|
|
431
|
+
* THE REASON A PAYMENT WAS REFUSED, which this mapping used to drop.
|
|
432
|
+
*
|
|
433
|
+
* The blocked-row builder above already computes both of these from the
|
|
434
|
+
* contract's `request_blocked` event, and the Activity page renders
|
|
435
|
+
* them — but `recentActivity` listed eleven fields and neither was
|
|
436
|
+
* among them. So the Overview showed "BLOCKED" twice and said nothing
|
|
437
|
+
* about WHY, while the answer sat one object away.
|
|
438
|
+
*
|
|
439
|
+
* That is not cosmetic. The sharpest thing this product does is tell a
|
|
440
|
+
* sibling apart from its parent: `PARENT_DAILY_BUDGET` means someone
|
|
441
|
+
* else spent your pool, `DAILY_BUDGET` means you did. Dropping the code
|
|
442
|
+
* makes those two identical on the page a user actually lands on.
|
|
443
|
+
*/
|
|
444
|
+
blockReason: p.blockReason ?? null,
|
|
445
|
+
blockMessage: p.blockMessage ?? null,
|
|
213
446
|
})),
|
|
214
447
|
paused: cfgView.paused,
|
|
215
448
|
};
|