@floatonnear/float-adapter 0.2.1 → 0.3.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/keyring.d.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * cannot exercise a hierarchy at all; it can only ever spend one leaf.
15
15
  *
16
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
17
+ * a virtual agent's NEP-611 gas key, which can call only `request_payment` on its own
18
18
  * vault, cannot call other contracts, and cannot attach a deposit. The vault
19
19
  * enforces every cap regardless of who holds the key, so the blast radius of
20
20
  * the whole keyring is bounded by the tree's own limits. That is the same
@@ -25,12 +25,61 @@ export interface AgentCredential {
25
25
  secret: string;
26
26
  /** Present when known; lets a caller select by node instead of account id. */
27
27
  nodeId?: number;
28
+ /**
29
+ * A human name for this agent — "marketing", "news".
30
+ *
31
+ * THE ONLY IDENTIFIER THAT IS ACTUALLY UNIQUE PER AGENT once agents are
32
+ * virtual. `agentId` is the account a key signs AS, and a virtual agent's
33
+ * key is a gas key ON THE VAULT, so every agent on one vault shares it.
34
+ * `nodeId` is unique but is a number nobody wants to quote.
35
+ */
36
+ role?: string;
28
37
  }
29
38
  export interface Keyring {
30
- /** agent account id -> credential */
39
+ /**
40
+ * agent account id -> credential.
41
+ *
42
+ * LOSSY FOR VIRTUAL AGENTS, deliberately kept for compatibility. Every
43
+ * agent on one vault has the same `agentId`, so this map holds only the
44
+ * last one loaded. Use `all` for anything that counts agents and `byRole`
45
+ * or `byNode` to select one.
46
+ */
31
47
  byAccount: Map<string, AgentCredential>;
32
48
  /** node id -> credential */
33
49
  byNode: Map<number, AgentCredential>;
50
+ /** role name -> credential */
51
+ byRole: Map<string, AgentCredential>;
52
+ /**
53
+ * Every credential loaded, in file order.
54
+ *
55
+ * The ONLY honest count. Ambiguity used to be judged by `byAccount.size`,
56
+ * which collapses virtual agents onto one key — so two agents read as
57
+ * "exactly one, unambiguous" and an un-targeted payment silently signed
58
+ * with whichever was written last, charging the wrong node. Measured
59
+ * 2026-11-03: a two-agent file produced `byAccount.size === 1`.
60
+ */
61
+ all: AgentCredential[];
62
+ /**
63
+ * How many file entries were skipped because they belong to a different
64
+ * vault. Zero unless `vault` was passed to `loadKeyring`.
65
+ *
66
+ * Surfaced rather than discarded: "no agents configured" and "three agents
67
+ * configured, none of them for this vault" look identical from an empty
68
+ * ring, and the second is a pointing error the operator can fix in
69
+ * seconds once told.
70
+ */
71
+ skippedOtherVault: number;
72
+ /**
73
+ * Why `NEAR_VAULT_AGENT_KEY_FILE` yielded no key, when one was named.
74
+ *
75
+ * THE THIRD CAUSE OF AN EMPTY RING, and the same reason
76
+ * `skippedOtherVault` is carried rather than discarded. The generic refusal
77
+ * says "Set NEAR_VAULT_AGENT_ID and NEAR_VAULT_AGENT_KEY" — which, for an
78
+ * operator whose key file is simply mistyped, is advice to paste the secret
79
+ * back inline. That is the exposure the file option exists to remove, so
80
+ * misreporting this case actively undoes the change.
81
+ */
82
+ agentKeyError?: string;
34
83
  /**
35
84
  * Used when a request names no agent. Preserves the previous single-agent
36
85
  * behaviour exactly, so existing deployments and the dashboard's own calls
@@ -52,6 +101,35 @@ export declare function loadKeyring(env: {
52
101
  agentId?: string;
53
102
  agentKey?: string;
54
103
  agentKeysPath?: string;
104
+ /**
105
+ * The vault this adapter serves. When given, credentials belonging to a
106
+ * DIFFERENT vault are skipped.
107
+ *
108
+ * The keyring file is per-machine, not per-vault: one `.float/agent-keys.json`
109
+ * accumulates every agent ever provisioned here. Without this filter an
110
+ * adapter serving vault A also loads vault B's agents, which goes wrong in
111
+ * two ways.
112
+ *
113
+ * Loudly, if there are several: `selectAgent` refuses an un-targeted payment
114
+ * and lists agents that are not even on this vault, so the error names
115
+ * options that could never have worked.
116
+ *
117
+ * QUIETLY, if there is exactly one: `all.length === 1` makes a credential
118
+ * for an unrelated vault the FALLBACK. Every un-targeted payment is then
119
+ * signed by a key the contract has never heard of — it fails on chain with
120
+ * NOT_VIRTUAL_AGENT, which reads like a binding bug rather than a
121
+ * configuration one, and sends the reader looking in the wrong place.
122
+ *
123
+ * Observed 2026-11-03: an adapter on work12-testnet-fleet loaded
124
+ * rudeisland4178's agent and named it in a refusal.
125
+ */
126
+ vault?: string;
127
+ /**
128
+ * Passed through from `resolveKeySource` when NEAR_VAULT_AGENT_KEY_FILE was
129
+ * named but unreadable. Carried, not acted on: a broken default must not
130
+ * stop the agents that DID load from being selected by name.
131
+ */
132
+ agentKeyError?: string;
55
133
  }): Keyring;
