@usesigil/kit 0.2.3 → 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.
@@ -3,11 +3,17 @@
3
3
  *
4
4
  * Each function is stateless (fetches fresh from RPC), composes existing
5
5
  * SDK functions, and returns raw values with toJSON() for MCP serialization.
6
+ *
7
+ * S14: the five view types are composed by pure `build*` helpers that take a
8
+ * shared {@link OverviewContext}. The existing reads each assemble their own
9
+ * context with minimal fetches; `getOverview` fetches once and shares the
10
+ * context across all helpers so derived values (security posture etc.) are
11
+ * computed exactly once.
6
12
  */
7
13
  import { isSome } from "@solana/kit";
8
14
  import { toDxError } from "./errors.js";
9
15
  import { resolveVaultStateForOwner, getSpendingHistory, getPendingPolicyForVault, } from "../state-resolver.js";
10
- import { getVaultPnL } from "../balance-tracker.js";
16
+ import { getVaultPnL, getVaultPnLFromState } from "../balance-tracker.js";
11
17
  import { getSecurityPosture } from "../security-analytics.js";
12
18
  import { evaluateAlertConditions } from "../security-analytics.js";
13
19
  import { getAgentProfile } from "../agent-analytics.js";
@@ -42,75 +48,416 @@ function serializeBigints(obj) {
42
48
  }
43
49
  return obj;
44
50
  }
