@usesigil/kit 0.2.3 → 0.4.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.
Files changed (46) hide show
  1. package/dist/agent-errors.d.ts.map +1 -1
  2. package/dist/agent-errors.js +2 -2
  3. package/dist/agent-errors.js.map +1 -1
  4. package/dist/balance-tracker.d.ts +20 -0
  5. package/dist/balance-tracker.d.ts.map +1 -1
  6. package/dist/balance-tracker.js +18 -5
  7. package/dist/balance-tracker.js.map +1 -1
  8. package/dist/create-vault.d.ts.map +1 -1
  9. package/dist/create-vault.js +22 -0
  10. package/dist/create-vault.js.map +1 -1
  11. package/dist/dashboard/index.d.ts +18 -2
  12. package/dist/dashboard/index.d.ts.map +1 -1
  13. package/dist/dashboard/index.js +26 -0
  14. package/dist/dashboard/index.js.map +1 -1
  15. package/dist/dashboard/mutations.d.ts.map +1 -1
  16. package/dist/dashboard/mutations.js +3 -3
  17. package/dist/dashboard/mutations.js.map +1 -1
  18. package/dist/dashboard/reads.d.ts +124 -1
  19. package/dist/dashboard/reads.d.ts.map +1 -1
  20. package/dist/dashboard/reads.js +564 -328
  21. package/dist/dashboard/reads.js.map +1 -1
  22. package/dist/dashboard/types.d.ts +120 -0
  23. package/dist/dashboard/types.d.ts.map +1 -1
  24. package/dist/index.d.ts +3 -3
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +16 -8
  27. package/dist/index.js.map +1 -1
  28. package/dist/owner-transaction.d.ts.map +1 -1
  29. package/dist/owner-transaction.js +6 -4
  30. package/dist/owner-transaction.js.map +1 -1
  31. package/dist/presets.d.ts +19 -11
  32. package/dist/presets.d.ts.map +1 -1
  33. package/dist/presets.js +10 -13
  34. package/dist/presets.js.map +1 -1
  35. package/dist/rpc-helpers.d.ts +23 -0
  36. package/dist/rpc-helpers.d.ts.map +1 -1
  37. package/dist/rpc-helpers.js +45 -0
  38. package/dist/rpc-helpers.js.map +1 -1
  39. package/dist/seal.d.ts +8 -0
  40. package/dist/seal.d.ts.map +1 -1
  41. package/dist/seal.js +15 -5
  42. package/dist/seal.js.map +1 -1
  43. package/dist/state-resolver.d.ts.map +1 -1
  44. package/dist/state-resolver.js +35 -24
  45. package/dist/state-resolver.js.map +1 -1
  46. package/package.json +2 -2
