@floatonnear/float-adapter 0.2.0 → 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.
@@ -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 only if the key really is a function-call key. */
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
- export declare function getAgentKeys(view: ViewFn, keys: KeyFn, _cfg: AdapterConfig): Promise<AgentKeyView[]>;
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: string;
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
- * The bound agent accounts, with their access keys read FROM THE CHAIN.
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 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.
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, _cfg) {
239
+ export async function getAgentKeys(view, keys, cfg) {
125
240
  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
- };
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
- 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
- };
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
- createdAt: new Date(0).toISOString(),
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
  };