45
- // ─── getVaultState ───────────────────────────────────────────────────────────
46
- export async function getVaultState(rpc, vault, network) {
47
- try {
48
- const [state, pnl] = await Promise.all([
49
- resolveVaultStateForOwner(rpc, vault, undefined, toNet(network)),
50
- getVaultPnL(rpc, vault, toNet(network)),
51
- ]);
52
- const posture = getSecurityPosture(asVaultState(state));
53
- const v = state.vault;
54
- const bal = state.stablecoinBalances;
55
- const total = bal.usdc + bal.usdt;
56
- const tokens = [
57
- ...(bal.usdc > 0n
58
- ? [{ mint: "USDC", amount: bal.usdc, decimals: 6 }]
59
- : []),
60
- ...(bal.usdt > 0n
61
- ? [{ mint: "USDT", amount: bal.usdt, decimals: 6 }]
62
- : []),
63
- ];
64
- const checks = posture.checks.map((c) => ({
65
- name: c.id,
66
- passed: c.passed,
67
- }));
68
- const level = posture.criticalFailures.length > 0
69
- ? "critical"
70
- : posture.failCount > 0
71
- ? "elevated"
72
- : "healthy";
73
- return {
51
+ /**
52
+ * Default size of the activity window included in `getOverview`. Consumers
53
+ * may override via `GetOverviewOptions.activityLimit`.
54
+ *
55
+ * The value matches `getAgents`' existing per-agent enrichment window so one
56
+ * fetch serves both the overview's activity feed and the agents' last-action
57
+ * fields without inflating RPC cost.
58
+ */
59
+ export const DEFAULT_OVERVIEW_ACTIVITY_LIMIT = 100;
60
+ /**
61
+ * Shared "is this an account-not-found error?" predicate.
62
+ *
63
+ * Both `getPolicy` and `getOverview` treat a missing `PendingPolicyUpdate`
64
+ * account as "no pending update" (not an error). The current Kit doesn't
65
+ * expose a typed `AccountNotFound` SolanaError at this call site, so both
66
+ * paths fall back to substring matching. Extracting it here means the
67
+ * fragility lives in one place and only one site needs to update if Kit
68
+ * ever surfaces a typed variant.
69
+ */
70
+ function isAccountNotFoundError(err) {
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ return (message.includes("could not find") ||
73
+ message.includes("Account does not exist"));
74
+ }
75
+ // ─── Build helpers (pure composition — no RPC) ───────────────────────────────
76
+ // Each helper accepts an OverviewContext and returns one view type. `getOverview`
77
+ // pre-populates memoized derivations (posture/breakdown/alerts) so repeat calls
78
+ // share one computation; existing reads pass a minimal ctx and the helper
79
+ // derives what it needs from `ctx.state`.
80
+ /**
81
+ * Guard for state fields that `resolveVaultStateForOwner` normally guarantees.
82
+ *
83
+ * `state.vault` and `state.policy` are non-null on any success path from the
84
+ * resolver, but consumers that hand-construct an {@link OverviewContext} for
85
+ * testing or custom composition could pass a partial shape. Fail fast with a
86
+ * labeled error instead of a cryptic "cannot read properties of null".
87
+ */
88
+ function requireCtxField(value, field) {
89
+ if (value === null || value === undefined) {
90
+ throw new Error(`[dashboard/reads] OverviewContext.state.${field} is required but missing`);
91
+ }
92
+ return value;
93
+ }
94
+ /**
95
+ * Compose {@link VaultState} from a pre-fetched {@link OverviewContext}.
96
+ *
97
+ * Requires `ctx.state`. Uses `ctx.pnl` when present; otherwise defaults to
98
+ * zero P&L. Uses `ctx.posture` when memoized; otherwise computes from state.
99
+ */
100
+ export function buildVaultState(ctx) {
101
+ const v = requireCtxField(ctx.state.vault, "vault");
102
+ const posture = ctx.posture ?? getSecurityPosture(asVaultState(ctx.state));
103
+ const pnlPercent = ctx.pnl && Number.isFinite(ctx.pnl.pnlPercent) ? ctx.pnl.pnlPercent : 0;
104
+ const pnlAbsolute = ctx.pnl ? ctx.pnl.pnl : 0n;
105
+ const bal = ctx.state.stablecoinBalances;
106
+ const total = bal.usdc + bal.usdt;
107
+ const tokens = [
108
+ ...(bal.usdc > 0n ? [{ mint: "USDC", amount: bal.usdc, decimals: 6 }] : []),
109
+ ...(bal.usdt > 0n ? [{ mint: "USDT", amount: bal.usdt, decimals: 6 }] : []),
110
+ ];
111
+ const checks = posture.checks.map((c) => ({
112
+ name: c.id,
113
+ passed: c.passed,
114
+ }));
115
+ const level = posture.criticalFailures.length > 0
116
+ ? "critical"
117
+ : posture.failCount > 0
118
+ ? "elevated"
119
+ : "healthy";
120
+ const vaultAddr = ctx.vault;
121
+ const status = (v.status === 0 ? "active" : v.status === 1 ? "frozen" : "closed");
122
+ return {
123
+ vault: {
124
+ address: vaultAddr,
125
+ status,
126
+ owner: v.owner,
127
+ agentCount: v.agents?.length ?? 0,
128
+ openPositions: v.openPositions,
129
+ totalVolume: v.totalVolume,
130
+ totalFees: v.totalFeesCollected,
131
+ },
132
+ balance: { total, tokens },
133
+ pnl: { percent: pnlPercent, absolute: pnlAbsolute },
134
+ health: { level, alertCount: posture.failCount, checks },
135
+ toJSON: () => ({
74
136
  vault: {
75
- address: vault,
76
- status: v.status === 0 ? "active" : v.status === 1 ? "frozen" : "closed",
137
+ address: vaultAddr,
138
+ status,
77
139
  owner: v.owner,
78
140
  agentCount: v.agents?.length ?? 0,
79
141
  openPositions: v.openPositions,
80
- totalVolume: v.totalVolume,
81
- totalFees: v.totalFeesCollected,
142
+ totalVolume: bs(v.totalVolume),
143
+ totalFees: bs(v.totalFeesCollected),
82
144
  },
83
- balance: { total, tokens },
84
- pnl: {
85
- percent: Number.isFinite(pnl.pnlPercent) ? pnl.pnlPercent : 0,
86
- absolute: pnl.pnl,
145
+ balance: {
146
+ total: bs(total),
147
+ tokens: tokens.map((t) => ({ ...t, amount: bs(t.amount) })),
87
148
  },
149
+ pnl: { percent: pnlPercent, absolute: bs(pnlAbsolute) },
88
150
  health: { level, alertCount: posture.failCount, checks },
151
+ }),
152
+ };
153
+ }
154
+ /**
155
+ * Compose {@link AgentData}[] from a pre-fetched {@link OverviewContext}.
156
+ *
157
+ * Requires `ctx.state`. Uses `ctx.activity` to populate per-agent last-action
158
+ * and blocked-count fields; when absent, those fields default to empty/zero.
159
+ */
160
+ export function buildAgents(ctx) {
161
+ const state = ctx.state;
162
+ const v = requireCtxField(state.vault, "vault");
163
+ const vaultAgents = v.agents;
164
+ if (!vaultAgents || vaultAgents.length === 0)
165
+ return [];
166
+ const activity = ctx.activity ?? [];
167
+ const blockedCutoffMs = Date.now() - 24 * 3600 * 1000;
168
+ return vaultAgents.map((entry) => {
169
+ const addr = entry.pubkey;
170
+ const profile = getAgentProfile(asVaultState(state), addr);
171
+ const budget = state.allAgentBudgets.get(addr);
172
+ const spentAmt = budget?.spent24h ?? 0n;
173
+ const capAmt = budget?.cap ?? 0n;
174
+ const pct = capAmt > 0n ? Number((spentAmt * 10000n) / capAmt) / 100 : 0;
175
+ // Items are newest-first (getSignaturesForAddress ordering).
176
+ const agentActivity = activity.filter((item) => item.agent !== null && item.agent === addr);
177
+ const last = agentActivity[0];
178
+ const lastActionType = last
179
+ ? mapCategory(last.category ?? "unknown", last.eventType ?? "", last.actionType ?? undefined)
180
+ : "";
181
+ const lastActionProtocol = last?.protocolName ?? "";
182
+ const lastActionTimestamp = last ? last.timestamp * 1000 : 0;
183
+ const blockedCount24h = agentActivity.filter((item) => !item.success && item.timestamp * 1000 >= blockedCutoffMs).length;
184
+ return {
185
+ address: addr,
186
+ status: (profile?.paused ? "paused" : "active"),
187
+ capabilityLabel: profile?.capabilityLabel ?? "Disabled",
188
+ capability: profile?.capability ?? 0,
189
+ spending: { amount: spentAmt, limit: capAmt, percent: pct },
190
+ lastActionType,
191
+ lastActionProtocol,
192
+ lastActionTimestamp,
193
+ blockedCount24h,
89
194
  toJSON: () => ({
90
- vault: {
91
- address: vault,
92
- status: (v.status === 0
93
- ? "active"
94
- : v.status === 1
95
- ? "frozen"
96
- : "closed"),
97
- owner: v.owner,
98
- agentCount: v.agents?.length ?? 0,
99
- openPositions: v.openPositions,
100
- totalVolume: bs(v.totalVolume),
101
- totalFees: bs(v.totalFeesCollected),
102
- },
103
- balance: {
104
- total: bs(total),
105
- tokens: tokens.map((t) => ({ ...t, amount: bs(t.amount) })),
106
- },
107
- pnl: {
108
- percent: Number.isFinite(pnl.pnlPercent) ? pnl.pnlPercent : 0,
109
- absolute: bs(pnl.pnl),
110
- },
111
- health: { level, alertCount: posture.failCount, checks },
195
+ address: addr,
196
+ status: profile?.paused ? "paused" : "active",
197
+ capabilityLabel: profile?.capabilityLabel ?? "Disabled",
198
+ capability: profile?.capability ?? 0,
199
+ spending: { amount: bs(spentAmt), limit: bs(capAmt), percent: pct },
200
+ lastActionType,
201
+ lastActionProtocol,
202
+ lastActionTimestamp,
203
+ blockedCount24h,
112
204
  }),
113
205
  };
206
+ });
207
+ }
208
+ /**
209
+ * Compose {@link SpendingData} from a pre-fetched {@link OverviewContext}.
210
+ *
211
+ * Requires `ctx.state`. Uses `ctx.breakdown` when memoized.
212
+ */
213
+ export function buildSpending(ctx) {
214
+ const state = ctx.state;
215
+ const breakdown = ctx.breakdown ?? getSpendingBreakdown(asVaultState(state));
216
+ const nowUnix = BigInt(Math.floor(Date.now() / 1000));
217
+ const epochs = getSpendingHistory(state.tracker, nowUnix);
218
+ const chart = epochs.map((e) => ({
219
+ time: new Date(e.timestamp * 1000).toISOString(),
220
+ amount: Number(e.usdAmount) / 1_000_000,
221
+ }));
222
+ const { spent24h: spent, cap, remaining } = state.globalBudget;
223
+ const percent = cap > 0n ? Number((spent * 10000n) / cap) / 100 : 0;
224
+ const velocityPerMs = spent > 0n ? Number(spent) / (24 * 3600 * 1000) : 0;
225
+ const rundown = velocityPerMs > 0 && remaining > 0n
226
+ ? Math.floor(Number(remaining) / velocityPerMs)
227
+ : 0;
228
+ const protoBreak = breakdown.byProtocol.map((p) => ({
229
+ name: resolveProtocolName(p.protocol),
230
+ programId: p.protocol,
231
+ amount: p.spent24h,
232
+ percent: p.utilization,
233
+ }));
234
+ return {
235
+ global: { today: spent, cap, remaining, percent, rundownMs: rundown },
236
+ chart,
237
+ protocolBreakdown: protoBreak,
238
+ toJSON: () => ({
239
+ global: {
240
+ today: bs(spent),
241
+ cap: bs(cap),
242
+ remaining: bs(remaining),
243
+ percent,
244
+ rundownMs: rundown,
245
+ },
246
+ chart,
247
+ protocolBreakdown: protoBreak.map((p) => ({
248
+ ...p,
249
+ amount: bs(p.amount),
250
+ })),
251
+ }),
252
+ };
253
+ }
254
+ /**
255
+ * Compose {@link HealthData} from a pre-fetched {@link OverviewContext}.
256
+ *
257
+ * Requires `ctx.state` + `ctx.vault` (for alert evaluation). Uses `ctx.posture`
258
+ * and `ctx.alerts` when memoized.
259
+ */
260
+ export function buildHealth(ctx) {
261
+ const posture = ctx.posture ?? getSecurityPosture(asVaultState(ctx.state));
262
+ const alerts = ctx.alerts ?? evaluateAlertConditions(ctx.state, ctx.vault);
263
+ const level = posture.criticalFailures.length > 0
264
+ ? "critical"
265
+ : posture.failCount > 0
266
+ ? "elevated"
267
+ : "healthy";
268
+ const critAlerts = alerts.filter((a) => a.severity === "critical");
269
+ const lastBlock = critAlerts.length > 0
270
+ ? {
271
+ agent: critAlerts[0].agentAddress || "",
272
+ reason: critAlerts[0].title,
273
+ amount: 0n,
274
+ timestamp: Date.now(),
275
+ }
276
+ : undefined;
277
+ const checks = posture.checks.map((c) => ({
278
+ name: c.id,
279
+ passed: c.passed,
280
+ }));
281
+ return {
282
+ level,
283
+ blockedCount24h: critAlerts.length,
284
+ checks,
285
+ lastBlock,
286
+ toJSON: () => ({
287
+ level,
288
+ blockedCount24h: critAlerts.length,
289
+ checks,
290
+ lastBlock: lastBlock
291
+ ? { ...lastBlock, amount: bs(lastBlock.amount) }
292
+ : undefined,
293
+ }),
294
+ };
295
+ }
296
+ /**
297
+ * Compose {@link PolicyData} from a pre-fetched {@link OverviewContext}.
298
+ *
299
+ * Requires `ctx.state`. Uses `ctx.pendingPolicy` (which may be `null` to mean
300
+ * "confirmed no pending update"); when `undefined` treats as no pending update.
301
+ */
302
+ export function buildPolicy(ctx) {
303
+ const state = ctx.state;
304
+ const pendingPolicy = ctx.pendingPolicy ?? null;
305
+ const p = requireCtxField(state.policy, "policy");
306
+ const protocols = (p.protocols || []);
307
+ const approvedApps = protocols.map((addr) => ({
308
+ name: resolveProtocolName(addr),
309
+ programId: addr,
310
+ }));
311
+ const modeMap = {
312
+ 0: "unrestricted",
313
+ 1: "whitelist",
314
+ 2: "blacklist",
315
+ };
316
+ const dailyCap = p.dailySpendingCapUsd;
317
+ const maxPerTrade = p.maxTransactionSizeUsd ?? 0n;
318
+ const protocolCaps = (p.protocolCaps || []);
319
+ const sessionExpiry = p.sessionExpirySlots;
320
+ const policyVer = (p.policyVersion ?? 0n);
321
+ const timelockSec = Number(p.timelockDuration);
322
+ let pendingUpdate;
323
+ if (pendingPolicy) {
324
+ const pp = pendingPolicy;
325
+ const executesAtSec = Number(pp.executesAt ?? 0);
326
+ const appliesAt = Number.isFinite(executesAtSec) ? executesAtSec * 1000 : 0;
327
+ const nowSec = Math.floor(Date.now() / 1000);
328
+ // Decode each Option<T> field from PendingPolicyUpdate. Only Some fields
329
+ // land in `changes`. 14 fields total — every timelockable PolicyConfig field.
330
+ // Source: programs/sigil/src/state/pending_policy.rs
331
+ const changes = {};
332
+ if (isSome(pp.dailySpendingCapUsd))
333
+ changes.dailyCap = pp.dailySpendingCapUsd.value;
334
+ if (isSome(pp.maxTransactionAmountUsd))
335
+ changes.maxPerTrade = pp.maxTransactionAmountUsd.value;
336
+ if (isSome(pp.protocols))
337
+ changes.approvedApps = pp.protocols.value;
338
+ if (isSome(pp.protocolMode))
339
+ changes.protocolMode = modeMap[pp.protocolMode.value] || "unrestricted";
340
+ if (isSome(pp.hasProtocolCaps))
341
+ changes.hasProtocolCaps = pp.hasProtocolCaps.value;
342
+ if (isSome(pp.protocolCaps))
343
+ changes.protocolCaps = pp.protocolCaps.value;
344
+ if (isSome(pp.canOpenPositions))
345
+ changes.canOpenPositions = pp.canOpenPositions.value;
346
+ if (isSome(pp.maxConcurrentPositions))
347
+ changes.maxConcurrentPositions = pp.maxConcurrentPositions.value;
348
+ if (isSome(pp.maxSlippageBps))
349
+ changes.maxSlippageBps = pp.maxSlippageBps.value;
350
+ if (isSome(pp.maxLeverageBps))
351
+ changes.leverageLimit = pp.maxLeverageBps.value;
352
+ if (isSome(pp.allowedDestinations))
353
+ changes.allowedDestinations = pp.allowedDestinations.value;
354
+ if (isSome(pp.developerFeeRate))
355
+ changes.developerFeeRate = pp.developerFeeRate.value;
356
+ if (isSome(pp.sessionExpirySlots))
357
+ changes.sessionExpirySlots = pp.sessionExpirySlots.value;
358
+ if (isSome(pp.timelockDuration))
359
+ changes.timelock = Number(pp.timelockDuration.value);
360
+ pendingUpdate = {
361
+ changes,
362
+ appliesAt,
363
+ canApply: executesAtSec > 0 && executesAtSec <= nowSec,
364
+ canCancel: true,
365
+ };
366
+ }
367
+ return {
368
+ dailyCap,
369
+ maxPerTrade,
370
+ approvedApps,
371
+ protocolMode: modeMap[p.protocolMode] || "unrestricted",
372
+ hasProtocolCaps: p.hasProtocolCaps,
373
+ protocolCaps,
374
+ canOpenPositions: p.canOpenPositions,
375
+ maxConcurrentPositions: p.maxConcurrentPositions,
376
+ maxSlippageBps: p.maxSlippageBps,
377
+ leverageLimitBps: p.maxLeverageBps,
378
+ allowedDestinations: (p.allowedDestinations || []),
379
+ developerFeeRate: p.developerFeeRate,
380
+ sessionExpirySlots: sessionExpiry,
381
+ timelockSeconds: timelockSec,
382
+ policyVersion: policyVer,
383
+ pendingUpdate,
384
+ toJSON: () => ({
385
+ dailyCap: bs(dailyCap),
386
+ maxPerTrade: bs(maxPerTrade),
387
+ approvedApps,
388
+ protocolMode: modeMap[p.protocolMode] || "unrestricted",
389
+ hasProtocolCaps: p.hasProtocolCaps,
390
+ protocolCaps: protocolCaps.map(bs),
391
+ canOpenPositions: p.canOpenPositions,
392
+ maxConcurrentPositions: p.maxConcurrentPositions,
393
+ maxSlippageBps: p.maxSlippageBps,
394
+ leverageLimitBps: p.maxLeverageBps,
395
+ allowedDestinations: (p.allowedDestinations || []),
396
+ developerFeeRate: p.developerFeeRate,
397
+ sessionExpirySlots: bs(sessionExpiry),
398
+ timelockSeconds: timelockSec,
399
+ policyVersion: bs(policyVer),
400
+ pendingUpdate: pendingUpdate
401
+ ? {
402
+ changes: serializeBigints(pendingUpdate.changes),
403
+ appliesAt: pendingUpdate.appliesAt,
404
+ canApply: pendingUpdate.canApply,
405
+ canCancel: pendingUpdate.canCancel,
406
+ }
407
+ : undefined,
408
+ }),
409
+ };
410
+ }
411
+ /**
412
+ * Map raw {@link VaultActivityItem}[] to {@link ActivityRow}[] with stable
413
+ * derived IDs and toJSON serializers. Pure — no filtering applied.
414
+ *
415
+ * Both `getActivity` (which then filters) and `getOverview` (which returns
416
+ * unfiltered) consume the output.
417
+ */
418
+ export function buildActivityRows(items) {
419
+ return items.map((item) => {
420
+ const cat = item.category ?? "unknown";
421
+ const evt = item.eventType ?? "";
422
+ const act = item.actionType ?? undefined;
423
+ const posEffect = item.positionEffect ?? undefined;
424
+ const type = mapCategory(cat, evt, act, posEffect);
425
+ const amt = item.amount ?? 0n;
426
+ const sig = item.txSignature || `evt-${item.timestamp}-${item.eventType}`;
427
+ return {
428
+ id: sig,
429
+ timestamp: item.timestamp * 1000,
430
+ type,
431
+ protocol: item.protocolName || "",
432
+ protocolId: item.protocol || "",
433
+ agent: item.agent || "",
434
+ amount: amt,
435
+ status: item.success ? "approved" : "blocked",
436
+ reason: item.success ? undefined : item.description,
437
+ txSignature: item.txSignature,
438
+ toJSON: () => ({
439
+ id: sig,
440
+ timestamp: item.timestamp * 1000,
441
+ type,
442
+ protocol: item.protocolName || "",
443
+ protocolId: item.protocol || "",
444
+ agent: item.agent || "",
445
+ amount: bs(amt),
446
+ status: item.success ? "approved" : "blocked",
447
+ reason: item.success ? undefined : item.description,
448
+ txSignature: item.txSignature,
449
+ }),
450
+ };
451
+ });
452
+ }
453
+ // ─── getVaultState ───────────────────────────────────────────────────────────
454
+ export async function getVaultState(rpc, vault, network) {
455
+ try {
456
+ const [state, pnl] = await Promise.all([
457
+ resolveVaultStateForOwner(rpc, vault, undefined, toNet(network)),
458
+ getVaultPnL(rpc, vault, toNet(network)),
459
+ ]);
460
+ return buildVaultState({ vault, state, pnl });
114
461
  }
115
462
  catch (err) {
116
463
  throw toDxError(err, "OwnerClient.getVaultState");
@@ -119,70 +466,27 @@ export async function getVaultState(rpc, vault, network) {
119
466
  // ─── getAgents ───────────────────────────────────────────────────────────────
120
467
  export async function getAgents(rpc, vault, network) {
121
468
  try {
122
- // Fetch vault state and activity feed in parallel. Single getVaultActivity
123
- // call is shared across all agents (N+1 prevention). Activity is enrichment,
124
- // so a fetch failure degrades gracefully to empty last-action fields.
125
- // Window: 100 most recent signatures large enough to surface last action
126
- // for low-volume agents without inflating RPC cost.
469
+ // Single getVaultActivity call is shared across all agents (N+1 prevention).
470
+ // Activity is enrichment, so a fetch failure degrades gracefully to empty
471
+ // last-action fields. Window: 100 most recent signatures large enough to
472
+ // surface last action for low-volume agents without inflating RPC cost.
473
+ //
474
+ // Fix for docs/SECURITY-FINDINGS-2026-04-07.md Finding 5: the previous
475
+ // `.catch(() => [])` swallowed activity-fetch failures silently. If Helius
476
+ // started rate-limiting getSignaturesForAddress, every dashboard call would
477
+ // show "last action: never" for every agent forever and nobody would
478
+ // notice. Graceful degradation is still the right behavior (activity is
479
+ // enrichment, not core), but it must be observable — console.warn is the
480
+ // minimum bar.
127
481
  const [state, activity] = await Promise.all([
128
482
  resolveVaultStateForOwner(rpc, vault, undefined, toNet(network)),
129
- // Fix for docs/SECURITY-FINDINGS-2026-04-07.md Finding 5: the
130
- // previous `.catch(() => [])` swallowed activity-fetch failures
131
- // silently. If Helius started rate-limiting getSignaturesForAddress,
132
- // every dashboard call would show "last action: never" for every
133
- // agent forever and nobody would notice. Graceful degradation is
134
- // still the right behavior (activity is enrichment, not core), but
135
- // it must be observable — console.warn is the minimum bar.
136
483
  getVaultActivity(rpc, vault, 100, toNet(network)).catch((err) => {
137
484
  // eslint-disable-next-line no-console
138
485
  console.warn("[OwnerClient.getAgents] activity enrichment failed — falling back to empty last-action fields:", err instanceof Error ? err.message : String(err));
139
486
  return [];
140
487
  }),
141
488
  ]);
142
- const vaultAgents = state.vault.agents;
143
- if (!vaultAgents || vaultAgents.length === 0)
144
- return [];
145
- const blockedCutoffMs = Date.now() - 24 * 3600 * 1000;
146
- return vaultAgents.map((entry) => {
147
- const addr = entry.pubkey;
148
- const profile = getAgentProfile(asVaultState(state), addr);
149
- const budget = state.allAgentBudgets.get(addr);
150
- const spentAmt = budget?.spent24h ?? 0n;
151
- const capAmt = budget?.cap ?? 0n;
152
- const pct = capAmt > 0n ? Number((spentAmt * 10000n) / capAmt) / 100 : 0;
153
- // Derive last-action and blocked-count fields from the shared activity
154
- // feed. Items are newest-first (getSignaturesForAddress ordering).
155
- const agentActivity = activity.filter((item) => item.agent !== null && item.agent === addr);
156
- const last = agentActivity[0];
157
- const lastActionType = last
158
- ? mapCategory(last.category ?? "unknown", last.eventType ?? "", last.actionType ?? undefined)
159
- : "";
160
- const lastActionProtocol = last?.protocolName ?? "";
161
- const lastActionTimestamp = last ? last.timestamp * 1000 : 0;
162
- const blockedCount24h = agentActivity.filter((item) => !item.success && item.timestamp * 1000 >= blockedCutoffMs).length;
163
- return {
164
- address: addr,
165
- status: (profile?.paused ? "paused" : "active"),
166
- capabilityLabel: profile?.capabilityLabel ?? "Disabled",
167
- capability: profile?.capability ?? 0,
168
- spending: { amount: spentAmt, limit: capAmt, percent: pct },
169
- lastActionType,
170
- lastActionProtocol,
171
- lastActionTimestamp,
172
- blockedCount24h,
173
- toJSON: () => ({
174
- address: addr,
175
- status: profile?.paused ? "paused" : "active",
176
- capabilityLabel: profile?.capabilityLabel ?? "Disabled",
177
- capability: profile?.capability ?? 0,
178
- spending: { amount: bs(spentAmt), limit: bs(capAmt), percent: pct },
179
- lastActionType,
180
- lastActionProtocol,
181
- lastActionTimestamp,
182
- blockedCount24h,
183
- }),
184
- };
185
- });
489
+ return buildAgents({ vault, state, activity });
186
490
  }
187
491
  catch (err) {
188
492
  throw toDxError(err, "OwnerClient.getAgents");
@@ -192,44 +496,7 @@ export async function getAgents(rpc, vault, network) {
192
496
  export async function getSpending(rpc, vault, network) {
193
497
  try {
194
498
  const state = await resolveVaultStateForOwner(rpc, vault, undefined, toNet(network));
195
- const breakdown = getSpendingBreakdown(asVaultState(state));
196
- const nowUnix = BigInt(Math.floor(Date.now() / 1000));
197
- const epochs = getSpendingHistory(state.tracker, nowUnix);
198
- const chart = epochs.map((e) => ({
199
- time: new Date(e.timestamp * 1000).toISOString(),
200
- amount: Number(e.usdAmount) / 1_000_000,
201
- }));
202
- const { spent24h: spent, cap, remaining } = state.globalBudget;
203
- const percent = cap > 0n ? Number((spent * 10000n) / cap) / 100 : 0;
204
- const velocityPerMs = spent > 0n ? Number(spent) / (24 * 3600 * 1000) : 0;
205
- const rundown = velocityPerMs > 0 && remaining > 0n
206
- ? Math.floor(Number(remaining) / velocityPerMs)
207
- : 0;
208
- const protoBreak = breakdown.byProtocol.map((p) => ({
209
- name: resolveProtocolName(p.protocol),
210
- programId: p.protocol,
211
- amount: p.spent24h,
212
- percent: p.utilization,
213
- }));
214
- return {
215
- global: { today: spent, cap, remaining, percent, rundownMs: rundown },
216
- chart,
217
- protocolBreakdown: protoBreak,
218
- toJSON: () => ({
219
- global: {
220
- today: bs(spent),
221
- cap: bs(cap),
222
- remaining: bs(remaining),
223
- percent,
224
- rundownMs: rundown,
225
- },
226
- chart,
227
- protocolBreakdown: protoBreak.map((p) => ({
228
- ...p,
229
- amount: bs(p.amount),
230
- })),
231
- }),
232
- };
499
+ return buildSpending({ vault, state });
233
500
  }
234
501
  catch (err) {
235
502
  throw toDxError(err, "OwnerClient.getSpending");
@@ -240,39 +507,7 @@ export async function getActivity(rpc, vault, network, filters) {
240
507
  try {
241
508
  const limit = filters?.limit ?? 50;
242
509
  const items = await getVaultActivity(rpc, vault, limit, toNet(network));
243
- let rows = items.map((item, i) => {
244
- const cat = item.category ?? "unknown";
245
- const evt = item.eventType ?? "";
246
- const act = item.actionType ?? undefined;
247
- const posEffect = item.positionEffect ?? undefined;
248
- const type = mapCategory(cat, evt, act, posEffect);
249
- const amt = item.amount ?? 0n;
250
- const sig = item.txSignature || `evt-${item.timestamp}-${item.eventType}`;
251
- return {
252
- id: sig,
253
- timestamp: item.timestamp * 1000,
254
- type,
255
- protocol: item.protocolName || "",
256
- protocolId: item.protocol || "",
257
- agent: item.agent || "",
258
- amount: amt,
259
- status: item.success ? "approved" : "blocked",
260
- reason: item.success ? undefined : item.description,
261
- txSignature: item.txSignature,
262
- toJSON: () => ({
263
- id: sig,
264
- timestamp: item.timestamp * 1000,
265
- type,
266
- protocol: item.protocolName || "",
267
- protocolId: item.protocol || "",
268
- agent: item.agent || "",
269
- amount: bs(amt),
270
- status: item.success ? "approved" : "blocked",
271
- reason: item.success ? undefined : item.description,
272
- txSignature: item.txSignature,
273
- }),
274
- };
275
- });
510
+ let rows = buildActivityRows(items);
276
511
  if (filters?.agent)
277
512
  rows = rows.filter((r) => r.agent === filters.agent);
278
513
  if (filters?.protocol)
@@ -352,40 +587,7 @@ function rangeToMs(r) {
352
587
  export async function getHealth(rpc, vault, network) {
353
588
  try {
354
589
  const state = await resolveVaultStateForOwner(rpc, vault, undefined, toNet(network));
355
- const posture = getSecurityPosture(asVaultState(state));
356
- const alerts = evaluateAlertConditions(state, vault);
357
- const level = posture.criticalFailures.length > 0
358
- ? "critical"
359
- : posture.failCount > 0
360
- ? "elevated"
361
- : "healthy";
362
- const critAlerts = alerts.filter((a) => a.severity === "critical");
363
- const lastBlock = critAlerts.length > 0
364
- ? {
365
- agent: critAlerts[0].agentAddress || "",
366
- reason: critAlerts[0].title,
367
- amount: 0n,
368
- timestamp: Date.now(),
369
- }
370
- : undefined;
371
- const checks = posture.checks.map((c) => ({
372
- name: c.id,
373
- passed: c.passed,
374
- }));
375
- return {
376
- level,
377
- blockedCount24h: critAlerts.length,
378
- checks,
379
- lastBlock,
380
- toJSON: () => ({
381
- level,
382
- blockedCount24h: critAlerts.length,
383
- checks,
384
- lastBlock: lastBlock
385
- ? { ...lastBlock, amount: bs(lastBlock.amount) }
386
- : undefined,
387
- }),
388
- };
590
+ return buildHealth({ vault, state });
389
591
  }
390
592
  catch (err) {
391
593
  throw toDxError(err, "OwnerClient.getHealth");
@@ -399,124 +601,114 @@ export async function getPolicy(rpc, vault, network) {
399
601
  getPendingPolicyForVault(rpc, vault).catch((err) => {
400
602
  // Account-not-found is expected (no pending update) — return null.
401
603
  // Re-throw RPC errors so they're not silently swallowed.
402
- const message = err instanceof Error ? err.message : String(err);
403
- if (message.includes("could not find") ||
404
- message.includes("Account does not exist")) {
604
+ if (isAccountNotFoundError(err))
605
+ return null;
606
+ throw err;
607
+ }),
608
+ ]);
609
+ return buildPolicy({ vault, state, pendingPolicy });
610
+ }
611
+ catch (err) {
612
+ throw toDxError(err, "OwnerClient.getPolicy");
613
+ }
614
+ }
615
+ // ─── getOverview (S14) ───────────────────────────────────────────────────────
616
+ /**
617
+ * Single-call overview bundle — resolves vault state once, composes all five
618
+ * view types (vault, agents, spending, health, policy) plus a raw activity
619
+ * list, with PnL derived from the resolved state (no duplicate resolve).
620
+ *
621
+ * **Actual RPC shape.** Calling the five individual reads duplicates the
622
+ * vault-state resolution up to five times. `getOverview` resolves state
623
+ * exactly once and computes PnL from it via {@link getVaultPnLFromState}.
624
+ * The activity fetch is independent: `getVaultActivity(limit)` issues one
625
+ * `getSignaturesForAddress` followed by up to `limit` sequential
626
+ * `getTransaction` calls, so the wall-time cost of the activity feed
627
+ * dominates regardless of this method. Net savings vs. five separate reads:
628
+ * state resolution count drops from ~5 → 1.
629
+ *
630
+ * Activity is **unfiltered**. For filtered activity, call {@link getActivity}
631
+ * with `ActivityFilters`.
632
+ *
633
+ * Graceful degradation: activity fetch failure degrades to empty activity
634
+ * (same observable pattern as `getAgents`, documented in
635
+ * `docs/SECURITY-FINDINGS-2026-04-07.md` Finding 5); pending-policy
636
+ * account-not-found is treated as "no pending update" (same as `getPolicy`).
637
+ * **PnL and state-resolution errors are NOT degraded** and propagate via
638
+ * `toDxError`. A pending-policy error that is NOT account-not-found (e.g.
639
+ * network failure) also propagates — it is NOT treated as "no pending
640
+ * update", even on the `includeActivity: false` lightweight path.
641
+ */
642
+ export async function getOverview(rpc, vault, network, options) {
643
+ try {
644
+ const includeActivity = options?.includeActivity ?? true;
645
+ const activityLimit = options?.activityLimit ?? DEFAULT_OVERVIEW_ACTIVITY_LIMIT;
646
+ const net = toNet(network);
647
+ // Fan out every independent fetch in one Promise.all. State resolution,
648
+ // activity, and pending-policy have no cross-dependency, so wall time
649
+ // collapses to the slowest of the three. PnL is derived from state
650
+ // synchronously after — one state resolve, zero duplication.
651
+ const [state, activity, pendingPolicy] = await Promise.all([
652
+ resolveVaultStateForOwner(rpc, vault, undefined, net),
653
+ includeActivity
654
+ ? getVaultActivity(rpc, vault, activityLimit, net).catch((err) => {
655
+ // Same graceful-degradation pattern as getAgents
656
+ // (docs/SECURITY-FINDINGS-2026-04-07.md Finding 5): activity is
657
+ // enrichment, not core, but the failure must be observable.
658
+ // eslint-disable-next-line no-console
659
+ console.warn("[OwnerClient.getOverview] activity fetch failed — falling back to empty:", err instanceof Error ? err.message : String(err));
660
+ return [];
661
+ })
662
+ : Promise.resolve(undefined),
663
+ getPendingPolicyForVault(rpc, vault).catch((err) => {
664
+ if (isAccountNotFoundError(err))
405
665
  return null;
406
- }
407
666
  throw err;
408
667
  }),
409
668
  ]);
410
- const p = state.policy;
411
- const protocols = (p.protocols || []);
412
- const approvedApps = protocols.map((addr) => ({
413
- name: resolveProtocolName(addr),
414
- programId: addr,
415
- }));
416
- const modeMap = {
417
- 0: "unrestricted",
418
- 1: "whitelist",
419
- 2: "blacklist",
669
+ // PnL is pure from resolved state — no extra RPC.
670
+ const pnl = getVaultPnLFromState(state);
671
+ // Compute the three state-derived values exactly once and memoize on ctx.
672
+ // Every build* helper reads these via the `ctx.field ?? derive()` fallback
673
+ // so the memoized value short-circuits re-derivation.
674
+ const posture = getSecurityPosture(asVaultState(state));
675
+ const breakdown = getSpendingBreakdown(asVaultState(state));
676
+ const alerts = evaluateAlertConditions(state, vault);
677
+ const ctx = {
678
+ vault,
679
+ state,
680
+ pnl,
681
+ activity,
682
+ pendingPolicy,
683
+ posture,
684
+ breakdown,
685
+ alerts,
420
686
  };
421
- const dailyCap = p.dailySpendingCapUsd;
422
- const maxPerTrade = p.maxTransactionSizeUsd ?? 0n;
423
- const protocolCaps = (p.protocolCaps || []);
424
- const sessionExpiry = p.sessionExpirySlots;
425
- const policyVer = (p.policyVersion ?? 0n);
426
- const timelockSec = Number(p.timelockDuration);
427
- let pendingUpdate;
428
- if (pendingPolicy) {
429
- const pp = pendingPolicy;
430
- const executesAtSec = Number(pp.executesAt ?? 0);
431
- const appliesAt = Number.isFinite(executesAtSec)
432
- ? executesAtSec * 1000
433
- : 0;
434
- const nowSec = Math.floor(Date.now() / 1000);
435
- // Decode each Option<T> field from PendingPolicyUpdate. Only Some fields
436
- // land in `changes`. 14 fields total — every timelockable PolicyConfig field.
437
- // Source: programs/sigil/src/state/pending_policy.rs
438
- const changes = {};
439
- if (isSome(pp.dailySpendingCapUsd))
440
- changes.dailyCap = pp.dailySpendingCapUsd.value;
441
- if (isSome(pp.maxTransactionAmountUsd))
442
- changes.maxPerTrade = pp.maxTransactionAmountUsd.value;
443
- if (isSome(pp.protocols))
444
- changes.approvedApps = pp.protocols.value;
445
- if (isSome(pp.protocolMode))
446
- changes.protocolMode = modeMap[pp.protocolMode.value] || "unrestricted";
447
- if (isSome(pp.hasProtocolCaps))
448
- changes.hasProtocolCaps = pp.hasProtocolCaps.value;
449
- if (isSome(pp.protocolCaps))
450
- changes.protocolCaps = pp.protocolCaps.value;
451
- if (isSome(pp.canOpenPositions))
452
- changes.canOpenPositions = pp.canOpenPositions.value;
453
- if (isSome(pp.maxConcurrentPositions))
454
- changes.maxConcurrentPositions = pp.maxConcurrentPositions.value;
455
- if (isSome(pp.maxSlippageBps))
456
- changes.maxSlippageBps = pp.maxSlippageBps.value;
457
- if (isSome(pp.maxLeverageBps))
458
- changes.leverageLimit = pp.maxLeverageBps.value;
459
- if (isSome(pp.allowedDestinations))
460
- changes.allowedDestinations = pp.allowedDestinations.value;
461
- if (isSome(pp.developerFeeRate))
462
- changes.developerFeeRate = pp.developerFeeRate.value;
463
- if (isSome(pp.sessionExpirySlots))
464
- changes.sessionExpirySlots = pp.sessionExpirySlots.value;
465
- if (isSome(pp.timelockDuration))
466
- changes.timelock = Number(pp.timelockDuration.value);
467
- pendingUpdate = {
468
- changes,
469
- appliesAt,
470
- canApply: executesAtSec > 0 && executesAtSec <= nowSec,
471
- canCancel: true,
472
- };
473
- }
687
+ const vaultView = buildVaultState(ctx);
688
+ const agentsView = buildAgents(ctx);
689
+ const spendingView = buildSpending(ctx);
690
+ const healthView = buildHealth(ctx);
691
+ const policyView = buildPolicy(ctx);
692
+ const activityRows = buildActivityRows(activity ?? []);
474
693
  return {
475
- dailyCap,
476
- maxPerTrade,
477
- approvedApps,
478
- protocolMode: modeMap[p.protocolMode] || "unrestricted",
479
- hasProtocolCaps: p.hasProtocolCaps,
480
- protocolCaps,
481
- canOpenPositions: p.canOpenPositions,
482
- maxConcurrentPositions: p.maxConcurrentPositions,
483
- maxSlippageBps: p.maxSlippageBps,
484
- leverageLimitBps: p.maxLeverageBps,
485
- allowedDestinations: (p.allowedDestinations || []),
486
- developerFeeRate: p.developerFeeRate,
487
- sessionExpirySlots: sessionExpiry,
488
- timelockSeconds: timelockSec,
489
- policyVersion: policyVer,
490
- pendingUpdate,
694
+ vault: vaultView,
695
+ agents: agentsView,
696
+ spending: spendingView,
697
+ health: healthView,
698
+ policy: policyView,
699
+ activity: activityRows,
491
700
  toJSON: () => ({
492
- dailyCap: bs(dailyCap),
493
- maxPerTrade: bs(maxPerTrade),
494
- approvedApps,
495
- protocolMode: modeMap[p.protocolMode] || "unrestricted",
496
- hasProtocolCaps: p.hasProtocolCaps,
497
- protocolCaps: protocolCaps.map(bs),
498
- canOpenPositions: p.canOpenPositions,
499
- maxConcurrentPositions: p.maxConcurrentPositions,
500
- maxSlippageBps: p.maxSlippageBps,
501
- leverageLimitBps: p.maxLeverageBps,
502
- allowedDestinations: (p.allowedDestinations || []),
503
- developerFeeRate: p.developerFeeRate,
504
- sessionExpirySlots: bs(sessionExpiry),
505
- timelockSeconds: timelockSec,
506
- policyVersion: bs(policyVer),
507
- pendingUpdate: pendingUpdate
508
- ? {
509
- changes: serializeBigints(pendingUpdate.changes),
510
- appliesAt: pendingUpdate.appliesAt,
511
- canApply: pendingUpdate.canApply,
512
- canCancel: pendingUpdate.canCancel,
513
- }
514
- : undefined,
701
+ vault: vaultView.toJSON(),
702
+ agents: agentsView.map((a) => a.toJSON()),
703
+ spending: spendingView.toJSON(),
704
+ health: healthView.toJSON(),
705
+ policy: policyView.toJSON(),
706
+ activity: activityRows.map((r) => r.toJSON()),
515
707
  }),
516
708
  };
517
709
  }
518
710
  catch (err) {
519
- throw toDxError(err, "OwnerClient.getPolicy");
711
+ throw toDxError(err, "OwnerClient.getOverview");
520
712
  }
521
713
  }
522
714
  //# sourceMappingURL=reads.js.map