@panoptic-eng/mcp 0.1.0 → 0.1.2

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.
@@ -1,6 +1,6 @@
1
- import { DEFAULT_VEGOID, MAX_TICK, MIN_TICK, STANDARD_TICK_WIDTHS, WAD, createMemoryStorage, createTokenIdBuilder, decodeTokenId, getAccountBuyingPower, getAccountCollateral, getAccountGreeks, getAccountHistory, getAccountPremia, getChainDeployment, getDeltaHedgeParams, getLegDelta, getMarginBuffer, getNetLiquidationValues, getOpenPositionPreview, getPool, getPoolDeploymentBlock, getPoolLiquidities, getPositions, getTrackedPositionIds, hasLoanOrCredit, hasLongLeg, isCall, isCreditLeg, isLiquidatable, isLoanLeg, isShortOnly, isSpread, scanChunks, simulateClosePosition, syncPositions } from "@panoptic-eng/sdk/v2";
1
+ import { DEFAULT_VEGOID, MAX_TICK, MIN_TICK, STANDARD_TICK_WIDTHS, WAD, createMemoryStorage, createTokenIdBuilder, decodeTokenId, getAccountBuyingPower, getAccountCollateral, getAccountGreeks, getAccountHistory, getAccountPremia, getChainDeployment, getDeltaHedgeParams, getLegDelta, getMarginBuffer, getNetLiquidationValues, getOpenPositionPreview, getPool, getPoolDeploymentBlock, getPoolLiquidities, getPositions, getTrackedPositionIds, hasLoanOrCredit, hasLongLeg, isCall, isCreditLeg, isLiquidatable, isLoanLeg, isShortOnly, isSpread, roundToTickSpacing, scanChunks, simulateClosePosition, syncPositions, tickToPriceDecimalScaled } from "@panoptic-eng/sdk/v2";
2
2
  import { createPublicClient, getAddress, http, isAddress, keccak256, toBytes, toHex } from "viem";
3
- import { base, mainnet, sepolia } from "viem/chains";
3
+ import { mainnet, sepolia } from "viem/chains";
4
4
  import { createRequire } from "node:module";
5
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
6
  import { z } from "zod";
@@ -9,27 +9,29 @@ import { z } from "zod";
9
9
  /** viem chain definitions (carry the Multicall3 address the SDK reads rely on). */
10
10
  const VIEM_CHAINS = {
11
11
  [mainnet.id]: mainnet,
12
- [base.id]: base,
13
12
  [sepolia.id]: sepolia
14
13
  };
15
- /** Chain ids Panoptic has deployments for (mainnet, Base, Sepolia). */
16
- const KNOWN_CHAIN_IDS = [
17
- mainnet.id,
18
- base.id,
19
- sepolia.id
20
- ];
14
+ const KNOWN_CHAIN_IDS = [mainnet.id, sepolia.id];
21
15
  /**
22
- * Resolve an optional user-supplied chain-id string to a trusted known-chain
23
- * constant, falling back to `fallback` when omitted/blank. Matches on canonical
24
- * decimal form only (never Number(), so "1e0" is rejected) and throws on
25
- * unsupported ids.
16
+ * Validate a user-supplied chain-id string against the trusted known-chain
17
+ * constants AND the SDK deployment registry, returning the matched id. Matches
18
+ * on canonical decimal form only (never Number(), so "1e0" is rejected). This is
19
+ * the single validation path shared by resolveChainId (per-call args) and
20
+ * resolveConfig (startup) so the two can't drift.
26
21
  */