56
134
  export declare class AgentSelectionError extends Error {
57
135
  }
@@ -65,4 +143,5 @@ export declare class AgentSelectionError extends Error {
65
143
  export declare function selectAgent(ring: Keyring, want?: {
66
144
  agent?: string;
67
145
  nodeId?: number;
146
+ role?: string;
68
147
  }): AgentCredential;
package/dist/keyring.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * cannot exercise a hierarchy at all; it can only ever spend one leaf.
15
15
  *
16
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
17
+ * a virtual agent's NEP-611 gas key, which can call only `request_payment` on its own
18
18
  * vault, cannot call other contracts, and cannot attach a deposit. The vault
19
19
  * enforces every cap regardless of who holds the key, so the blast radius of
20
20
  * the whole keyring is bounded by the tree's own limits. That is the same
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import { readFileSync, existsSync } from "node:fs";
24
24
  export function emptyKeyring() {
25
- return { byAccount: new Map(), byNode: new Map() };
25
+ return { byAccount: new Map(), byNode: new Map(), byRole: new Map(), all: [], skippedOtherVault: 0 };
26
26
  }
27
27
  /**
28
28
  * Build a keyring from the singular env vars plus, optionally, the JSON file
@@ -35,10 +35,14 @@ export function emptyKeyring() {
35
35
  */
36
36
  export function loadKeyring(env) {
37
37
  const ring = emptyKeyring();
38
+ ring.agentKeyError = env.agentKeyError;
38
39
  const add = (c) => {
40
+ ring.all.push(c);
39
41
  ring.byAccount.set(c.agentId, c);
40
42
  if (typeof c.nodeId === "number")
41
43
  ring.byNode.set(c.nodeId, c);
44
+ if (c.role)
45
+ ring.byRole.set(c.role, c);
42
46
  };
43
47
  if (env.agentKeysPath && existsSync(env.agentKeysPath)) {
44
48
  let parsed;
@@ -54,10 +58,19 @@ export function loadKeyring(env) {
54
58
  if (Array.isArray(parsed)) {
55
59
  for (const e of parsed) {
56
60
  if (e && typeof e.agentId === "string" && typeof e.secret === "string") {
61
+ // A credential for ANOTHER vault is not this adapter's to use.
62
+ // Counted rather than dropped in silence, so a keyring that looks
63
+ // empty can be told apart from one whose entries all belong
64
+ // elsewhere — those are different problems with different fixes.
65
+ if (env.vault && e.agentId !== env.vault) {
66
+ ring.skippedOtherVault += 1;
67
+ continue;
68
+ }
57
69
  add({
58
70
  agentId: e.agentId,
59
71
  secret: e.secret,
60
72
  nodeId: typeof e.nodeId === "number" ? e.nodeId : undefined,
73
+ role: typeof e.role === "string" && e.role ? e.role : undefined,
61
74
  });
62
75
  }
63
76
  }
@@ -68,11 +81,15 @@ export function loadKeyring(env) {
68
81
  add(c);
69
82
  ring.fallback = c;
70
83
  }
71
- else if (ring.byAccount.size === 1) {
72
- // Exactly one key in the file and no env pair: unambiguous, so use it.
73
- // More than one with no env pair is deliberately NOT guessed at — see
74
- // selectAgent.
75
- ring.fallback = [...ring.byAccount.values()][0];
84
+ else if (ring.all.length === 1) {
85
+ // Exactly one credential and no env pair: unambiguous, so use it.
86
+ //
87
+ // `all.length`, NOT `byAccount.size`. Every virtual agent on a vault
88
+ // shares an `agentId`, so the map collapses them and two agents read as
89
+ // one — which made this branch fire on an ambiguous keyring and pick
90
+ // whichever was written last. Silently: the payment succeeded, against
91
+ // the wrong node's budget.
92
+ ring.fallback = ring.all[0];
76
93
  }
77
94
  return ring;
78
95
  }
@@ -86,10 +103,26 @@ export class AgentSelectionError extends Error {
86
103
  * costs the caller another round trip to discover what the valid options were.
87
104
  */
88
105
  export function selectAgent(ring, want = {}) {
106
+ // Lists `all`, not `byAccount.values()`. The map holds one entry per
107
+ // ACCOUNT, so on a fleet it named a single agent while several were
108
+ // loaded — an error message that under-reports the thing it exists to
109
+ // report.
89
110
  const known = () => {
90
- const list = [...ring.byAccount.values()].map((c) => `${c.agentId}${typeof c.nodeId === "number" ? ` (node ${c.nodeId})` : ""}`);
111
+ const list = ring.all.map((c) => {
112
+ const bits = [c.role, typeof c.nodeId === "number" ? `node ${c.nodeId}` : null].filter(Boolean);
113
+ return bits.length ? `${c.agentId} (${bits.join(", ")})` : c.agentId;
114
+ });
91
115
  return list.length ? list.join(", ") : "none";
92
116
  };
117
+ if (want.role !== undefined) {
118
+ const c = ring.byRole.get(want.role);
119
+ if (!c) {
120
+ const roles = ring.all.map((x) => x.role).filter(Boolean);
121
+ throw new AgentSelectionError(`no key for role '${want.role}'. Known roles: ${roles.length ? roles.join(", ") : "none"}. ` +
122
+ `Loaded agents: ${known()}.`);
123
+ }
124
+ return c;
125
+ }
93
126
  if (want.agent !== undefined) {
94
127
  const c = ring.byAccount.get(want.agent);
95
128
  if (!c) {
@@ -108,12 +141,49 @@ export function selectAgent(ring, want = {}) {
108
141
  }
109
142
  if (ring.fallback)
110
143
  return ring.fallback;
111
- if (ring.byAccount.size > 1) {
144
+ // A BROKEN KEY FILE IS WHAT REMOVED THE DEFAULT. Appended to the ambiguity
145
+ // error below as well as replacing the empty-ring one, because with a fleet
146
+ // on disk the ring is NOT empty and "pass role, agent or nodeId" is a true
147
+ // statement that hides a regression — a deployment that had a default and
148
+ // silently lost it reads as a caller bug.
149
+ const keyFileNote = ring.agentKeyError
150
+ ? ` The default agent is ALSO missing because a key file was named and could ` +
151
+ `not be read: ${ring.agentKeyError}`
152
+ : "";
153
+ if (ring.all.length > 1) {
112
154
  // Refuse rather than guess. Picking arbitrarily would charge a budget the
113
155
  // caller did not choose, and the payment would succeed — so the mistake
114
156
  // would surface later as a confusing balance, not as an error.
157
+ //
158
+ // `all.length`, NOT `byAccount.size`. This guard was unreachable for a
159
+ // fleet of virtual agents: they share an `agentId`, the map held one, and
160
+ // `loadKeyring` had already installed the last-written one as `fallback`
161
+ // on the way past. The refusal below never fired and the wrong agent paid.
115
162
  throw new AgentSelectionError(`several agents are loaded and the request named none, so there is no ` +
116
- `safe default. Pass "agent" or "nodeId". Loaded agents: ${known()}.`);
163
+ `safe default. Pass "role", "agent" or "nodeId". Loaded agents: ${known()}.` +
164
+ keyFileNote);
165
+ }
166
+ // BEFORE the other-vault and nothing-configured branches. A named-but-broken
167
+ // key file is a cause; both of those are descriptions of an empty result, and
168
+ // the nothing-configured one would tell the operator to paste the secret
169
+ // inline — undoing the reason the file option exists.
170
+ if (ring.agentKeyError) {
171
+ throw new AgentSelectionError(`no agent key could be loaded: ${ring.agentKeyError} Fix the path or the file — ` +
172
+ `or unset the _FILE variable to fall back to an inline NEAR_VAULT_AGENT_KEY.` +
173
+ (ring.skippedOtherVault > 0
174
+ ? ` (${ring.skippedOtherVault} credential(s) in the keyring file belong to a ` +
175
+ `different vault and were skipped.)`
176
+ : ""));
177
+ }
178
+ // "NOTHING CONFIGURED" AND "NOTHING FOR THIS VAULT" ARE DIFFERENT PROBLEMS.
179
+ // Both leave an empty ring, and the generic message sends someone to add
180
+ // credentials they already have — when the real fix is pointing the adapter
181
+ // at the vault those credentials belong to.
182
+ if (ring.skippedOtherVault > 0) {
183
+ throw new AgentSelectionError(`no agent credentials for this vault. ${ring.skippedOtherVault} credential(s) ` +
184
+ `were loaded but belong to a DIFFERENT vault, so they were skipped — a key ` +
185
+ `bound to another vault cannot spend here. Check NEAR_VAULT_CONTRACT points ` +
186
+ `at the vault you mean, or provision an agent for it with vault_add_role.`);
117
187
  }
118
188
  throw new AgentSelectionError("no agent credentials are configured. Set NEAR_VAULT_AGENT_ID and " +
119
189
  "NEAR_VAULT_AGENT_KEY, or point NEAR_VAULT_AGENT_KEYS at the " +
package/dist/map.d.ts CHANGED
@@ -69,9 +69,47 @@ export interface DayUsage {
69
69
  export interface ApiAgent {
70
70
  agentId: string;
71
71
  walletAddress: string;
72
- createdAt: string;
72
+ /**
73
+ * NULL, because the contract does not record one.
74
+ *
75
+ * `Node` has no timestamp field - id, parent, path, label, account,
76
+ * limits, cooldown, frozen, recipients, and nothing else. Only a payment
77
+ * REQUEST carries `created_at_ns`.
78
+ *
79
+ * This used to be `new Date(0).toISOString()`, which satisfied a
80
+ * non-nullable type by inventing "1970-01-01T00:00:00.000Z". Nothing
81
+ * rendered it yet, so it was not a visible bug - it was a loaded one: the
82
+ * first page to show an agent's age would have shipped "Created Jan 1,
83
+ * 1970" with complete confidence. A missing value has to BE missing, or
84
+ * the type system is helping you lie.
85
+ */
86
+ createdAt: string | null;
73
87
  status: "active" | "killed";
88
+ /** The node's own label. NO tree glyphs, no indentation. */
74
89
  displayName: string;
90
+ /**
91
+ * How deep in the budget tree this node sits. Root is 0.
92
+ *
93
+ * A NUMBER, deliberately. Depth used to be encoded into displayName as
94
+ * U+00A0 padding plus a box-drawing glyph, and every invisible-character
95
+ * bug in this project traces back to that: regexes that silently matched
96
+ * nothing, `btoa` throwing on the glyph, agent names carrying invisible
97
+ * padding into prose. A consumer that wants indentation can multiply.
98
+ */
99
+ depth: number;
100
+ /**
101
+ * This node's parent id, or null at the root.
102
+ *
103
+ * WITHOUT IT THE DASHBOARD CANNOT CHECK THE CONTRACT'S OWN RULE. Under
104
+ * strict allocation `set_node_caps` refuses a cap below the sum already
105
+ * allocated to that node's children, at EVERY depth, not just root
106
+ * (lib.rs:854). The policies page could only evaluate that for root,
107
+ * because `depth` alone says a node sits at level 2 but not WHICH level-1
108
+ * node it hangs from. So shrinking a mid-tree budget below its children
109
+ * passed validation, and the owner signed and paid gas for a transaction
110
+ * the contract was always going to reject.
111
+ */
112
+ parent: number | null;
75
113
  lifetimeSpent?: string;
76
114
  lifetimePayments?: number;
77
115
  walletBalance?: string | null;
@@ -82,7 +120,24 @@ export interface ApiAgent {
82
120
  perTxLimit?: string;
83
121
  cooldownSeconds?: number;
84
122
  blockUnknown?: boolean;
123
+ /** The EFFECTIVE allowlist — this node's own, or the nearest ancestor's. */
85
124
  allowedAddresses?: string[];
125
+ /**
126
+ * This node's OWN list, present only when it has one.
127
+ *
128
+ * Reported separately from `allowedAddresses` because the two answer
129
+ * different questions, and an editor needs both. `allowedAddresses` says
130
+ * "who can be paid from here", which is what a reader wants. This says
131
+ * "who is written on THIS node", which is what a writer needs — because
132
+ * saving a list REPLACES the inherited one rather than extending it, so
133
+ * an editor that only sees the effective list would show a parent's
134
+ * addresses and silently offer to copy them onto the child.
135
+ *
136
+ * Absent means inheriting. That is a different state from an empty list,
137
+ * which the contract rejects outright: `validate_recipients` fails on
138
+ * `is_empty()` with "1..=32 recipients, or None to inherit".
139
+ */
140
+ ownAllowedAddresses?: string[];
86
141
  } | null;
87
142
  }
88
143
  export interface Payment {
@@ -113,16 +168,6 @@ export declare function treeToApiAgents(tree: VaultNode[], budgets: Map<number,
113
168
  spent: bigint;
114
169
  count: number;
115
170
  }>): ApiAgent[];
116
- /**
117
- * Depth prefix using NON-BREAKING spaces.
118
- *
119
- * Plain spaces look right in a test assertion and vanish on screen: HTML
120
- * collapses leading whitespace, so depth-2 rendered identically to depth-1 and
121
- * the hierarchy — the entire point of the view — was invisible. The unit test
122
- * was green while the screen was wrong, which is why this is asserted against
123
- * the rendered character and not a trimmed string.
124
- */
125
- export declare function indent(depth: number, label: string): string;
126
171
  /** Mirrors the contract's `effective_recipients`: nearest ancestor with a list. */
127
172
  export declare function effectiveRecipients(n: VaultNode, byId: Map<number, VaultNode>): string[] | undefined;
128
173
  /** Settled spend per node, within RETAINED history (purge_settled removes rows). */
package/dist/map.js CHANGED
@@ -23,9 +23,11 @@ export function nodeToApiAgent(n, usage, decimals) {
23
23
  // the thing this product exists to make visible.
24
24
  agentId: n.account ?? `node-${n.id}`,
25
25
  walletAddress: n.account ?? "",
26
- createdAt: new Date(0).toISOString(),
26
+ createdAt: null,
27
27
  status: n.frozen ? "killed" : "active",
28
28
  displayName: n.label,
29
+ depth: 0,
30
+ parent: n.parent,
29
31
  dailySpent: fromBaseUnits(used, decimals),
30
32
  dailyBudgetUsedPercent: cap > 0n ? Number((used * 100n) / cap) : 0,
31
33
  walletBalance: null, // the VAULT holds the tokens; an agent account holds none
@@ -50,12 +52,21 @@ export function treeToApiAgents(tree, budgets, decimals, lifetime = new Map()) {
50
52
  const byId = new Map(tree.map((n) => [n.id, n]));
51
53
  return tree.map((n) => {
52
54
  const a = nodeToApiAgent(n, budgets.get(n.id) ?? { executed: "0", pending: "0" }, decimals);
53
- a.displayName = indent(n.path.length, n.label);
55
+ a.depth = n.path.length;
54
56
  // The EFFECTIVE allowlist, resolved the way the contract resolves it.
55
57
  // Reporting the raw `recipients` field made every inheriting node look
56
58
  // unrestricted in the UI ("Open x402"), which is the opposite of true:
57
59
  // they inherit the root's allowlist and the contract enforces it.
58
- a.policy = { ...a.policy, allowedAddresses: effectiveRecipients(n, byId) };
60
+ // BOTH, deliberately. `allowedAddresses` is what the contract will
61
+ // enforce here; `ownAllowedAddresses` is what is actually written on this
62
+ // node, and is undefined when it inherits. An editor that cannot tell
63
+ // them apart will offer to "keep" a parent's list and, by saving it,
64
+ // detach the child from the parent forever.
65
+ a.policy = {
66
+ ...a.policy,
67
+ allowedAddresses: effectiveRecipients(n, byId),
68
+ ownAllowedAddresses: n.recipients?.length ? n.recipients : undefined,
69
+ };
59
70
  const lt = lifetime.get(n.id);
60
71
  if (lt) {
61
72
  a.lifetimeSpent = fromBaseUnits(lt.spent, decimals);
@@ -64,20 +75,6 @@ export function treeToApiAgents(tree, budgets, decimals, lifetime = new Map()) {
64
75
  return a;
65
76
  });
66
77
  }
67
- /**
68
- * Depth prefix using NON-BREAKING spaces.
69
- *
70
- * Plain spaces look right in a test assertion and vanish on screen: HTML
71
- * collapses leading whitespace, so depth-2 rendered identically to depth-1 and
72
- * the hierarchy — the entire point of the view — was invisible. The unit test
73
- * was green while the screen was wrong, which is why this is asserted against
74
- * the rendered character and not a trimmed string.
75
- */
76
- export function indent(depth, label) {
77
- if (depth === 0)
78
- return label;
79
- return `${"\u00a0".repeat(3 * (depth - 1))}└─ ${label}`;
80
- }
81
78
  /** Mirrors the contract's `effective_recipients`: nearest ancestor with a list. */
82
79
  export function effectiveRecipients(n, byId) {
83
80
  const chain = [...n.path, n.id].reverse(); // self first, then upward
@@ -138,9 +135,30 @@ export function requestToPayment(r, nodeLabels, decimals, delivery) {
138
135
  amount: fromBaseUnits(BigInt(r.amount), decimals),
139
136
  currency: "USDC",
140
137
  recipientAddress: r.recipient,
141
- // What the agent paid FOR, which is what the memo carries. The recipient
142
- // is already its own column, so echoing it adds nothing.
143
- venue: r.memo || r.recipient,
138
+ /**
139
+ * THE MEMO IS REDACTED HERE, and only here.
140
+ *
141
+ * The dashboard is keyless and multi-tenant: anyone can type a vault name
142
+ * and read it. A memo is what the agent paid FOR — "landing page draft",
143
+ * "copywriting" — and that is a company's operations, legible to anyone
144
+ * who guesses an account id.
145
+ *
146
+ * BE EXACT ABOUT WHAT THIS DOES. The memo is ON CHAIN and public; this
147
+ * does not make it secret and must never be described as privacy. It
148
+ * removes it from the surface that turned "know how to query a NEAR RPC"
149
+ * into "type a name", which is a real reduction in casual exposure and
150
+ * nothing more. Anyone determined still has it in one view call.
151
+ *
152
+ * THE AGENT'S OWN TOOLING IS UNAFFECTED. `vault_history` in
153
+ * @floatonnear/mcp calls `list_requests` directly against the chain and
154
+ * never passes through this adapter, so an agent keeps full context on the
155
+ * machine its owner controls. The split is PUBLIC SURFACE vs LOCAL TOOL,
156
+ * not two different truths.
157
+ *
158
+ * Falls back to the recipient, which was already the behaviour for a
159
+ * payment with no memo — so a row still says who was paid.
160
+ */
161
+ venue: r.recipient,
144
162
  rail: railOf(r.route),
145
163
  // A Base payment is NOT "success". The contract proved it obtained a
146
164
  // signature; it cannot see whether the transaction landed, and nothing in
@@ -161,7 +179,11 @@ export function requestToPayment(r, nodeLabels, decimals, delivery) {
161
179
  // anything undelivered.
162
180
  txSignature: delivery?.baseTxHash ?? null,
163
181
  createdAt: nsToIso(r.created_at_ns),
164
- prompt: r.memo || null,
182
+ // Redacted with `venue` above. Null rather than omitted, so a consumer
183
+ // that expects the field still type-checks and an absent memo and a
184
+ // withheld one look the same to it — the dashboard must not render a
185
+ // "redacted" badge that advertises which vaults have interesting ones.
186
+ prompt: null,
165
187
  resultUrl: null,
166
188
  // Carried for terminal failures too, so a failed row can say WHY.
167
189
  blockReason: r.status === "SETTLED" ? null : r.reason_code,
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Every owner action the dashboard can prepare.
3
+ *
4
+ * This lived as a switch inline in the request handler, which meant it was
5
+ * unreachable from a test — and that is how `pause` and `allocation` came to
6
+ * be rendered as dead readout text on the Policies page despite both having
7
+ * contract setters and both being checked on every single payment.
8
+ *
9
+ * It stays a PURE function: no signing, no network, no key. The adapter only
10
+ * ever holds an agent key, which can call `request_payment` and nothing else,
11
+ * so an owner method can only ever be *described* here and signed in the
12
+ * owner's own wallet.
13
+ */
14
+ export type OwnerPlanRequest = {
15
+ action: "caps";
16
+ nodeId: number;
17
+ dailyCap?: string;
18
+ perTxCap?: string;
19
+ } | {
20
+ action: "cooldown";
21
+ nodeId: number;
22
+ cooldownSec?: number;
23
+ } | {
24
+ action: "recipients";
25
+ nodeId: number;
26
+ recipients?: string[] | null;
27
+ } | {
28
+ action: "freeze";
29
+ nodeId: number;
30
+ frozen?: boolean;
31
+ } | {
32
+ action: "pause";
33
+ paused?: boolean;
34
+ } | {
35
+ action: "allocation";
36
+ strict?: boolean;
37
+ }
38
+ /**
39
+ * The two teardown steps a BROWSER SIGNATURE CAN REACH.
40
+ *
41
+ * Closing a vault is six steps (see scripts/teardown-vault.mjs) and four of
42
+ * them need the VAULT as signer, not the owner: the gas-key withdrawal, the
43
+ * token's `storage_unregister`, the 33-byte stub deploy and the
44
+ * `DeleteAccount` itself. A wallet refuses `DeployContract` from a dApp —
45
+ * which is the entire reason vault CREATION goes through a factory — so a
46
+ * dashboard cannot finish a teardown and must not pretend to.
47
+ *
48
+ * These two it can do, and they are the two that matter most:
49
+ *
50
+ * withdraw deleting an account does NOT return fungible tokens, so
51
+ * moving them out first is the difference between recovering
52
+ * a balance and burning it
53
+ * purge the only lever that shrinks STATE, and without it the
54
+ * delete is refused outright
55
+ */
56
+ | {
57
+ action: "withdraw";
58
+ token?: string;
59
+ amountBase?: string;
60
+ receiverId?: string;
61
+ } | {
62
+ action: "purge";
63
+ beforeDay?: number;
64
+ limit?: number;
65
+ }
66
+ /**
67
+ * Give an agent key spending authority, AFTER the vault already exists.
68
+ *
69
+ * `add_role` is the method and `bind_virtual_agent` is not, which is not
70
+ * obvious and matters. Binding alone grants authority and leaves the key
71
+ * unable to transact: it still needs installing as a NEP-611 gas key ON
72
+ * the vault, and that `AddKey` would need the VAULT as signer — the same
73
+ * wall the teardown runs into.
74
+ *
75
+ * `add_role` does the whole thing in one owner-signed call, and the
76
+ * contract says why in its own comment: "THE KEY IS ADDED TO THIS
77
+ * ACCOUNT, BY THIS ACCOUNT. That is the only way an AddKey can reach the
78
+ * vault from a browser signature." The attached deposit funds the gas key
79
+ * and the vault nets zero.
80
+ *
81
+ * The cost is that it creates a NEW node under root rather than attaching
82
+ * to an existing one. That is the honest trade: the alternative is not
83
+ * "attach to an existing node", it is "cannot be done from a browser".
84
+ */
85
+ | {
86
+ action: "role";
87
+ label?: string;
88
+ dailyCap?: string;
89
+ perTxCap?: string;
90
+ cooldownSec?: number;
91
+ recipients?: string[] | null;
92
+ publicKey?: string;
93
+ fundingYocto?: string;
94
+ };
95
+ /**
96
+ * The action name is not one we serve.
97
+ *
98
+ * A SEPARATE type from a bad argument, because the HTTP layer has to tell
99
+ * them apart: it used to catch every throw from here and report
100
+ * `unknown action: <name>`, so a negative cooldown or a duplicate recipient
101
+ * was reported as a method that does not exist - the one explanation that
102
+ * cannot lead the caller to a fix.
103
+ */
104
+ export declare class UnknownActionError extends Error {
105
+ constructor(action: string);
106
+ }
107
+ export type OwnerPlanEnv = {
108
+ vault: string;
109
+ token: string;
110
+ decimals: number;
111
+ };
112
+ export declare function planOwnerAction(b: OwnerPlanRequest, env: OwnerPlanEnv): import("@floatonnear/mcp/payloads").OwnerPayload;