@@ -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,460 @@ 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
+ * @experimental Part of the `build*` composition surface introduced alongside
101
+ * `getOverview` (S14). Signature and JSON shape may shift before v1.0; if you
102
+ * depend on it, pin your SDK version and watch the changeset.
103
+ *
104
+ * @see OwnerClient.getOverview — the stable single-call alternative that
105
+ * pre-populates a full {@link OverviewContext} for you.
106
+ */
107
+ export function buildVaultState(ctx) {
108
+ const v = requireCtxField(ctx.state.vault, "vault");
109
+ const posture = ctx.posture ?? getSecurityPosture(asVaultState(ctx.state));
110
+ const pnlPercent = ctx.pnl && Number.isFinite(ctx.pnl.pnlPercent) ? ctx.pnl.pnlPercent : 0;
111
+ const pnlAbsolute = ctx.pnl ? ctx.pnl.pnl : 0n;
112
+ const bal = ctx.state.stablecoinBalances;
113
+ const total = bal.usdc + bal.usdt;
114
+ const tokens = [
115
+ ...(bal.usdc > 0n ? [{ mint: "USDC", amount: bal.usdc, decimals: 6 }] : []),
116
+ ...(bal.usdt > 0n ? [{ mint: "USDT", amount: bal.usdt, decimals: 6 }] : []),
117
+ ];
118
+ const checks = posture.checks.map((c) => ({
119
+ name: c.id,
120
+ passed: c.passed,
121
+ }));
122
+ const level = posture.criticalFailures.length > 0
123
+ ? "critical"
124
+ : posture.failCount > 0
125
+ ? "elevated"
126
+ : "healthy";
127
+ const vaultAddr = ctx.vault;
128
+ const status = (v.status === 0 ? "active" : v.status === 1 ? "frozen" : "closed");
129
+ return {
130
+ vault: {
131
+ address: vaultAddr,
132
+ status,
133
+ owner: v.owner,
134
+ agentCount: v.agents?.length ?? 0,
135
+ openPositions: v.openPositions,
136
+ totalVolume: v.totalVolume,
137
+ totalFees: v.totalFeesCollected,
138
+ },
139
+ balance: { total, tokens },
140
+ pnl: { percent: pnlPercent, absolute: pnlAbsolute },
141
+ health: { level, alertCount: posture.failCount, checks },
142
+ toJSON: () => ({
74
143
  vault: {
75
- address: vault,
76
- status: v.status === 0 ? "active" : v.status === 1 ? "frozen" : "closed",
144
+ address: vaultAddr,
145
+ status,
77
146
  owner: v.owner,
78
147
  agentCount: v.agents?.length ?? 0,
79
148
  openPositions: v.openPositions,
80
- totalVolume: v.totalVolume,
81
- totalFees: v.totalFeesCollected,
149
+ totalVolume: bs(v.totalVolume),
150
+ totalFees: bs(v.totalFeesCollected),
82
151
  },
83
- balance: { total, tokens },
84
- pnl: {
85
- percent: Number.isFinite(pnl.pnlPercent) ? pnl.pnlPercent : 0,
86
- absolute: pnl.pnl,
152
+ balance: {
153
+ total: bs(total),
154
+ tokens: tokens.map((t) => ({ ...t, amount: bs(t.amount) })),
87
155
  },
156
+ pnl: { percent: pnlPercent, absolute: bs(pnlAbsolute) },
88
157
  health: { level, alertCount: posture.failCount, checks },
158
+ }),
159
+ };
160
+ }
161
+ /**
162
+ * Compose {@link AgentData}[] from a pre-fetched {@link OverviewContext}.
163
+ *
164
+ * Requires `ctx.state`. Uses `ctx.activity` to populate per-agent last-action
165
+ * and blocked-count fields; when absent, those fields default to empty/zero.
166
+ *
167
+ * @experimental Part of the `build*` composition surface (S14). Signature and
168
+ * JSON shape may shift before v1.0.
169
+ *
170
+ * @see OwnerClient.getOverview — the stable single-call alternative that
171
+ * pre-populates a full {@link OverviewContext} for you.
172
+ */
173
+ export function buildAgents(ctx) {
174
+ const state = ctx.state;
175
+ const v = requireCtxField(state.vault, "vault");
176
+ const vaultAgents = v.agents;
177
+ if (!vaultAgents || vaultAgents.length === 0)
178
+ return [];
179
+ const activity = ctx.activity ?? [];
180
+ const blockedCutoffMs = Date.now() - 24 * 3600 * 1000;
181
+ return vaultAgents.map((entry) => {
182
+ const addr = entry.pubkey;
183
+ const profile = getAgentProfile(asVaultState(state), addr);
184
+ const budget = state.allAgentBudgets.get(addr);
185
+ const spentAmt = budget?.spent24h ?? 0n;
186
+ const capAmt = budget?.cap ?? 0n;
187
+ const pct = capAmt > 0n ? Number((spentAmt * 10000n) / capAmt) / 100 : 0;
188
+ // Items are newest-first (getSignaturesForAddress ordering).
189
+ const agentActivity = activity.filter((item) => item.agent !== null && item.agent === addr);
190
+ const last = agentActivity[0];
191
+ const lastActionType = last
192
+ ? mapCategory(last.category ?? "unknown", last.eventType ?? "", last.actionType ?? undefined)
193
+ : "";
194
+ const lastActionProtocol = last?.protocolName ?? "";
195
+ const lastActionTimestamp = last ? last.timestamp * 1000 : 0;
196
+ const blockedCount24h = agentActivity.filter((item) => !item.success && item.timestamp * 1000 >= blockedCutoffMs).length;
197
+ return {
198
+ address: addr,
199
+ status: (profile?.paused ? "paused" : "active"),
200
+ capabilityLabel: profile?.capabilityLabel ?? "Disabled",
201
+ capability: profile?.capability ?? 0,
202
+ spending: { amount: spentAmt, limit: capAmt, percent: pct },
203
+ lastActionType,
204
+ lastActionProtocol,
205
+ lastActionTimestamp,
206
+ blockedCount24h,
207
+ toJSON: () => ({
208
+ address: addr,
209
+ status: profile?.paused ? "paused" : "active",
210
+ capabilityLabel: profile?.capabilityLabel ?? "Disabled",
211
+ capability: profile?.capability ?? 0,
212
+ spending: { amount: bs(spentAmt), limit: bs(capAmt), percent: pct },
213
+ lastActionType,
214
+ lastActionProtocol,
215
+ lastActionTimestamp,
216
+ blockedCount24h,
217
+ }),
218
+ };
219
+ });
220
+ }
221
+ /**
222
+ * Compose {@link SpendingData} from a pre-fetched {@link OverviewContext}.
223
+ *
224
+ * Requires `ctx.state`. Uses `ctx.breakdown` when memoized.
225
+ *
226
+ * @experimental Part of the `build*` composition surface (S14). Signature and
227
+ * JSON shape may shift before v1.0.
228
+ *
229
+ * @see OwnerClient.getOverview — the stable single-call alternative that
230
+ * pre-populates a full {@link OverviewContext} for you.
231
+ */
232
+ export function buildSpending(ctx) {
233
+ const state = ctx.state;
234
+ const breakdown = ctx.breakdown ?? getSpendingBreakdown(asVaultState(state));
235
+ const nowUnix = BigInt(Math.floor(Date.now() / 1000));
236
+ const epochs = getSpendingHistory(state.tracker, nowUnix);
237
+ const chart = epochs.map((e) => ({
238
+ time: new Date(e.timestamp * 1000).toISOString(),
239
+ amount: Number(e.usdAmount) / 1_000_000,
240
+ }));
241
+ const { spent24h: spent, cap, remaining } = state.globalBudget;
242
+ const percent = cap > 0n ? Number((spent * 10000n) / cap) / 100 : 0;
243
+ const velocityPerMs = spent > 0n ? Number(spent) / (24 * 3600 * 1000) : 0;
244
+ const rundown = velocityPerMs > 0 && remaining > 0n
245
+ ? Math.floor(Number(remaining) / velocityPerMs)
246
+ : 0;
247
+ const protoBreak = breakdown.byProtocol.map((p) => ({
248
+ name: resolveProtocolName(p.protocol),
249
+ programId: p.protocol,
250
+ amount: p.spent24h,
251
+ percent: p.utilization,
252
+ }));
253
+ return {
254
+ global: { today: spent, cap, remaining, percent, rundownMs: rundown },
255
+ chart,
256
+ protocolBreakdown: protoBreak,
257
+ toJSON: () => ({
258
+ global: {
259
+ today: bs(spent),
260
+ cap: bs(cap),
261
+ remaining: bs(remaining),
262
+ percent,
263
+ rundownMs: rundown,
264
+ },
265
+ chart,
266
+ protocolBreakdown: protoBreak.map((p) => ({
267
+ ...p,
268
+ amount: bs(p.amount),
269
+ })),
270
+ }),
271
+ };
272
+ }
273
+ /**
274
+ * Compose {@link HealthData} from a pre-fetched {@link OverviewContext}.
275
+ *
276
+ * Requires `ctx.state.vault` when neither `ctx.posture` nor `ctx.alerts` is
277
+ * memoized (downstream `getSecurityPosture` / `evaluateAlertConditions`
278
+ * dereference it). Fully memoized callers can pass a minimal state shape.
279
+ *
280
+ * @experimental Part of the `build*` composition surface (S14). Signature and
281
+ * JSON shape may shift before v1.0.
282
+ *
283
+ * @see OwnerClient.getOverview — the stable single-call alternative that
284
+ * pre-populates a full {@link OverviewContext} for you.
285
+ */
286
+ export function buildHealth(ctx) {
287
+ // When both posture and alerts are memoized, the helpers below never run
288
+ // and state.vault is untouched — the memoized path is the whole point of
289
+ // OverviewContext. Guard only when at least one derivation will execute.
290
+ if (ctx.posture === undefined || ctx.alerts === undefined) {
291
+ requireCtxField(ctx.state.vault, "vault");
292
+ }
293
+ const posture = ctx.posture ?? getSecurityPosture(asVaultState(ctx.state));
294
+ const alerts = ctx.alerts ?? evaluateAlertConditions(ctx.state, ctx.vault);
295
+ const level = posture.criticalFailures.length > 0
296
+ ? "critical"
297
+ : posture.failCount > 0
298
+ ? "elevated"
299
+ : "healthy";
300
+ const critAlerts = alerts.filter((a) => a.severity === "critical");
301
+ const lastBlock = critAlerts.length > 0
302
+ ? {
303
+ agent: critAlerts[0].agentAddress || "",
304
+ reason: critAlerts[0].title,
305
+ amount: 0n,
306
+ timestamp: Date.now(),
307
+ }
308
+ : undefined;
309
+ const checks = posture.checks.map((c) => ({
310
+ name: c.id,
311
+ passed: c.passed,
312
+ }));
313
+ return {
314
+ level,
315
+ blockedCount24h: critAlerts.length,
316
+ checks,
317
+ lastBlock,
318
+ toJSON: () => ({
319
+ level,
320
+ blockedCount24h: critAlerts.length,
321
+ checks,
322
+ lastBlock: lastBlock
323
+ ? { ...lastBlock, amount: bs(lastBlock.amount) }
324
+ : undefined,
325
+ }),
326
+ };
327
+ }
328
+ /**
329
+ * Compose {@link PolicyData} from a pre-fetched {@link OverviewContext}.
330
+ *
331
+ * Requires `ctx.state`. Uses `ctx.pendingPolicy` (which may be `null` to mean
332
+ * "confirmed no pending update"); when `undefined` treats as no pending update.
333
+ *
334
+ * @experimental Part of the `build*` composition surface (S14). Signature and
335
+ * JSON shape may shift before v1.0.
336
+ *
337
+ * @see OwnerClient.getOverview — the stable single-call alternative that
338
+ * pre-populates a full {@link OverviewContext} for you.
339
+ */
340
+ export function buildPolicy(ctx) {
341
+ const state = ctx.state;
342
+ const pendingPolicy = ctx.pendingPolicy ?? null;
343
+ const p = requireCtxField(state.policy, "policy");
344
+ const protocols = (p.protocols || []);
345
+ const approvedApps = protocols.map((addr) => ({
346
+ name: resolveProtocolName(addr),
347
+ programId: addr,
348
+ }));
349
+ const modeMap = {
350
+ 0: "unrestricted",
351
+ 1: "whitelist",
352
+ 2: "blacklist",
353
+ };
354
+ const dailyCap = p.dailySpendingCapUsd;
355
+ const maxPerTrade = p.maxTransactionSizeUsd ?? 0n;
356
+ const protocolCaps = (p.protocolCaps || []);
357
+ const sessionExpiry = p.sessionExpirySlots;
358
+ const policyVer = (p.policyVersion ?? 0n);
359
+ const timelockSec = Number(p.timelockDuration);
360
+ let pendingUpdate;
361
+ if (pendingPolicy) {
362
+ const pp = pendingPolicy;
363
+ const executesAtSec = Number(pp.executesAt ?? 0);
364
+ const appliesAt = Number.isFinite(executesAtSec) ? executesAtSec * 1000 : 0;
365
+ const nowSec = Math.floor(Date.now() / 1000);
366
+ // Decode each Option<T> field from PendingPolicyUpdate. Only Some fields
367
+ // land in `changes`. 14 fields total — every timelockable PolicyConfig field.
368
+ // Source: programs/sigil/src/state/pending_policy.rs
369
+ const changes = {};
370
+ if (isSome(pp.dailySpendingCapUsd))
371
+ changes.dailyCap = pp.dailySpendingCapUsd.value;
372
+ if (isSome(pp.maxTransactionAmountUsd))
373
+ changes.maxPerTrade = pp.maxTransactionAmountUsd.value;
374
+ if (isSome(pp.protocols))
375
+ changes.approvedApps = pp.protocols.value;
376
+ if (isSome(pp.protocolMode))
377
+ changes.protocolMode = modeMap[pp.protocolMode.value] || "unrestricted";
378
+ if (isSome(pp.hasProtocolCaps))
379
+ changes.hasProtocolCaps = pp.hasProtocolCaps.value;
380
+ if (isSome(pp.protocolCaps))
381
+ changes.protocolCaps = pp.protocolCaps.value;
382
+ if (isSome(pp.canOpenPositions))
383
+ changes.canOpenPositions = pp.canOpenPositions.value;
384
+ if (isSome(pp.maxConcurrentPositions))
385
+ changes.maxConcurrentPositions = pp.maxConcurrentPositions.value;
386
+ if (isSome(pp.maxSlippageBps))
387
+ changes.maxSlippageBps = pp.maxSlippageBps.value;
388
+ if (isSome(pp.maxLeverageBps))
389
+ changes.leverageLimit = pp.maxLeverageBps.value;
390
+ if (isSome(pp.allowedDestinations))
391
+ changes.allowedDestinations = pp.allowedDestinations.value;
392
+ if (isSome(pp.developerFeeRate))
393
+ changes.developerFeeRate = pp.developerFeeRate.value;
394
+ if (isSome(pp.sessionExpirySlots))
395
+ changes.sessionExpirySlots = pp.sessionExpirySlots.value;
396
+ if (isSome(pp.timelockDuration))
397
+ changes.timelock = Number(pp.timelockDuration.value);
398
+ pendingUpdate = {
399
+ changes,
400
+ appliesAt,
401
+ canApply: executesAtSec > 0 && executesAtSec <= nowSec,
402
+ canCancel: true,
403
+ };
404
+ }
405
+ return {
406
+ dailyCap,
407
+ maxPerTrade,
408
+ approvedApps,
409
+ protocolMode: modeMap[p.protocolMode] || "unrestricted",
410
+ hasProtocolCaps: p.hasProtocolCaps,
411
+ protocolCaps,
412
+ canOpenPositions: p.canOpenPositions,
413
+ maxConcurrentPositions: p.maxConcurrentPositions,
414
+ maxSlippageBps: p.maxSlippageBps,
415
+ leverageLimitBps: p.maxLeverageBps,
416
+ allowedDestinations: (p.allowedDestinations || []),
417
+ developerFeeRate: p.developerFeeRate,
418
+ sessionExpirySlots: sessionExpiry,
419
+ timelockSeconds: timelockSec,
420
+ policyVersion: policyVer,
421
+ pendingUpdate,
422
+ toJSON: () => ({
423
+ dailyCap: bs(dailyCap),
424
+ maxPerTrade: bs(maxPerTrade),
425
+ approvedApps,
426
+ protocolMode: modeMap[p.protocolMode] || "unrestricted",
427
+ hasProtocolCaps: p.hasProtocolCaps,
428
+ protocolCaps: protocolCaps.map(bs),
429
+ canOpenPositions: p.canOpenPositions,
430
+ maxConcurrentPositions: p.maxConcurrentPositions,
431
+ maxSlippageBps: p.maxSlippageBps,
432
+ leverageLimitBps: p.maxLeverageBps,
433
+ allowedDestinations: (p.allowedDestinations || []),
434
+ developerFeeRate: p.developerFeeRate,
435
+ sessionExpirySlots: bs(sessionExpiry),
436
+ timelockSeconds: timelockSec,
437
+ policyVersion: bs(policyVer),
438
+ pendingUpdate: pendingUpdate
439
+ ? {
440
+ changes: serializeBigints(pendingUpdate.changes),
441
+ appliesAt: pendingUpdate.appliesAt,
442
+ canApply: pendingUpdate.canApply,
443
+ canCancel: pendingUpdate.canCancel,
444
+ }
445
+ : undefined,
446
+ }),
447
+ };
448
+ }
449
+ /**
450
+ * Map raw {@link VaultActivityItem}[] to {@link ActivityRow}[] with stable
451
+ * derived IDs and toJSON serializers. Pure — no filtering applied.
452
+ *
453
+ * Both `getActivity` (which then filters) and `getOverview` (which returns
454
+ * unfiltered) consume the output.
455
+ *
456
+ * @experimental Part of the `build*` composition surface (S14). Signature and
457
+ * JSON shape may shift before v1.0.
458
+ *
459
+ * @see OwnerClient.getOverview — the stable single-call alternative that
460
+ * pre-populates a full {@link OverviewContext} for you.
461
+ */
462
+ export function buildActivityRows(items) {
463
+ return items.map((item) => {
464
+ const cat = item.category ?? "unknown";
465
+ const evt = item.eventType ?? "";
466
+ const act = item.actionType ?? undefined;
467
+ const posEffect = item.positionEffect ?? undefined;
468
+ const type = mapCategory(cat, evt, act, posEffect);
469
+ const amt = item.amount ?? 0n;
470
+ const sig = item.txSignature || `evt-${item.timestamp}-${item.eventType}`;
471
+ return {
472
+ id: sig,
473
+ timestamp: item.timestamp * 1000,
474
+ type,
475
+ protocol: item.protocolName || "",
476
+ protocolId: item.protocol || "",
477
+ agent: item.agent || "",
478
+ amount: amt,
479
+ status: item.success ? "approved" : "blocked",
480
+ reason: item.success ? undefined : item.description,
481
+ txSignature: item.txSignature,
89
482
  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 },
483
+ id: sig,
484
+ timestamp: item.timestamp * 1000,
485
+ type,
486
+ protocol: item.protocolName || "",
487
+ protocolId: item.protocol || "",
488
+ agent: item.agent || "",
489
+ amount: bs(amt),
490
+ status: item.success ? "approved" : "blocked",
491
+ reason: item.success ? undefined : item.description,
492
+ txSignature: item.txSignature,
112
493
  }),
113
494
  };
495
+ });
496
+ }
497
+ // ─── getVaultState ───────────────────────────────────────────────────────────
498
+ export async function getVaultState(rpc, vault, network) {
499
+ try {
500
+ const [state, pnl] = await Promise.all([
501
+ resolveVaultStateForOwner(rpc, vault, undefined, toNet(network)),
502
+ getVaultPnL(rpc, vault, toNet(network)),
503
+ ]);
504
+ return buildVaultState({ vault, state, pnl });
114
505
  }
115
506
  catch (err) {
116
507
  throw toDxError(err, "OwnerClient.getVaultState");
@@ -119,70 +510,27 @@ export async function getVaultState(rpc, vault, network) {
119
510
  // ─── getAgents ───────────────────────────────────────────────────────────────
120
511
  export async function getAgents(rpc, vault, network) {
121
512
  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.
513
+ // Single getVaultActivity call is shared across all agents (N+1 prevention).
514
+ // Activity is enrichment, so a fetch failure degrades gracefully to empty
515
+ // last-action fields. Window: 100 most recent signatures large enough to
516
+ // surface last action for low-volume agents without inflating RPC cost.
517
+ //
518
+ // Fix for docs/SECURITY-FINDINGS-2026-04-07.md Finding 5: the previous
519
+ // `.catch(() => [])` swallowed activity-fetch failures silently. If Helius
520
+ // started rate-limiting getSignaturesForAddress, every dashboard call would
521
+ // show "last action: never" for every agent forever and nobody would
522
+ // notice. Graceful degradation is still the right behavior (activity is
523
+ // enrichment, not core), but it must be observable — console.warn is the
524
+ // minimum bar.
127
525
  const [state, activity] = await Promise.all([
128
526
  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
527
  getVaultActivity(rpc, vault, 100, toNet(network)).catch((err) => {
137
528
  // eslint-disable-next-line no-console
138
529
  console.warn("[OwnerClient.getAgents] activity enrichment failed — falling back to empty last-action fields:", err instanceof Error ? err.message : String(err));
139
530
  return [];
140
531
  }),
141
532
  ]);
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
- });
533
+ return buildAgents({ vault, state, activity });
186
534
  }
187
535
  catch (err) {
188
536
  throw toDxError(err, "OwnerClient.getAgents");
@@ -192,44 +540,7 @@ export async function getAgents(rpc, vault, network) {
192
540
  export async function getSpending(rpc, vault, network) {
193
541
  try {
194
542
  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
- };
543
+ return buildSpending({ vault, state });
233
544
  }
234
545
  catch (err) {
235
546
  throw toDxError(err, "OwnerClient.getSpending");
@@ -240,39 +551,7 @@ export async function getActivity(rpc, vault, network, filters) {
240
551
  try {
241
552
  const limit = filters?.limit ?? 50;
242
553
  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
- });
554
+ let rows = buildActivityRows(items);
276
555
  if (filters?.agent)
277
556
  rows = rows.filter((r) => r.agent === filters.agent);
278
557
  if (filters?.protocol)
@@ -352,40 +631,7 @@ function rangeToMs(r) {
352
631
  export async function getHealth(rpc, vault, network) {
353
632
  try {
354
633
  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
- };
634
+ return buildHealth({ vault, state });
389
635
  }
390
636
  catch (err) {
391
637
  throw toDxError(err, "OwnerClient.getHealth");
@@ -399,124 +645,114 @@ export async function getPolicy(rpc, vault, network) {
399
645
  getPendingPolicyForVault(rpc, vault).catch((err) => {
400
646
  // Account-not-found is expected (no pending update) — return null.
401
647
  // 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")) {
648
+ if (isAccountNotFoundError(err))
405
649
  return null;
406
- }
407
650
  throw err;
408
651
  }),
409
652
  ]);
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",
653
+ return buildPolicy({ vault, state, pendingPolicy });
654
+ }
655
+ catch (err) {
656
+ throw toDxError(err, "OwnerClient.getPolicy");
657
+ }
658
+ }
659
+ // ─── getOverview (S14) ───────────────────────────────────────────────────────
660
+ /**
661
+ * Single-call overview bundle — resolves vault state once, composes all five
662
+ * view types (vault, agents, spending, health, policy) plus a raw activity
663
+ * list, with PnL derived from the resolved state (no duplicate resolve).
664
+ *
665
+ * **Actual RPC shape.** Calling the five individual reads duplicates the
666
+ * vault-state resolution up to five times. `getOverview` resolves state
667
+ * exactly once and computes PnL from it via {@link getVaultPnLFromState}.
668
+ * The activity fetch is independent: `getVaultActivity(limit)` issues one
669
+ * `getSignaturesForAddress` followed by up to `limit` sequential
670
+ * `getTransaction` calls, so the wall-time cost of the activity feed
671
+ * dominates regardless of this method. Net savings vs. five separate reads:
672
+ * state resolution count drops from ~5 → 1.
673
+ *
674
+ * Activity is **unfiltered**. For filtered activity, call {@link getActivity}
675
+ * with `ActivityFilters`.
676
+ *
677
+ * Graceful degradation: activity fetch failure degrades to empty activity
678
+ * (same observable pattern as `getAgents`, documented in
679
+ * `docs/SECURITY-FINDINGS-2026-04-07.md` Finding 5); pending-policy
680
+ * account-not-found is treated as "no pending update" (same as `getPolicy`).
681
+ * **PnL and state-resolution errors are NOT degraded** and propagate via
682
+ * `toDxError`. A pending-policy error that is NOT account-not-found (e.g.
683
+ * network failure) also propagates — it is NOT treated as "no pending
684
+ * update", even on the `includeActivity: false` lightweight path.
685
+ */
686
+ export async function getOverview(rpc, vault, network, options) {
687
+ try {
688
+ const includeActivity = options?.includeActivity ?? true;
689
+ const activityLimit = options?.activityLimit ?? DEFAULT_OVERVIEW_ACTIVITY_LIMIT;
690
+ const net = toNet(network);
691
+ // Fan out every independent fetch in one Promise.all. State resolution,
692
+ // activity, and pending-policy have no cross-dependency, so wall time
693
+ // collapses to the slowest of the three. PnL is derived from state
694
+ // synchronously after — one state resolve, zero duplication.
695
+ const [state, activity, pendingPolicy] = await Promise.all([
696
+ resolveVaultStateForOwner(rpc, vault, undefined, net),
697
+ includeActivity
698
+ ? getVaultActivity(rpc, vault, activityLimit, net).catch((err) => {
699
+ // Same graceful-degradation pattern as getAgents
700
+ // (docs/SECURITY-FINDINGS-2026-04-07.md Finding 5): activity is
701
+ // enrichment, not core, but the failure must be observable.
702
+ // eslint-disable-next-line no-console
703
+ console.warn("[OwnerClient.getOverview] activity fetch failed — falling back to empty:", err instanceof Error ? err.message : String(err));
704
+ return [];
705
+ })
706
+ : Promise.resolve(undefined),
707
+ getPendingPolicyForVault(rpc, vault).catch((err) => {
708
+ if (isAccountNotFoundError(err))
709
+ return null;
710
+ throw err;
711
+ }),
712
+ ]);
713
+ // PnL is pure from resolved state — no extra RPC.
714
+ const pnl = getVaultPnLFromState(state);
715
+ // Compute the three state-derived values exactly once and memoize on ctx.
716
+ // Every build* helper reads these via the `ctx.field ?? derive()` fallback
717
+ // so the memoized value short-circuits re-derivation.
718
+ const posture = getSecurityPosture(asVaultState(state));
719
+ const breakdown = getSpendingBreakdown(asVaultState(state));
720
+ const alerts = evaluateAlertConditions(state, vault);
721
+ const ctx = {
722
+ vault,
723
+ state,
724
+ pnl,
725
+ activity,
726
+ pendingPolicy,
727
+ posture,
728
+ breakdown,
729
+ alerts,
420
730
  };
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
- }
731
+ const vaultView = buildVaultState(ctx);
732
+ const agentsView = buildAgents(ctx);
733
+ const spendingView = buildSpending(ctx);
734
+ const healthView = buildHealth(ctx);
735
+ const policyView = buildPolicy(ctx);
736
+ const activityRows = buildActivityRows(activity ?? []);
474
737
  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,
738
+ vault: vaultView,
739
+ agents: agentsView,
740
+ spending: spendingView,
741
+ health: healthView,
742
+ policy: policyView,
743
+ activity: activityRows,
491
744
  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,
745
+ vault: vaultView.toJSON(),
746
+ agents: agentsView.map((a) => a.toJSON()),
747
+ spending: spendingView.toJSON(),
748
+ health: healthView.toJSON(),
749
+ policy: policyView.toJSON(),
750
+ activity: activityRows.map((r) => r.toJSON()),
515
751
  }),
516
752
  };
517
753
  }
518
754
  catch (err) {
519
- throw toDxError(err, "OwnerClient.getPolicy");
755
+ throw toDxError(err, "OwnerClient.getOverview");
520
756
  }
521
757
  }
522
758
  //# sourceMappingURL=reads.js.map