27
- function resolveChainId(rawChainId, fallback) {
28
- if (rawChainId === void 0 || rawChainId.trim() === "") return fallback;
22
+ function toSupportedChainId(rawChainId) {
29
23
  const chainId = KNOWN_CHAIN_IDS.find((id) => id.toString() === rawChainId.trim());
30
- if (chainId === void 0) throw new Error(`Unsupported chain id ${rawChainId}. Supported: ${KNOWN_CHAIN_IDS.join(", ")}.`);
24
+ if (chainId === void 0 || getChainDeployment(chainId) === void 0) throw new Error(`Unsupported chain id ${rawChainId}. Supported: ${KNOWN_CHAIN_IDS.join(", ")}.`);
31
25
  return chainId;
32
26
  }
27
+ /**
28
+ * Resolve an optional user-supplied chain-id string to a trusted, deployed
29
+ * known-chain constant, falling back to `fallback` when omitted/blank.
30
+ */
31
+ function resolveChainId(rawChainId, fallback) {
32
+ if (rawChainId === void 0 || rawChainId.trim() === "") return fallback;
33
+ return toSupportedChainId(rawChainId);
34
+ }
33
35
  var ConfigError = class extends Error {};
34
36
  /**
35
37
  * Resolve server config from CLI flags (`--rpc-url`, `--chain-id`,
@@ -43,8 +45,12 @@ function resolveConfig(argv, env) {
43
45
  if (!rpcUrl) throw new ConfigError("Missing RPC URL. Set PANOPTIC_RPC_URL or pass --rpc-url <url>.");
44
46
  const rawChainId = flags["chain-id"] ?? env.PANOPTIC_CHAIN_ID;
45
47
  if (!rawChainId) throw new ConfigError("Missing chain id. Set PANOPTIC_CHAIN_ID or pass --chain-id <id>.");
46
- const chainId = KNOWN_CHAIN_IDS.find((id) => id.toString() === rawChainId.trim());
47
- if (chainId === void 0 || getChainDeployment(chainId) === void 0) throw new ConfigError(`Unsupported chain id ${rawChainId}. Supported: ${KNOWN_CHAIN_IDS.join(", ")}.`);
48
+ let chainId;
49
+ try {
50
+ chainId = toSupportedChainId(rawChainId);
51
+ } catch (err) {
52
+ throw new ConfigError(err instanceof Error ? err.message : String(err));
53
+ }
48
54
  const subgraphUrl = flags["subgraph-url"] ?? env.PANOPTIC_SUBGRAPH_URL ?? void 0;
49
55
  return {
50
56
  rpcUrl,
@@ -65,7 +71,8 @@ async function createContext(config) {
65
71
  return {
66
72
  config,
67
73
  client,
68
- deployment
74
+ deployment,
75
+ session: {}
69
76
  };
70
77
  }
71
78
  function parseFlags(argv) {
@@ -94,6 +101,46 @@ function parseAccountAddress(address) {
94
101
  return getAddress(address);
95
102
  }
96
103
 
104
+ //#endregion
105
+ //#region src/numeric.ts
106
+ /**
107
+ * Shared parsing for the decimal-/hex-string numeric inputs every tool accepts.
108
+ * Centralizes the validation so malformed input fails early with a consistent,
109
+ * readable message instead of a raw `BigInt()` "Cannot convert" throw (or, for
110
+ * `Number()`, silent coercion of forms like "1e9").
111
+ */
112
+ const UNSIGNED = /^\d+$/;
113
+ const SIGNED = /^-?\d+$/;
114
+ /**
115
+ * Parse a required decimal integer string to a bigint. Set `signed` to allow a
116
+ * leading `-` (e.g. ticks, target delta); otherwise only non-negative integers
117
+ * are accepted (blocks, epochs, sizes).
118
+ */
119
+ function parseDecimalBigInt(raw, label, opts = {}) {
120
+ const trimmed = raw.trim();
121
+ const pattern = opts.signed ? SIGNED : UNSIGNED;
122
+ if (!pattern.test(trimmed)) throw new Error(`${label} must be a ${opts.signed ? "decimal" : "non-negative decimal"} integer string.`);
123
+ return BigInt(trimmed);
124
+ }
125
+ /**
126
+ * Like {@link parseDecimalBigInt} but treats `undefined` and blank/whitespace
127
+ * strings as "not provided", returning `undefined`.
128
+ */
129
+ function parseOptionalDecimalBigInt(raw, label, opts = {}) {
130
+ if (raw === void 0 || raw.trim() === "") return void 0;
131
+ return parseDecimalBigInt(raw, label, opts);
132
+ }
133
+ /**
134
+ * Parse a TokenId supplied as either a 0x-hex or a decimal string. TokenIds are
135
+ * unsigned, so both forms map straight through `BigInt`, with a readable error
136
+ * on anything else.
137
+ */
138
+ function parseTokenId(raw, label = "tokenId") {
139
+ const trimmed = raw.trim();
140
+ if (!/^(0x[0-9a-fA-F]+|\d+)$/.test(trimmed)) throw new Error(`${label} must be a 0x-hex or decimal string.`);
141
+ return BigInt(trimmed);
142
+ }
143
+
97
144
  //#endregion
98
145
  //#region src/serialize.ts
99
146
  /**
@@ -166,42 +213,82 @@ function buildEnvelope(input) {
166
213
  //#region src/tools/positions.ts
167
214
  const STABLE_SYMBOL = /^(usdc|usdt|usdbc|usds|usde|dai|frax|lusd|gusd|tusd|pyusd)$/i;
168
215
  /**
169
- * Resolve which token is the directional "asset" (non-numeraire). An explicit
170
- * override wins; otherwise the stablecoin side is treated as the numeraire and
171
- * the other token as the asset (defaulting to token0 if neither looks stable).
216
+ * Choose the frame in which to report an account's aggregate net delta. An
217
+ * explicit caller override always wins. Absent that, the stablecoin side (if
218
+ * any) is treated as the numeraire so the other token becomes the reporting
219
+ * frame — a convenience default only, surfaced with `source`/`reason` so callers
220
+ * can see it was guessed and re-request a different frame.
172
221
  */
173
- function inferAssetIndex(pool, override) {
222
+ function suggestDeltaFrame(pool, override) {
174
223
  if (override !== void 0) return {
175
- asset: override === 0 ? 0n : 1n,
176
- inferred: false
224
+ frame: override === 0 ? 0n : 1n,
225
+ source: "override",
226
+ reason: "Caller-specified reporting frame."
177
227
  };
178
- if (STABLE_SYMBOL.test(pool.collateralTracker0.symbol)) return {
179
- asset: 1n,
180
- inferred: true
228
+ const sym0 = pool.collateralTracker0.symbol;
229
+ const sym1 = pool.collateralTracker1.symbol;
230
+ if (STABLE_SYMBOL.test(sym0)) return {
231
+ frame: 1n,
232
+ source: "heuristic",
233
+ reason: `token0 (${sym0}) looks like a stablecoin numeraire, so net delta is reported in token1 (${sym1}) units.`
181
234
  };
182
- if (STABLE_SYMBOL.test(pool.collateralTracker1.symbol)) return {
183
- asset: 0n,
184
- inferred: true
235
+ if (STABLE_SYMBOL.test(sym1)) return {
236
+ frame: 0n,
237
+ source: "heuristic",
238
+ reason: `token1 (${sym1}) looks like a stablecoin numeraire, so net delta is reported in token0 (${sym0}) units.`
185
239
  };
186
240
  return {
187
- asset: 0n,
188
- inferred: true
241
+ frame: 0n,
242
+ source: "default",
243
+ reason: `Neither token (${sym0}/${sym1}) looks like a stablecoin; defaulting the reporting frame to token0. Pass assetIndex to choose the frame explicitly.`
244
+ };
245
+ }
246
+ /**
247
+ * Resolve the delta reporting frame with precedence: explicit tool argument →
248
+ * session preference (set via configure_session) → heuristic guess. Only the
249
+ * last case sets `needsUserChoice`, signalling the caller to ask the user which
250
+ * token they want their net delta denominated in.
251
+ */
252
+ function resolveDeltaFrame(pool, argIndex, sessionPreference) {
253
+ if (argIndex !== void 0) return {
254
+ ...suggestDeltaFrame(pool, argIndex),
255
+ source: "override",
256
+ needsUserChoice: false
257
+ };
258
+ if (sessionPreference !== void 0) {
259
+ const symbol = sessionPreference === 0n ? pool.collateralTracker0.symbol : pool.collateralTracker1.symbol;
260
+ return {
261
+ frame: sessionPreference,
262
+ source: "session",
263
+ reason: `Using the session's preferred reporting frame (${symbol}); set via configure_session.`,
264
+ needsUserChoice: false
265
+ };
266
+ }
267
+ return {
268
+ ...suggestDeltaFrame(pool),
269
+ needsUserChoice: true
189
270
  };
190
271
  }
272
+ /** Explanatory note attached to every account-level net-delta figure. */
273
+ const DELTA_FRAME_NOTE = "Net delta is a single figure expressed in the chosen reporting frame (assetIndex). It is not a claim about a canonical pool asset: each position leg carries its own asset, and selecting a frame overrides every leg into that frame. The sign and magnitude depend on the frame — pass assetIndex to report in the other token.";
191
274
  /**
192
275
  * Discover an account's open position ids on the configured pool via the SDK's
193
276
  * event-scan sync into a fresh in-memory store — no subgraph required. Returns
194
277
  * the populated storage so greeks reads (which read synced positions from
195
278
  * storage) can reuse it.
196
279
  */
280
+ const SYNC_TTL_MS = 15e3;
281
+ const SYNC_CACHE_MAX = 64;
197
282
  const syncCache = new Map();
198
283
  async function syncAccountPositions(ctx, account, fromBlock) {
199
284
  const poolAddress = ctx.deployment.panoptic.pool.panopticPool;
200
285
  const chainId = BigInt(ctx.config.chainId);
201
286
  const cacheKey = `${chainId}:${poolAddress}:${account}:${fromBlock ?? "default"}`;
287
+ const now = Date.now();
202
288
  const cached = syncCache.get(cacheKey);
203
- if (cached) return cached;
204
- const pending = (async () => {
289
+ if (cached && cached.expiresAt > now) return cached.promise;
290
+ if (cached) syncCache.delete(cacheKey);
291
+ const promise = (async () => {
205
292
  const storage = createMemoryStorage();
206
293
  await syncPositions({
207
294
  client: ctx.client,
@@ -224,9 +311,19 @@ async function syncAccountPositions(ctx, account, fromBlock) {
224
311
  storage
225
312
  };
226
313
  })();
227
- syncCache.set(cacheKey, pending);
228
- pending.catch(() => syncCache.delete(cacheKey));
229
- return pending;
314
+ if (syncCache.size >= SYNC_CACHE_MAX) {
315
+ const oldest = syncCache.keys().next().value;
316
+ if (oldest !== void 0) syncCache.delete(oldest);
317
+ }
318
+ const entry = {
319
+ promise,
320
+ expiresAt: now + SYNC_TTL_MS
321
+ };
322
+ syncCache.set(cacheKey, entry);
323
+ promise.catch(() => {
324
+ if (syncCache.get(cacheKey) === entry) syncCache.delete(cacheKey);
325
+ });
326
+ return promise;
230
327
  }
231
328
 
232
329
  //#endregion
@@ -242,7 +339,7 @@ function registerAccountHealth(server, ctx) {
242
339
  }, async ({ address, fromBlock }) => {
243
340
  const account = parseAccountAddress(address);
244
341
  const queryAddress = ctx.deployment.panoptic.v2.panopticQuery;
245
- const { poolAddress, tokenIds } = await syncAccountPositions(ctx, account, fromBlock ? BigInt(fromBlock) : void 0);
342
+ const { poolAddress, tokenIds } = await syncAccountPositions(ctx, account, parseOptionalDecimalBigInt(fromBlock, "fromBlock"));
246
343
  const [buyingPower, marginBuffer, liquidation] = await Promise.all([
247
344
  getAccountBuyingPower({
248
345
  client: ctx.client,
@@ -270,11 +367,50 @@ function registerAccountHealth(server, ctx) {
270
367
  openTokenIds: tokenIds,
271
368
  buyingPower,
272
369
  marginBuffer,
273
- liquidation
370
+ liquidation,
371
+ ...tokenIds.length === 0 ? { note: "No open positions found on this pool for this account; margin buffer/liquidation reflect an unlevered account. If positions are expected, widen the scan with fromBlock — public RPCs can truncate large eth_getLogs ranges." } : {}
274
372
  });
275
373
  });
276
374
  }
277
375
 
376
+ //#endregion
377
+ //#region src/tools/pool.ts
378
+ const POOL_TTL_MS = 6e3;
379
+ const POOL_CACHE_MAX = 8;
380
+ const poolCache = new Map();
381
+ /**
382
+ * Read the configured pool's live state, reusing a recent read when one is in
383
+ * flight or fresh. Failures are not cached, so the next call retries.
384
+ */
385
+ async function getCachedPool(ctx) {
386
+ const poolAddress = ctx.deployment.panoptic.pool.panopticPool;
387
+ const chainId = BigInt(ctx.config.chainId);
388
+ const cacheKey = `${chainId}:${poolAddress}`;
389
+ const now = Date.now();
390
+ const cached = poolCache.get(cacheKey);
391
+ if (cached && cached.expiresAt > now) return cached.promise;
392
+ if (cached) poolCache.delete(cacheKey);
393
+ const promise = getPool({
394
+ client: ctx.client,
395
+ poolAddress,
396
+ chainId,
397
+ stateViewAddress: ctx.deployment.infrastructure.stateView
398
+ });
399
+ if (poolCache.size >= POOL_CACHE_MAX) {
400
+ const oldest = poolCache.keys().next().value;
401
+ if (oldest !== void 0) poolCache.delete(oldest);
402
+ }
403
+ const entry = {
404
+ promise,
405
+ expiresAt: now + POOL_TTL_MS
406
+ };
407
+ poolCache.set(cacheKey, entry);
408
+ promise.catch(() => {
409
+ if (poolCache.get(cacheKey) === entry) poolCache.delete(cacheKey);
410
+ });
411
+ return promise;
412
+ }
413
+
278
414
  //#endregion
279
415
  //#region src/tools/advisePosition.ts
280
416
  const NEAR_LIQUIDATION_TICKS = 500n;
@@ -319,26 +455,14 @@ function registerAdvisePosition(server, ctx) {
319
455
  fromBlock: z.string().optional().describe("Optional starting block (decimal string) to bound the position event scan.")
320
456
  }
321
457
  }, async ({ tokenId, address, fromBlock }) => {
322
- if (!isAddress(address)) throw new Error(`Invalid address: ${address}`);
323
- const account = getAddress(address);
324
- let id;
325
- try {
326
- id = BigInt(tokenId.trim());
327
- } catch {
328
- throw new Error(`Invalid tokenId (expected a 0x-hex or decimal string): ${tokenId}`);
329
- }
458
+ const account = parseAccountAddress(address);
459
+ const id = parseTokenId(tokenId);
330
460
  const poolAddress = ctx.deployment.panoptic.pool.panopticPool;
331
461
  const queryAddress = ctx.deployment.panoptic.v2.panopticQuery;
332
- const chainId = BigInt(ctx.config.chainId);
333
- const pool = await getPool({
334
- client: ctx.client,
335
- poolAddress,
336
- chainId,
337
- stateViewAddress: ctx.deployment.infrastructure.stateView
338
- });
462
+ const pool = await getCachedPool(ctx);
339
463
  const decoded = decodeTokenId(id);
340
464
  const legs = decoded.legs.map((leg) => analyzeLeg(leg, pool.currentTick));
341
- const { tokenIds } = await syncAccountPositions(ctx, account, fromBlock ? BigInt(fromBlock) : void 0);
465
+ const { tokenIds } = await syncAccountPositions(ctx, account, parseOptionalDecimalBigInt(fromBlock, "fromBlock"));
342
466
  const owned = tokenIds.some((t) => t === id);
343
467
  const idList = owned ? tokenIds : [...tokenIds, id];
344
468
  const [{ positions }, premia, marginBuffer, liquidation] = await Promise.all([
@@ -435,6 +559,35 @@ function registerAdvisePosition(server, ctx) {
435
559
  });
436
560
  }
437
561
 
562
+ //#endregion
563
+ //#region src/tools/tickMath.ts
564
+ /**
565
+ * Clamp a tick into the valid Uniswap range [MIN_TICK, MAX_TICK]. The SDK
566
+ * provides tick↔price math and roundToTickSpacing but no plain clamp, so this
567
+ * one small helper lives here and is shared by the tools that build tick bands.
568
+ */
569
+ function clampTick(tick) {
570
+ if (tick < MIN_TICK) return MIN_TICK;
571
+ if (tick > MAX_TICK) return MAX_TICK;
572
+ return tick;
573
+ }
574
+ /**
575
+ * Round a tick to the nearest `spacing` multiple, guaranteed to stay within the
576
+ * valid range. roundToTickSpacing rounds to the *nearest* multiple, so a value
577
+ * at the range edge can round outward past MIN_TICK/MAX_TICK when spacing does
578
+ * not divide them; this clamps the result to the inward-aligned multiples
579
+ * (largest ≤ MAX_TICK, smallest ≥ MIN_TICK) so the output is always both a
580
+ * `spacing` multiple AND in range — required by callers like scanChunks.
581
+ */
582
+ function roundToSpacingInRange(tick, spacing) {
583
+ const rounded = roundToTickSpacing(clampTick(tick), spacing);
584
+ const maxAligned = MAX_TICK - MAX_TICK % spacing;
585
+ const minAligned = MIN_TICK - MIN_TICK % spacing;
586
+ if (rounded > maxAligned) return maxAligned;
587
+ if (rounded < minAligned) return minAligned;
588
+ return rounded;
589
+ }
590
+
438
591
  //#endregion
439
592
  //#region src/tools/chunkSpread.ts
440
593
  const DEFAULT_TIMESCALE = "1D";
@@ -443,15 +596,6 @@ const DEFAULT_SCAN_HALFSPAN = 6000n;
443
596
  function toMultiplier(spreadWad) {
444
597
  return Number(spreadWad * 10000n / WAD) / 1e4;
445
598
  }
446
- function clampTick$1(tick) {
447
- if (tick < MIN_TICK) return MIN_TICK;
448
- if (tick > MAX_TICK) return MAX_TICK;
449
- return tick;
450
- }
451
- /** Floor a tick to the nearest lower multiple of `step`. */
452
- function alignDown(tick, step) {
453
- return tick - (tick % step + step) % step;
454
- }
455
599
  function registerChunkSpread(server, ctx) {
456
600
  server.registerTool("chunk_spread", {
457
601
  title: "Chunk spread multipliers",
@@ -472,21 +616,16 @@ function registerChunkSpread(server, ctx) {
472
616
  }, async ({ timescale, width, tickLower, tickUpper, vegoid }) => {
473
617
  const poolAddress = ctx.deployment.panoptic.pool.panopticPool;
474
618
  const queryAddress = ctx.deployment.panoptic.v2.panopticQuery;
475
- const pool = await getPool({
476
- client: ctx.client,
477
- poolAddress,
478
- chainId: BigInt(ctx.config.chainId),
479
- stateViewAddress: ctx.deployment.infrastructure.stateView
480
- });
619
+ const pool = await getCachedPool(ctx);
481
620
  const resolvedTimescale = timescale ?? DEFAULT_TIMESCALE;
482
621
  const rawWidth = width !== void 0 ? BigInt(width) : STANDARD_TICK_WIDTHS[resolvedTimescale];
483
622
  const chunkWidth = rawWidth < pool.tickSpacing ? pool.tickSpacing : rawWidth - rawWidth % pool.tickSpacing;
484
623
  const vg = vegoid !== void 0 ? BigInt(vegoid) : DEFAULT_VEGOID;
485
624
  const halfWidthsRaw = DEFAULT_SCAN_HALFSPAN / chunkWidth;
486
625
  const halfWidths = halfWidthsRaw < 2n ? 2n : halfWidthsRaw;
487
- const center = alignDown(pool.currentTick, chunkWidth);
488
- const lower = tickLower !== void 0 ? alignDown(clampTick$1(BigInt(tickLower)), chunkWidth) : alignDown(clampTick$1(center - halfWidths * chunkWidth), chunkWidth);
489
- const upper = tickUpper !== void 0 ? alignDown(clampTick$1(BigInt(tickUpper)), chunkWidth) : alignDown(clampTick$1(center + halfWidths * chunkWidth), chunkWidth);
626
+ const center = roundToSpacingInRange(pool.currentTick, chunkWidth);
627
+ const lower = tickLower !== void 0 ? roundToSpacingInRange(parseDecimalBigInt(tickLower, "tickLower", { signed: true }), chunkWidth) : roundToSpacingInRange(center - halfWidths * chunkWidth, chunkWidth);
628
+ const upper = tickUpper !== void 0 ? roundToSpacingInRange(parseDecimalBigInt(tickUpper, "tickUpper", { signed: true }), chunkWidth) : roundToSpacingInRange(center + halfWidths * chunkWidth, chunkWidth);
490
629
  const { chunks, _meta } = await scanChunks({
491
630
  client: ctx.client,
492
631
  poolAddress,
@@ -587,7 +726,7 @@ function registerGetPortfolio(server, ctx) {
587
726
  }
588
727
  }, async ({ address, fromBlock }) => {
589
728
  const account = parseAccountAddress(address);
590
- const { poolAddress, chainId, tokenIds, storage } = await syncAccountPositions(ctx, account, fromBlock ? BigInt(fromBlock) : void 0);
729
+ const { poolAddress, chainId, tokenIds, storage } = await syncAccountPositions(ctx, account, parseOptionalDecimalBigInt(fromBlock, "fromBlock"));
591
730
  const [positions, collateral, greeks] = await Promise.all([
592
731
  getPositions({
593
732
  client: ctx.client,
@@ -614,7 +753,55 @@ function registerGetPortfolio(server, ctx) {
614
753
  openTokenIds: tokenIds,
615
754
  positions,
616
755
  collateral,
617
- greeks
756
+ greeks,
757
+ ...tokenIds.length === 0 ? { note: "No open positions found on this pool for this account (positions/greeks reflect collateral only). If positions are expected, widen the scan with fromBlock — public RPCs can truncate large eth_getLogs ranges." } : {}
758
+ });
759
+ });
760
+ }
761
+
762
+ //#endregion
763
+ //#region src/tools/session.ts
764
+ /**
765
+ * Build the response a delta tool returns when it cannot pick a reporting frame
766
+ * on its own. Rather than silently guessing which token the user wants their net
767
+ * delta denominated in, the tool surfaces a question the agent should put to the
768
+ * user, plus the exact follow-up call to make. `suggestion` carries the
769
+ * heuristic guess so the agent can offer a sensible default.
770
+ */
771
+ function assetPreferenceRequired(params) {
772
+ const { token0, token1, suggestedAssetIndex, suggestionReason } = params;
773
+ const suggestedSymbol = suggestedAssetIndex === 0n ? token0 : token1;
774
+ return jsonResult({
775
+ status: "needs_asset_preference",
776
+ question: `Which token should net delta be reported in for this pool — ${token0} (assetIndex 0) or ${token1} (assetIndex 1)? There is no canonical answer: it is a denomination choice (e.g. some traders treat ETH as the numeraire, others USDC).`,
777
+ suggestion: {
778
+ assetIndex: suggestedAssetIndex,
779
+ symbol: suggestedSymbol,
780
+ reason: suggestionReason
781
+ },
782
+ howToProceed: "Ask the user which token they prefer, then either pass `assetIndex` (0 or 1) directly to this tool for a one-off, or call `configure_session` with `assetIndex` to set it for the rest of the session so you are not asked again.",
783
+ tokens: {
784
+ token0,
785
+ token1
786
+ }
787
+ });
788
+ }
789
+ function registerConfigureSession(server, ctx) {
790
+ server.registerTool("configure_session", {
791
+ title: "Configure the session",
792
+ description: "Set (or read) preferences for this MCP session. Currently records the user's preferred delta reporting frame (`assetIndex`) so delta tools (suggest_delta_hedge, hedge_params) report net delta in the chosen token's units without asking again. There is no canonical pool asset — this is a per-user denomination choice, so ask the user before setting it. Call with no arguments to read the current session preferences.",
793
+ inputSchema: {
794
+ assetIndex: z.union([z.literal(0), z.literal(1)]).optional().describe("Preferred delta reporting frame for the session: 0 = report net delta in token0 units, 1 = token1 units. Omit to leave unchanged."),
795
+ clearAssetIndex: z.boolean().optional().describe("Set true to clear a previously set preferred assetIndex.")
796
+ }
797
+ }, async ({ assetIndex, clearAssetIndex }) => {
798
+ if (clearAssetIndex === true) ctx.session.preferredAssetIndex = void 0;
799
+ else if (assetIndex !== void 0) ctx.session.preferredAssetIndex = assetIndex === 0 ? 0n : 1n;
800
+ const current = ctx.session.preferredAssetIndex;
801
+ return jsonResult({
802
+ chainId: ctx.config.chainId,
803
+ preferences: { preferredAssetIndex: current },
804
+ note: current === void 0 ? "No preferred delta reporting frame is set. Delta tools will ask the user (or you can pass assetIndex per call)." : `Net delta will be reported in token${current} units for this session unless a call overrides it with an explicit assetIndex.`
618
805
  });
619
806
  });
620
807
  }
@@ -630,32 +817,17 @@ function registerHedgeParams(server, ctx) {
630
817
  tokenId: z.string().optional().describe("TokenId (0x-hex or decimal) for single-position hedging. Requires positionSize."),
631
818
  positionSize: z.string().optional().describe("Position size (decimal string). Required with tokenId."),
632
819
  targetDelta: z.string().optional().describe("Target delta in asset smallest units. Defaults to 0 (delta-neutral)."),
633
- assetIndex: z.union([z.literal(0), z.literal(1)]).optional().describe("Directional asset (0/1) for whole-portfolio mode. Defaults to inferring the non-stablecoin token."),
820
+ assetIndex: z.union([z.literal(0), z.literal(1)]).optional().describe("Reporting frame for whole-portfolio net delta: 0 = token0 units, 1 = token1 units. A denomination choice that overrides each leg's own asset when aggregating, NOT a canonical pool asset. Ignored in single-position (tokenId) mode, where the leg's own asset is used. If omitted and no session preference is set (via configure_session), the tool asks the user which token to use instead of guessing."),
634
821
  fromBlock: z.string().optional().describe("Optional starting block (decimal string) to bound the position event scan.")
635
822
  }
636
823
  }, async ({ address, tokenId, positionSize, targetDelta, assetIndex, fromBlock }) => {
637
824
  const poolAddress = ctx.deployment.panoptic.pool.panopticPool;
638
825
  const chainId = BigInt(ctx.config.chainId);
639
- let target;
640
- try {
641
- target = targetDelta !== void 0 ? BigInt(targetDelta) : 0n;
642
- } catch {
643
- throw new Error(`Invalid targetDelta (expected a decimal string): ${targetDelta}`);
644
- }
826
+ const target = targetDelta !== void 0 ? parseDecimalBigInt(targetDelta, "targetDelta", { signed: true }) : 0n;
645
827
  if (tokenId !== void 0) {
646
828
  if (positionSize === void 0) throw new Error("positionSize is required when hedging a single tokenId.");
647
- let id;
648
- let size;
649
- try {
650
- id = BigInt(tokenId.trim());
651
- } catch {
652
- throw new Error(`Invalid tokenId (expected a 0x-hex or decimal string): ${tokenId}`);
653
- }
654
- try {
655
- size = BigInt(positionSize);
656
- } catch {
657
- throw new Error(`Invalid positionSize (expected a decimal string): ${positionSize}`);
658
- }
829
+ const id = parseTokenId(tokenId);
830
+ const size = parseDecimalBigInt(positionSize, "positionSize");
659
831
  const hedge$1 = await getDeltaHedgeParams({
660
832
  client: ctx.client,
661
833
  poolAddress,
@@ -673,21 +845,17 @@ function registerHedgeParams(server, ctx) {
673
845
  });
674
846
  }
675
847
  if (address === void 0) throw new Error("Provide either `address` (whole-portfolio) or `tokenId` + `positionSize` (single).");
676
- if (!isAddress(address)) throw new Error(`Invalid address: ${address}`);
677
- const account = getAddress(address);
678
- const pool = await getPool({
679
- client: ctx.client,
680
- poolAddress,
681
- chainId,
682
- stateViewAddress: ctx.deployment.infrastructure.stateView
848
+ const account = parseAccountAddress(address);
849
+ const pool = await getCachedPool(ctx);
850
+ const frameResolution = resolveDeltaFrame(pool, assetIndex, ctx.session.preferredAssetIndex);
851
+ if (frameResolution.needsUserChoice) return assetPreferenceRequired({
852
+ token0: pool.collateralTracker0.symbol,
853
+ token1: pool.collateralTracker1.symbol,
854
+ suggestedAssetIndex: frameResolution.frame,
855
+ suggestionReason: frameResolution.reason
683
856
  });
684
- const { asset, inferred: assetInferred } = inferAssetIndex(pool, assetIndex);
685
- let scanFromBlock;
686
- try {
687
- scanFromBlock = fromBlock ? BigInt(fromBlock) : void 0;
688
- } catch {
689
- throw new Error(`Invalid fromBlock (expected a decimal string): ${fromBlock}`);
690
- }
857
+ const { frame: asset, source: assetFrameSource, reason: assetFrameReason } = frameResolution;
858
+ const scanFromBlock = parseOptionalDecimalBigInt(fromBlock, "fromBlock");
691
859
  const { tokenIds, storage } = await syncAccountPositions(ctx, account, scanFromBlock);
692
860
  const greeks = await getAccountGreeks({
693
861
  client: ctx.client,
@@ -712,7 +880,13 @@ function registerHedgeParams(server, ctx) {
712
880
  pool: poolAddress,
713
881
  assetIndex: asset,
714
882
  assetSymbol: asset === 0n ? pool.collateralTracker0.symbol : pool.collateralTracker1.symbol,
715
- assetInferred,
883
+ deltaFrame: {
884
+ assetIndex: asset,
885
+ symbol: asset === 0n ? pool.collateralTracker0.symbol : pool.collateralTracker1.symbol,
886
+ source: assetFrameSource,
887
+ reason: assetFrameReason
888
+ },
889
+ deltaFrameNote: DELTA_FRAME_NOTE,
716
890
  openTokenIds: tokenIds,
717
891
  currentDelta: greeks.totalDelta,
718
892
  targetDelta: target,
@@ -721,6 +895,486 @@ function registerHedgeParams(server, ctx) {
721
895
  });
722
896
  }
723
897
 
898
+ //#endregion
899
+ //#region src/tools/vaults.ts
900
+ const HYPO_VAULT_READ_ABI = [
901
+ {
902
+ type: "function",
903
+ name: "accountant",
904
+ inputs: [],
905
+ outputs: [{ type: "address" }],
906
+ stateMutability: "view"
907
+ },
908
+ {
909
+ type: "function",
910
+ name: "balanceOf",
911
+ inputs: [{ type: "address" }],
912
+ outputs: [{ type: "uint256" }],
913
+ stateMutability: "view"
914
+ },
915
+ {
916
+ type: "function",
917
+ name: "decimals",
918
+ inputs: [],
919
+ outputs: [{ type: "uint8" }],
920
+ stateMutability: "view"
921
+ },
922
+ {
923
+ type: "function",
924
+ name: "depositEpoch",
925
+ inputs: [],
926
+ outputs: [{ type: "uint128" }],
927
+ stateMutability: "view"
928
+ },
929
+ {
930
+ type: "function",
931
+ name: "depositEpochState",
932
+ inputs: [{ type: "uint256" }],
933
+ outputs: [
934
+ { type: "uint128" },
935
+ { type: "uint128" },
936
+ { type: "uint128" }
937
+ ],
938
+ stateMutability: "view"
939
+ },
940
+ {
941
+ type: "function",
942
+ name: "feeWallet",
943
+ inputs: [],
944
+ outputs: [{ type: "address" }],
945
+ stateMutability: "view"
946
+ },
947
+ {
948
+ type: "function",
949
+ name: "manager",
950
+ inputs: [],
951
+ outputs: [{ type: "address" }],
952
+ stateMutability: "view"
953
+ },
954
+ {
955
+ type: "function",
956
+ name: "name",
957
+ inputs: [],
958
+ outputs: [{ type: "string" }],
959
+ stateMutability: "view"
960
+ },
961
+ {
962
+ type: "function",
963
+ name: "performanceFeeBps",
964
+ inputs: [],
965
+ outputs: [{ type: "uint256" }],
966
+ stateMutability: "view"
967
+ },
968
+ {
969
+ type: "function",
970
+ name: "queuedDeposit",
971
+ inputs: [{ type: "address" }, { type: "uint256" }],
972
+ outputs: [{ type: "uint128" }],
973
+ stateMutability: "view"
974
+ },
975
+ {
976
+ type: "function",
977
+ name: "queuedWithdrawal",
978
+ inputs: [{ type: "address" }, { type: "uint256" }],
979
+ outputs: [
980
+ { type: "uint128" },
981
+ { type: "uint128" },
982
+ { type: "bool" }
983
+ ],
984
+ stateMutability: "view"
985
+ },
986
+ {
987
+ type: "function",
988
+ name: "reservedWithdrawalAssets",
989
+ inputs: [],
990
+ outputs: [{ type: "uint256" }],
991
+ stateMutability: "view"
992
+ },
993
+ {
994
+ type: "function",
995
+ name: "symbol",
996
+ inputs: [],
997
+ outputs: [{ type: "string" }],
998
+ stateMutability: "view"
999
+ },
1000
+ {
1001
+ type: "function",
1002
+ name: "totalSupply",
1003
+ inputs: [],
1004
+ outputs: [{ type: "uint256" }],
1005
+ stateMutability: "view"
1006
+ },
1007
+ {
1008
+ type: "function",
1009
+ name: "underlyingToken",
1010
+ inputs: [],
1011
+ outputs: [{ type: "address" }],
1012
+ stateMutability: "view"
1013
+ },
1014
+ {
1015
+ type: "function",
1016
+ name: "userBasis",
1017
+ inputs: [{ type: "address" }],
1018
+ outputs: [{ type: "uint256" }],
1019
+ stateMutability: "view"
1020
+ },
1021
+ {
1022
+ type: "function",
1023
+ name: "withdrawalEpoch",
1024
+ inputs: [],
1025
+ outputs: [{ type: "uint128" }],
1026
+ stateMutability: "view"
1027
+ },
1028
+ {
1029
+ type: "function",
1030
+ name: "withdrawalEpochState",
1031
+ inputs: [{ type: "uint256" }],
1032
+ outputs: [
1033
+ { type: "uint128" },
1034
+ { type: "uint128" },
1035
+ { type: "uint128" }
1036
+ ],
1037
+ stateMutability: "view"
1038
+ }
1039
+ ];
1040
+ const VAULTS = [{
1041
+ key: "wethPlpVault",
1042
+ managerKey: "wethPlpVaultManager",
1043
+ underlyingKey: "weth",
1044
+ label: "WETH PLP Vault",
1045
+ strategy: "Passive liquidity provider"
1046
+ }, {
1047
+ key: "usdcPlpVault",
1048
+ managerKey: "usdcPlpVaultManager",
1049
+ underlyingKey: "usdc",
1050
+ label: "USDC PLP Vault",
1051
+ strategy: "Passive liquidity provider"
1052
+ }];
1053
+ const UNDERLYINGS = {
1054
+ 1: {
1055
+ weth: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
1056
+ usdc: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
1057
+ },
1058
+ 11155111: {
1059
+ weth: "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14",
1060
+ usdc: "0xFFFeD8254566B7F800f6D8CDb843ec75AE49B07A"
1061
+ }
1062
+ };
1063
+ function readAddress(value) {
1064
+ return typeof value === "string" && isAddress(value) ? getAddress(value) : void 0;
1065
+ }
1066
+ function getVaultRegistry(chainId) {
1067
+ const dep = getChainDeployment(chainId);
1068
+ if (dep === void 0) return [];
1069
+ return getVaultRegistryFromDeployment(chainId, dep);
1070
+ }
1071
+ function getVaultRegistryFromDeployment(chainId, dep) {
1072
+ const underlyings = UNDERLYINGS[chainId];
1073
+ if (underlyings === void 0) return [];
1074
+ return VAULTS.flatMap((vault) => {
1075
+ const vaultAddress = readAddress(dep.hypovault.vaults[vault.key]);
1076
+ const managerAddress = readAddress(dep.hypovault.managers[vault.managerKey]);
1077
+ const accountantAddress = readAddress(dep.hypovault.core.accountant);
1078
+ const underlyingToken = readAddress(underlyings[vault.underlyingKey]);
1079
+ const panopticPool = readAddress(dep.panoptic.pool.panopticPool);
1080
+ const collateralTracker0 = readAddress(dep.panoptic.pool.collateralTracker0);
1081
+ const collateralTracker1 = readAddress(dep.panoptic.pool.collateralTracker1);
1082
+ if (vaultAddress === void 0 || managerAddress === void 0 || accountantAddress === void 0 || underlyingToken === void 0 || panopticPool === void 0 || collateralTracker0 === void 0 || collateralTracker1 === void 0) return [];
1083
+ return [{
1084
+ chainId,
1085
+ key: vault.key,
1086
+ label: vault.label,
1087
+ strategy: vault.strategy,
1088
+ vaultAddress,
1089
+ managerAddress,
1090
+ accountantAddress,
1091
+ underlyingToken,
1092
+ panopticPool,
1093
+ collateralTracker0,
1094
+ collateralTracker1
1095
+ }];
1096
+ });
1097
+ }
1098
+ function findVaultByAddress(address, chainId) {
1099
+ return getVaultRegistry(chainId).find((vault) => vault.vaultAddress === address);
1100
+ }
1101
+ function getVaultAddressRoles(address, chainId) {
1102
+ const roles = [];
1103
+ const registry = getVaultRegistry(chainId);
1104
+ for (const vault of registry) {
1105
+ if (vault.vaultAddress === address) roles.push(`hypovault:${vault.key}`);
1106
+ if (vault.managerAddress === address) roles.push(`hypovault:${vault.key}:manager`);
1107
+ if (vault.accountantAddress === address) roles.push(`hypovault:${vault.key}:accountant`);
1108
+ if (vault.underlyingToken === address) roles.push(`hypovault:${vault.key}:underlyingToken`);
1109
+ }
1110
+ const dep = getChainDeployment(chainId);
1111
+ if (dep !== void 0) {
1112
+ const core = dep.hypovault.core;
1113
+ const coreRoles = {
1114
+ hypovaultFactory: core.factory,
1115
+ hypovaultImplementation: core.hypoVaultImplementation,
1116
+ hypovaultAccountant: core.accountant,
1117
+ hypovaultRolesAuthority: core.rolesAuthority,
1118
+ hypovaultCollateralTrackerDecoderAndSanitizer: core.collateralTrackerDecoderAndSanitizer
1119
+ };
1120
+ for (const [role, value] of Object.entries(coreRoles)) {
1121
+ const candidate = readAddress(value);
1122
+ if (candidate === address && !roles.includes(role)) roles.push(role);
1123
+ }
1124
+ }
1125
+ return roles;
1126
+ }
1127
+ /**
1128
+ * Batch several HypoVault view reads into a single Multicall3 round-trip (all
1129
+ * resolved at one block, so the returned fields are mutually consistent). Uses
1130
+ * allowFailure:false so any reverting read throws, matching the previous
1131
+ * per-call behavior. Returns results positionally in the order of `calls`.
1132
+ */
1133
+ async function multicallVault(ctx, vaultAddress, calls) {
1134
+ const multicall = ctx.client.multicall;
1135
+ return multicall({
1136
+ contracts: calls.map((call) => ({
1137
+ address: vaultAddress,
1138
+ abi: HYPO_VAULT_READ_ABI,
1139
+ functionName: call.functionName,
1140
+ args: call.args ?? []
1141
+ })),
1142
+ allowFailure: false
1143
+ });
1144
+ }
1145
+ function depositEpochStateTuple([assetsDeposited, sharesReceived, assetsFulfilled]) {
1146
+ return {
1147
+ assetsDeposited,
1148
+ sharesReceived,
1149
+ assetsFulfilled
1150
+ };
1151
+ }
1152
+ function withdrawalEpochStateTuple([sharesWithdrawn, assetsReceived, sharesFulfilled]) {
1153
+ return {
1154
+ sharesWithdrawn,
1155
+ assetsReceived,
1156
+ sharesFulfilled
1157
+ };
1158
+ }
1159
+ function queuedWithdrawalTuple([amount, basis, shouldRedeposit]) {
1160
+ return {
1161
+ amount,
1162
+ basis,
1163
+ shouldRedeposit
1164
+ };
1165
+ }
1166
+ async function fetchVaultSummary(ctx, vaultAddress) {
1167
+ const registry = findVaultByAddress(vaultAddress, ctx.config.chainId);
1168
+ const [name, symbol, decimals, totalSupply, underlyingToken, manager, accountant, feeWallet, performanceFeeBps, depositEpoch, withdrawalEpoch, reservedWithdrawalAssets] = await multicallVault(ctx, vaultAddress, [
1169
+ { functionName: "name" },
1170
+ { functionName: "symbol" },
1171
+ { functionName: "decimals" },
1172
+ { functionName: "totalSupply" },
1173
+ { functionName: "underlyingToken" },
1174
+ { functionName: "manager" },
1175
+ { functionName: "accountant" },
1176
+ { functionName: "feeWallet" },
1177
+ { functionName: "performanceFeeBps" },
1178
+ { functionName: "depositEpoch" },
1179
+ { functionName: "withdrawalEpoch" },
1180
+ { functionName: "reservedWithdrawalAssets" }
1181
+ ]);
1182
+ const [depositEpochState, withdrawalEpochState] = await multicallVault(ctx, vaultAddress, [{
1183
+ functionName: "depositEpochState",
1184
+ args: [depositEpoch]
1185
+ }, {
1186
+ functionName: "withdrawalEpochState",
1187
+ args: [withdrawalEpoch]
1188
+ }]);
1189
+ return {
1190
+ chainId: ctx.config.chainId,
1191
+ vaultAddress,
1192
+ registry,
1193
+ metadata: {
1194
+ name,
1195
+ symbol,
1196
+ decimals
1197
+ },
1198
+ totalSupply,
1199
+ underlyingToken,
1200
+ manager,
1201
+ accountant,
1202
+ feeWallet,
1203
+ performanceFeeBps,
1204
+ depositEpoch,
1205
+ withdrawalEpoch,
1206
+ reservedWithdrawalAssets,
1207
+ currentDepositEpochState: depositEpochStateTuple(depositEpochState),
1208
+ currentWithdrawalEpochState: withdrawalEpochStateTuple(withdrawalEpochState),
1209
+ navAndSharePrice: "Not returned by this v1 tool: HypoVault totalAssets/convertToAssets require managerInput-aware accounting."
1210
+ };
1211
+ }
1212
+ async function fetchVaultAccount(ctx, vaultAddress, account, requestedDepositEpoch, requestedWithdrawalEpoch) {
1213
+ const [currentDepositEpoch, currentWithdrawalEpoch, shareBalance, userBasis] = await multicallVault(ctx, vaultAddress, [
1214
+ { functionName: "depositEpoch" },
1215
+ { functionName: "withdrawalEpoch" },
1216
+ {
1217
+ functionName: "balanceOf",
1218
+ args: [account]
1219
+ },
1220
+ {
1221
+ functionName: "userBasis",
1222
+ args: [account]
1223
+ }
1224
+ ]);
1225
+ const depositEpoch = requestedDepositEpoch ?? currentDepositEpoch;
1226
+ const withdrawalEpoch = requestedWithdrawalEpoch ?? currentWithdrawalEpoch;
1227
+ const [queuedDeposit, queuedWithdrawal, depositEpochState, withdrawalEpochState] = await multicallVault(ctx, vaultAddress, [
1228
+ {
1229
+ functionName: "queuedDeposit",
1230
+ args: [account, depositEpoch]
1231
+ },
1232
+ {
1233
+ functionName: "queuedWithdrawal",
1234
+ args: [account, withdrawalEpoch]
1235
+ },
1236
+ {
1237
+ functionName: "depositEpochState",
1238
+ args: [depositEpoch]
1239
+ },
1240
+ {
1241
+ functionName: "withdrawalEpochState",
1242
+ args: [withdrawalEpoch]
1243
+ }
1244
+ ]);
1245
+ const withdrawal = queuedWithdrawalTuple(queuedWithdrawal);
1246
+ const depositEpochStateResult = depositEpochStateTuple(depositEpochState);
1247
+ const withdrawalEpochStateResult = withdrawalEpochStateTuple(withdrawalEpochState);
1248
+ return {
1249
+ chainId: ctx.config.chainId,
1250
+ vaultAddress,
1251
+ account,
1252
+ currentDepositEpoch,
1253
+ currentWithdrawalEpoch,
1254
+ shareBalance,
1255
+ userBasis,
1256
+ deposit: {
1257
+ epoch: depositEpoch,
1258
+ queuedAssets: queuedDeposit,
1259
+ epochState: depositEpochStateResult,
1260
+ executable: depositEpoch < currentDepositEpoch && queuedDeposit > 0n && depositEpochStateResult.assetsFulfilled > 0n && depositEpochStateResult.sharesReceived > 0n
1261
+ },
1262
+ withdrawal: {
1263
+ epoch: withdrawalEpoch,
1264
+ queued: withdrawal,
1265
+ epochState: withdrawalEpochStateResult,
1266
+ executable: withdrawalEpoch < currentWithdrawalEpoch && withdrawal.amount > 0n && withdrawalEpochStateResult.sharesFulfilled > 0n && withdrawalEpochStateResult.assetsReceived > 0n
1267
+ }
1268
+ };
1269
+ }
1270
+ function registerListVaults(server, ctx) {
1271
+ server.registerTool("list_vaults", {
1272
+ title: "List HypoVaults",
1273
+ description: "Known HypoVault deployments for a chain — vaults, managers, accountant, underlying token, pool, collateral trackers, and strategy labels.",
1274
+ inputSchema: { chainId: z.string().optional().describe("Chain id (decimal string) to look up. Defaults to the configured chain.") }
1275
+ }, async ({ chainId }) => {
1276
+ const target = resolveChainId(chainId, ctx.config.chainId);
1277
+ const vaults = getVaultRegistry(target);
1278
+ return jsonResult({
1279
+ chainId: target,
1280
+ vaults,
1281
+ liveState: target === ctx.config.chainId ? "Use vault_summary or vault_account for live vault state." : `Live state unavailable: this server is configured for chain ${ctx.config.chainId}.`
1282
+ });
1283
+ });
1284
+ }
1285
+ function registerVaultSummary(server, ctx) {
1286
+ server.registerTool("vault_summary", {
1287
+ title: "Summarize a HypoVault",
1288
+ description: "Read live HypoVault state: supply, underlying, manager, accountant, fee wallet, fee bps, epochs, reserved withdrawal assets, and current epoch totals. Does not compute NAV/share price.",
1289
+ inputSchema: { vaultAddress: z.string().describe("The HypoVault address on the configured chain.") }
1290
+ }, async ({ vaultAddress }) => {
1291
+ const vault = parseAccountAddress(vaultAddress);
1292
+ return jsonResult(await fetchVaultSummary(ctx, vault));
1293
+ });
1294
+ }
1295
+ function registerVaultAccount(server, ctx) {
1296
+ server.registerTool("vault_account", {
1297
+ title: "Inspect a HypoVault account",
1298
+ description: "A user's HypoVault share balance, basis, queued deposit, queued withdrawal, redeposit flag, and whether selected epochs appear executable.",
1299
+ inputSchema: {
1300
+ vaultAddress: z.string().describe("The HypoVault address on the configured chain."),
1301
+ address: z.string().describe("The user/account address."),
1302
+ depositEpoch: z.string().optional().describe("Optional deposit epoch (decimal string). Defaults to current depositEpoch."),
1303
+ withdrawalEpoch: z.string().optional().describe("Optional withdrawal epoch (decimal string). Defaults to current withdrawalEpoch.")
1304
+ }
1305
+ }, async ({ vaultAddress, address, depositEpoch, withdrawalEpoch }) => {
1306
+ const vault = parseAccountAddress(vaultAddress);
1307
+ const account = parseAccountAddress(address);
1308
+ return jsonResult(await fetchVaultAccount(ctx, vault, account, parseOptionalDecimalBigInt(depositEpoch, "depositEpoch"), parseOptionalDecimalBigInt(withdrawalEpoch, "withdrawalEpoch")));
1309
+ });
1310
+ }
1311
+ function registerVaultExposure(server, ctx) {
1312
+ server.registerTool("vault_exposure", {
1313
+ title: "Inspect HypoVault Panoptic exposure",
1314
+ description: "Treat a HypoVault as the Panoptic account and return open positions, collateral, greeks, buying power, margin buffer, and liquidation status.",
1315
+ inputSchema: {
1316
+ vaultAddress: z.string().describe("The HypoVault address on the configured chain."),
1317
+ fromBlock: z.string().optional().describe("Optional starting block (decimal string) to bound the position event scan.")
1318
+ }
1319
+ }, async ({ vaultAddress, fromBlock }) => {
1320
+ const vault = parseAccountAddress(vaultAddress);
1321
+ const queryAddress = ctx.deployment.panoptic.v2.panopticQuery;
1322
+ const fromBlockNumber = parseOptionalDecimalBigInt(fromBlock, "fromBlock");
1323
+ const { poolAddress, chainId, tokenIds, storage } = await syncAccountPositions(ctx, vault, fromBlockNumber);
1324
+ const [positions, collateral, greeks, buyingPower, marginBuffer, liquidation] = await Promise.all([
1325
+ getPositions({
1326
+ client: ctx.client,
1327
+ poolAddress,
1328
+ owner: vault,
1329
+ tokenIds
1330
+ }),
1331
+ getAccountCollateral({
1332
+ client: ctx.client,
1333
+ poolAddress,
1334
+ account: vault
1335
+ }),
1336
+ getAccountGreeks({
1337
+ client: ctx.client,
1338
+ chainId,
1339
+ poolAddress,
1340
+ account: vault,
1341
+ storage
1342
+ }),
1343
+ getAccountBuyingPower({
1344
+ client: ctx.client,
1345
+ poolAddress,
1346
+ account: vault,
1347
+ tokenIds
1348
+ }),
1349
+ getMarginBuffer({
1350
+ client: ctx.client,
1351
+ poolAddress,
1352
+ account: vault,
1353
+ tokenIds,
1354
+ queryAddress
1355
+ }),
1356
+ isLiquidatable({
1357
+ client: ctx.client,
1358
+ poolAddress,
1359
+ account: vault,
1360
+ tokenIds
1361
+ })
1362
+ ]);
1363
+ return jsonResult({
1364
+ vaultAddress: vault,
1365
+ registry: findVaultByAddress(vault, ctx.config.chainId),
1366
+ pool: poolAddress,
1367
+ openTokenIds: tokenIds,
1368
+ positions,
1369
+ collateral,
1370
+ greeks,
1371
+ buyingPower,
1372
+ marginBuffer,
1373
+ liquidation
1374
+ });
1375
+ });
1376
+ }
1377
+
724
1378
  //#endregion
725
1379
  //#region src/tools/identifyAddress.ts
726
1380
  function registerIdentifyAddress(server, ctx) {
@@ -745,7 +1399,7 @@ function registerIdentifyAddress(server, ctx) {
745
1399
  poolManager: dep.infrastructure.poolManager,
746
1400
  stateView: dep.infrastructure.stateView
747
1401
  };
748
- const roles = Object.entries(candidates).filter(([, addr]) => isAddress(addr) && getAddress(addr) === needle).map(([role]) => role);
1402
+ const roles = [...Object.entries(candidates).filter(([, addr]) => isAddress(addr) && getAddress(addr) === needle).map(([role]) => role), ...getVaultAddressRoles(needle, target)];
749
1403
  return jsonResult({
750
1404
  address: needle,
751
1405
  chainId: target,
@@ -768,15 +1422,9 @@ function registerLiquidityMap(server, ctx) {
768
1422
  }, async ({ startTick, nTicks }) => {
769
1423
  const poolAddress = ctx.deployment.panoptic.pool.panopticPool;
770
1424
  const queryAddress = ctx.deployment.panoptic.v2.panopticQuery;
771
- const chainId = BigInt(ctx.config.chainId);
772
- const count = nTicks !== void 0 && nTicks.trim() !== "" ? BigInt(nTicks) : DEFAULT_N_TICKS;
773
- const pool = await getPool({
774
- client: ctx.client,
775
- poolAddress,
776
- chainId,
777
- stateViewAddress: ctx.deployment.infrastructure.stateView
778
- });
779
- const start = startTick !== void 0 && startTick.trim() !== "" ? BigInt(startTick) : pool.currentTick - count / 2n * pool.tickSpacing;
1425
+ const count = parseOptionalDecimalBigInt(nTicks, "nTicks") ?? DEFAULT_N_TICKS;
1426
+ const pool = await getCachedPool(ctx);
1427
+ const start = parseOptionalDecimalBigInt(startTick, "startTick", { signed: true }) ?? pool.currentTick - count / 2n * pool.tickSpacing;
780
1428
  const liquidities = await getPoolLiquidities({
781
1429
  client: ctx.client,
782
1430
  poolAddress,
@@ -834,14 +1482,14 @@ function registerListPools(server, ctx) {
834
1482
  //#region src/tools/positionScenario.ts
835
1483
  const DEFAULT_SPAN = 2500n;
836
1484
  const DEFAULT_POINTS = 25;
837
- function clampTick(tick) {
838
- if (tick < MIN_TICK) return MIN_TICK;
839
- if (tick > MAX_TICK) return MAX_TICK;
840
- return tick;
841
- }
842
- /** Human token1-per-token0 price at a tick (display float; uses token decimals). */
1485
+ const PRICE_PRECISION = 18n;
1486
+ /**
1487
+ * Human token1-per-token0 price at a tick, as a decimal string. Uses the SDK's
1488
+ * exact BigInt tick math (no Math.pow float precision loss / over/underflow at
1489
+ * extreme ticks) and applies the token-decimal scale.
1490
+ */
843
1491
  function priceAtTick(tick, decimals0, decimals1) {
844
- return Math.pow(1.0001, Number(tick)) * 10 ** (Number(decimals0) - Number(decimals1));
1492
+ return tickToPriceDecimalScaled(tick, decimals0, decimals1, PRICE_PRECISION);
845
1493
  }
846
1494
  function registerPositionScenario(server, ctx) {
847
1495
  server.registerTool("position_scenario", {
@@ -856,17 +1504,11 @@ function registerPositionScenario(server, ctx) {
856
1504
  fromBlock: z.string().optional().describe("Optional starting block (decimal string) to bound the position event scan.")
857
1505
  }
858
1506
  }, async ({ address, startTick, endTick, points, includePendingPremium, fromBlock }) => {
859
- if (!isAddress(address)) throw new Error(`Invalid address: ${address}`);
860
- const account = getAddress(address);
1507
+ const account = parseAccountAddress(address);
861
1508
  const poolAddress = ctx.deployment.panoptic.pool.panopticPool;
862
1509
  const queryAddress = ctx.deployment.panoptic.v2.panopticQuery;
863
- const pool = await getPool({
864
- client: ctx.client,
865
- poolAddress,
866
- chainId: BigInt(ctx.config.chainId),
867
- stateViewAddress: ctx.deployment.infrastructure.stateView
868
- });
869
- const { tokenIds } = await syncAccountPositions(ctx, account, fromBlock ? BigInt(fromBlock) : void 0);
1510
+ const pool = await getCachedPool(ctx);
1511
+ const { tokenIds } = await syncAccountPositions(ctx, account, parseOptionalDecimalBigInt(fromBlock, "fromBlock"));
870
1512
  if (tokenIds.length === 0) return jsonResult({
871
1513
  account,
872
1514
  pool: poolAddress,
@@ -875,8 +1517,10 @@ function registerPositionScenario(server, ctx) {
875
1517
  note: "Account has no open positions on this pool; nothing to evaluate."
876
1518
  });
877
1519
  const n = points ?? DEFAULT_POINTS;
878
- const start = clampTick(startTick !== void 0 ? BigInt(startTick) : pool.currentTick - DEFAULT_SPAN);
879
- const end = clampTick(endTick !== void 0 ? BigInt(endTick) : pool.currentTick + DEFAULT_SPAN);
1520
+ const startTickValue = parseOptionalDecimalBigInt(startTick, "startTick", { signed: true });
1521
+ const endTickValue = parseOptionalDecimalBigInt(endTick, "endTick", { signed: true });
1522
+ const start = clampTick(startTickValue ?? pool.currentTick - DEFAULT_SPAN);
1523
+ const end = clampTick(endTickValue ?? pool.currentTick + DEFAULT_SPAN);
880
1524
  const span = Number(end - start);
881
1525
  const atTicks = [];
882
1526
  for (let i = 0; i < n; i++) atTicks.push(clampTick(start + BigInt(Math.round(i * span / (n - 1)))));
@@ -928,23 +1572,12 @@ function registerPreviewPosition(server, ctx) {
928
1572
  fromBlock: z.string().optional().describe("Optional starting block (decimal string) to bound the position event scan.")
929
1573
  }
930
1574
  }, async ({ address, tokenId, positionSize, tickLimitLow, tickLimitHigh, swapAtMint, usePremiaAsCollateral, fromBlock }) => {
931
- if (!isAddress(address)) throw new Error(`Invalid address: ${address}`);
932
- const account = getAddress(address);
933
- let id;
934
- let size;
935
- try {
936
- id = BigInt(tokenId.trim());
937
- } catch {
938
- throw new Error(`Invalid tokenId (expected a 0x-hex or decimal string): ${tokenId}`);
939
- }
940
- try {
941
- size = BigInt(positionSize);
942
- } catch {
943
- throw new Error(`Invalid positionSize (expected a decimal string): ${positionSize}`);
944
- }
1575
+ const account = parseAccountAddress(address);
1576
+ const id = parseTokenId(tokenId);
1577
+ const size = parseDecimalBigInt(positionSize, "positionSize");
945
1578
  const poolAddress = ctx.deployment.panoptic.pool.panopticPool;
946
1579
  const queryAddress = ctx.deployment.panoptic.v2.panopticQuery;
947
- const { tokenIds } = await syncAccountPositions(ctx, account, fromBlock ? BigInt(fromBlock) : void 0);
1580
+ const { tokenIds } = await syncAccountPositions(ctx, account, parseOptionalDecimalBigInt(fromBlock, "fromBlock"));
948
1581
  const preview = await getOpenPositionPreview({
949
1582
  client: ctx.client,
950
1583
  poolAddress,
@@ -953,8 +1586,8 @@ function registerPreviewPosition(server, ctx) {
953
1586
  tokenId: id,
954
1587
  positionSize: size,
955
1588
  queryAddress,
956
- tickLimitLow: tickLimitLow !== void 0 ? BigInt(tickLimitLow) : MIN_TICK,
957
- tickLimitHigh: tickLimitHigh !== void 0 ? BigInt(tickLimitHigh) : MAX_TICK,
1589
+ tickLimitLow: parseOptionalDecimalBigInt(tickLimitLow, "tickLimitLow", { signed: true }) ?? MIN_TICK,
1590
+ tickLimitHigh: parseOptionalDecimalBigInt(tickLimitHigh, "tickLimitHigh", { signed: true }) ?? MAX_TICK,
958
1591
  swapAtMint: swapAtMint ?? false,
959
1592
  usePremiaAsCollateral: usePremiaAsCollateral ?? false
960
1593
  });
@@ -982,34 +1615,30 @@ function registerPreviewPosition(server, ctx) {
982
1615
 
983
1616
  //#endregion
984
1617
  //#region src/tools/suggestDeltaHedge.ts
985
- /** Align a tick down to the nearest multiple of tickSpacing (matches buildUniqueLoan). */
986
- function alignStrike(tick, tickSpacing) {
987
- const mod = (tick % tickSpacing + tickSpacing) % tickSpacing;
988
- return tick - mod;
989
- }
990
1618
  function registerSuggestDeltaHedge(server, ctx) {
991
1619
  server.registerTool("suggest_delta_hedge", {
992
1620
  title: "Suggest a delta-neutral hedge",
993
1621
  description: "Compute an account's net delta on the configured pool (option positions + deposited collateral) and suggest a Panoptic width=0 loan/credit leg that neutralizes it. A width=0 leg carries pure, spot-like directional delta (delta = ±size, no gamma): a LOAN borrowing the asset adds short delta, a CREDIT lending the asset adds long delta. If the account is net LONG the asset, the tool suggests a loan; if net SHORT, a credit. Returns an inspectable explicit-leg suggestion (tokenId + size) that can be handed to build_position; it does not execute anything.",
994
1622
  inputSchema: {
995
1623
  address: z.string().describe("The account address."),
996
- assetIndex: z.union([z.literal(0), z.literal(1)]).optional().describe("Which token is the directional asset (0 or 1). Defaults to inferring the non-stablecoin token from pool symbols."),
1624
+ assetIndex: z.union([z.literal(0), z.literal(1)]).optional().describe("Reporting frame for the aggregate net delta: 0 = token0 units, 1 = token1 units. This is a denomination choice (it overrides each leg's own asset when aggregating), NOT a canonical pool asset. If omitted and no session preference is set (via configure_session), the tool asks the user which token to use instead of guessing."),
997
1625
  fromBlock: z.string().optional().describe("Optional starting block (decimal string) to bound the position event scan.")
998
1626
  }
999
1627
  }, async ({ address, assetIndex, fromBlock }) => {
1000
- if (!isAddress(address)) throw new Error(`Invalid address: ${address}`);
1001
- const account = getAddress(address);
1628
+ const account = parseAccountAddress(address);
1002
1629
  const poolAddress = ctx.deployment.panoptic.pool.panopticPool;
1003
1630
  const chainId = BigInt(ctx.config.chainId);
1004
- const pool = await getPool({
1005
- client: ctx.client,
1006
- poolAddress,
1007
- chainId,
1008
- stateViewAddress: ctx.deployment.infrastructure.stateView
1631
+ const pool = await getCachedPool(ctx);
1632
+ const frameResolution = resolveDeltaFrame(pool, assetIndex, ctx.session.preferredAssetIndex);
1633
+ if (frameResolution.needsUserChoice) return assetPreferenceRequired({
1634
+ token0: pool.collateralTracker0.symbol,
1635
+ token1: pool.collateralTracker1.symbol,
1636
+ suggestedAssetIndex: frameResolution.frame,
1637
+ suggestionReason: frameResolution.reason
1009
1638
  });
1010
- const { asset, inferred: assetInferred } = inferAssetIndex(pool, assetIndex);
1639
+ const { frame: asset, source: assetFrameSource, reason: assetFrameReason } = frameResolution;
1011
1640
  const assetSymbol = asset === 0n ? pool.collateralTracker0.symbol : pool.collateralTracker1.symbol;
1012
- const { tokenIds, storage } = await syncAccountPositions(ctx, account, fromBlock ? BigInt(fromBlock) : void 0);
1641
+ const { tokenIds, storage } = await syncAccountPositions(ctx, account, parseOptionalDecimalBigInt(fromBlock, "fromBlock"));
1013
1642
  const greeks = await getAccountGreeks({
1014
1643
  client: ctx.client,
1015
1644
  chainId,
@@ -1025,12 +1654,17 @@ function registerSuggestDeltaHedge(server, ctx) {
1025
1654
  pool: poolAddress,
1026
1655
  assetIndex: asset,
1027
1656
  assetSymbol,
1028
- assetInferred,
1657
+ deltaFrame: {
1658
+ assetIndex: asset,
1659
+ symbol: assetSymbol,
1660
+ source: assetFrameSource,
1661
+ reason: assetFrameReason
1662
+ },
1029
1663
  openTokenIds: tokenIds,
1030
1664
  positionsDelta: greeks.positionsDelta,
1031
1665
  collateralDelta: greeks.collateralDelta,
1032
1666
  netDelta,
1033
- netDeltaNote: `Net delta is expressed in ${assetSymbol} smallest units (positive = net long the asset).`
1667
+ netDeltaNote: `Net delta is expressed in ${assetSymbol} smallest units (positive = net long ${assetSymbol}). ${DELTA_FRAME_NOTE}`
1034
1668
  };
1035
1669
  if (netDelta === 0n) return jsonResult({
1036
1670
  ...facts,
@@ -1039,7 +1673,7 @@ function registerSuggestDeltaHedge(server, ctx) {
1039
1673
  });
1040
1674
  const isLoan = netDelta > 0n;
1041
1675
  const size = netDelta > 0n ? netDelta : -netDelta;
1042
- const strike = alignStrike(pool.currentTick, pool.tickSpacing);
1676
+ const strike = roundToTickSpacing(pool.currentTick, pool.tickSpacing);
1043
1677
  const builder = createTokenIdBuilder(pool.poolId);
1044
1678
  const legConfig = {
1045
1679
  asset,
@@ -1084,20 +1718,25 @@ function registerTradeHistory(server, ctx) {
1084
1718
  }, async ({ address, fromBlock, toBlock }) => {
1085
1719
  const account = parseAccountAddress(address);
1086
1720
  const poolAddress = ctx.deployment.panoptic.pool.panopticPool;
1087
- const hasFrom = fromBlock !== void 0 && fromBlock.trim() !== "";
1088
- const hasTo = toBlock !== void 0 && toBlock.trim() !== "";
1089
- const start = hasFrom ? BigInt(fromBlock) : await getPoolDeploymentBlock(ctx.client, poolAddress) ?? 0n;
1721
+ const fromBlockArg = parseOptionalDecimalBigInt(fromBlock, "fromBlock");
1722
+ const toBlockArg = parseOptionalDecimalBigInt(toBlock, "toBlock");
1723
+ const deploymentBlock = fromBlockArg !== void 0 ? void 0 : await getPoolDeploymentBlock(ctx.client, poolAddress);
1724
+ const start = fromBlockArg ?? deploymentBlock ?? 0n;
1725
+ const fromBlockSource = fromBlockArg !== void 0 ? "caller" : deploymentBlock !== void 0 && deploymentBlock !== null ? "pool-deployment-block" : "fallback-zero";
1090
1726
  const history = await getAccountHistory({
1091
1727
  client: ctx.client,
1092
1728
  poolAddress,
1093
1729
  account,
1094
1730
  fromBlock: start,
1095
- toBlock: hasTo ? BigInt(toBlock) : void 0
1731
+ toBlock: toBlockArg
1096
1732
  });
1097
1733
  return jsonResult({
1098
1734
  account,
1099
1735
  pool: poolAddress,
1100
1736
  fromBlock: start,
1737
+ fromBlockSource,
1738
+ toBlock: toBlockArg,
1739
+ ...fromBlockSource === "fallback-zero" ? { scanWarning: "Pool deployment block could not be resolved; scanning from block 0. History may be incomplete or truncated by the RPC provider. Pass fromBlock to bound the scan." } : {},
1101
1740
  tradeCount: history.trades.length,
1102
1741
  trades: history.trades,
1103
1742
  _meta: history._meta
@@ -1120,6 +1759,7 @@ function createServer(ctx) {
1120
1759
  name: SERVER_NAME,
1121
1760
  version: SERVER_VERSION
1122
1761
  });
1762
+ registerConfigureSession(server, ctx);
1123
1763
  registerListPools(server, ctx);
1124
1764
  registerExplainPosition(server, ctx);
1125
1765
  registerIdentifyAddress(server, ctx);
@@ -1133,6 +1773,10 @@ function createServer(ctx) {
1133
1773
  registerPositionScenario(server, ctx);
1134
1774
  registerHedgeParams(server, ctx);
1135
1775
  registerTradeHistory(server, ctx);
1776
+ registerListVaults(server, ctx);
1777
+ registerVaultSummary(server, ctx);
1778
+ registerVaultAccount(server, ctx);
1779
+ registerVaultExposure(server, ctx);
1136
1780
  return server;
1137
1781
  }
1138
1782