diff --git a/README.md b/README.md index 753409c..7262b9b 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,12 @@ This starts an HTTP server with: - `POST /mcp` — MCP endpoint (Streamable HTTP with SSE) - `GET /health` — Health check +Each client gets its own session: `initialize` returns an `Mcp-Session-Id` that +subsequent requests must send back. A client that restarts can simply +`initialize` again — existing sessions are unaffected and the server does not +need restarting. Bind address and port come from `HOST` and `PORT` (`MCP_PORT` +is accepted as an alias for `PORT`). + ## Configuration > **Note:** `BLOCKFROST_API_KEY` is required for write operations (transaction building). Read-only tools work without it. Get a free key at [blockfrost.io](https://blockfrost.io/). @@ -307,8 +313,8 @@ For any client that supports MCP over stdio, point it to the `npx @indigoprotoco | Tool | Description | Parameters | | ----------------- | ---------------------------------------------------- | ---------------------------------- | | `get_assets` | Get all Indigo iAssets with prices and interest data | None | -| `get_asset` | Get details for a specific iAsset | `asset`: iUSD, iBTC, iETH, iSOL, iEUR, or iJPY | -| `get_asset_price` | Get the current price for a specific iAsset | `asset`: iUSD, iBTC, iETH, iSOL, iEUR, or iJPY | +| `get_asset` | Get details for a specific iAsset | `asset`: iUSD, iBTC, iETH, iSOL, iEUR, iJPY, or iADA | +| `get_asset_price` | Get the current price for a specific iAsset | `asset`: iUSD, iBTC, iETH, iSOL, iEUR, iJPY, or iADA | | `get_ada_price` | Get the current ADA price in USD | None | | `get_indy_price` | Get the current INDY token price in ADA and USD | None | @@ -323,9 +329,19 @@ For any client that supports MCP over stdio, point it to the `npx @indigoprotoco ### CDP Write Tools +> **Pyth-priced iAssets have a submission deadline.** Every iAsset is currently +> priced through Pyth, and the on-chain feed validator only accepts a transaction +> for **280 seconds** after the price update it embeds. Price-dependent write +> tools (`open_cdp`, `withdraw_cdp`, `mint_cdp`, `redeem_cdp`, `freeze_cdp`, +> `leverage_cdp`, `redeem_rob`) therefore return a `summary.pyth` block with the +> price, its timestamp and a `submitBefore` deadline. If signing takes longer +> than that — a hardware wallet, or a human approving in a browser — rebuild the +> transaction rather than submitting a stale one. A price update that has already +> expired is rejected at build time with a retry hint. + | Tool | Description | Parameters | | -------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `open_cdp` | Open a new CDP position (returns unsigned CBOR tx) | `address`: bech32 address; `asset`: iUSD, iBTC, iETH, iSOL, iEUR, or iJPY; `collateralAmount`: lovelace; `mintAmount`: iAsset smallest unit | +| `open_cdp` | Open a new CDP position (returns unsigned CBOR tx) | `address`: bech32 address; `asset`: iUSD, iBTC, iETH, iSOL, iEUR, iJPY, or iADA; `collateralAmount`: lovelace; `mintAmount`: iAsset smallest unit | | `deposit_cdp` | Deposit additional collateral into a CDP | `address`: bech32 address; `asset`: iAsset; `cdpTxHash`: CDP UTxO tx hash; `cdpOutputIndex`: output index; `amount`: lovelace | | `withdraw_cdp` | Withdraw collateral from a CDP | `address`: bech32 address; `asset`: iAsset; `cdpTxHash`: CDP UTxO tx hash; `cdpOutputIndex`: output index; `amount`: lovelace | | `close_cdp` | Close a CDP and reclaim collateral | `address`: bech32 address; `asset`: iAsset; `cdpTxHash`: CDP UTxO tx hash; `cdpOutputIndex`: output index | @@ -334,8 +350,8 @@ For any client that supports MCP over stdio, point it to the `npx @indigoprotoco | Tool | Description | Parameters | | ---------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mint_cdp` | Mint additional iAssets from an existing CDP (increases debt) | `address`: bech32 address; `asset`: iUSD, iBTC, iETH, iSOL, iEUR, or iJPY; `cdpTxHash`: CDP UTxO tx hash; `cdpOutputIndex`: CDP UTxO output index; `amount`: iAsset amount in smallest unit | -| `burn_cdp` | Burn iAssets to reduce CDP debt | `address`: bech32 address; `asset`: iUSD, iBTC, iETH, iSOL, iEUR, or iJPY; `cdpTxHash`: CDP UTxO tx hash; `cdpOutputIndex`: CDP UTxO output index; `amount`: iAsset amount in smallest unit | +| `mint_cdp` | Mint additional iAssets from an existing CDP (increases debt) | `address`: bech32 address; `asset`: iUSD, iBTC, iETH, iSOL, iEUR, iJPY, or iADA; `cdpTxHash`: CDP UTxO tx hash; `cdpOutputIndex`: CDP UTxO output index; `amount`: iAsset amount in smallest unit | +| `burn_cdp` | Burn iAssets to reduce CDP debt | `address`: bech32 address; `asset`: iUSD, iBTC, iETH, iSOL, iEUR, iJPY, or iADA; `cdpTxHash`: CDP UTxO tx hash; `cdpOutputIndex`: CDP UTxO output index; `amount`: iAsset amount in smallest unit | ### CDP Liquidation & Redemption Tools @@ -357,7 +373,7 @@ For any client that supports MCP over stdio, point it to the `npx @indigoprotoco | Tool | Description | Parameters | | ----------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------- | | `get_stability_pools` | Get the latest stability pool state for each iAsset | None | -| `get_stability_pool_accounts` | Get all open stability pool accounts, optionally filtered by iAsset | `asset?`: iUSD, iBTC, iETH, iSOL, iEUR, or iJPY | +| `get_stability_pool_accounts` | Get all open stability pool accounts, optionally filtered by iAsset | `asset?`: iUSD, iBTC, iETH, iSOL, iEUR, iJPY, or iADA | | `get_sp_account_by_owner` | Get stability pool accounts for specific owners | `owners`: array of payment key hashes or bech32 addresses | ### Staking Tools @@ -414,7 +430,7 @@ For any client that supports MCP over stdio, point it to the `npx @indigoprotoco | ----------------------- | --------------------------------------------- | --------------------------------------------------------------- | | `get_order_book` | Get open ROB (redemption order book) positions | `asset?`: iAsset filter; `owners?`: array of payment key hashes | | `get_redemption_orders` | Get executed redemption orders | `asset?`: iAsset filter; `limit?`: max records (default 100) | -| `get_redemption_queue` | Get open ROB order-book entries for an iAsset | `asset`: iUSD, iBTC, iETH, iSOL, iEUR, or iJPY | +| `get_redemption_queue` | Get open ROB order-book entries for an iAsset | `asset`: iUSD, iBTC, iETH, iSOL, iEUR, iJPY, or iADA | ### ROB Write Tools @@ -457,7 +473,7 @@ For any client that supports MCP over stdio, point it to the `npx @indigoprotoco | Tool | Description | Parameters | | ------------------- | ----------------------------------------------------------------- | ----------------------------------- | | `get_oracle_price` | On-chain price for an iAsset (OracleNft / Delisted / Pyth) | `asset` | -| `get_pyth_price` | Read the Pyth price-feed config for an iAsset | `asset` | +| `get_pyth_price` | Current Pyth price for an iAsset, plus its on-chain feed config | `asset` | | `feed_price_oracle` | Feed a new price to an OracleNft-backed price oracle (admin) | `address`, `oracleTxHash`, price | ### Stableswap Tools (v3) @@ -476,7 +492,8 @@ For any client that supports MCP over stdio, point it to the `npx @indigoprotoco | `BLOCKFROST_API_KEY` | For write ops | — | Blockfrost project ID for transaction building | | `CARDANO_NETWORK` | No | `mainnet` | Cardano network: `mainnet`, `preprod`, or `preview` | | `MCP_TRANSPORT` | No | `stdio` | Transport mode: `stdio` or `http` | -| `PORT` | No | `3000` | HTTP server port (only used when `MCP_TRANSPORT=http`) | +| `PORT` | No | `3000` | HTTP server port (only used when `MCP_TRANSPORT=http`); `MCP_PORT` is accepted as an alias | +| `HOST` | No | `0.0.0.0` | HTTP bind address (only used when `MCP_TRANSPORT=http`); set `127.0.0.1` to bind locally only | | `X402_PRIVATE_KEY` | No | — | EVM private key (`0x…`) of the payer wallet — enables auto-payment via split flow | | `PAYMENT_SERVER` | No | `https://mcp.openmm.io` | Settlement worker / proxy URL | | `X402_TESTNET` | No | `false` | Use Base Sepolia testnet | @@ -507,7 +524,7 @@ When connected to an LLM agent, you can ask natural language questions like: ### Prerequisites -- Node.js >= 18 +- Node.js >= 20 (the bundled `undici` requires the `File` global, added in Node 20) - npm ### Setup diff --git a/package.json b/package.json index f5019e4..82a0ec4 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "smithery:publish": "bash scripts/smithery-build.sh && npx smithery mcp publish --name indigoprotocol/indigo-mcp --from-build .smithery/stdio" }, "engines": { - "node": ">=18" + "node": ">=20" }, "repository": { "type": "git", diff --git a/src/server.ts b/src/server.ts index d70a9c2..6891154 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,14 +1,19 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { createServer as createHttpServer } from 'node:http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import { randomUUID } from 'node:crypto'; import { registerTools } from './tools/index.js'; import { registerResources } from './resources/index.js'; import { applyPaymentGate } from './payment.js'; +import packageJson from '../package.json' with { type: 'json' }; const SERVER_NAME = 'indigo-mcp'; -const SERVER_VERSION = '0.2.0'; +// Read from package.json so `initialize` and /health cannot drift from the +// published version, as they had (advertising 0.2.0 against a 0.3.0 package). +const SERVER_VERSION = packageJson.version; export function createServer(): McpServer { const server = new McpServer({ @@ -24,10 +29,91 @@ export function createServer(): McpServer { return server; } -async function startHttpServer(port: number): Promise { +/** Read and parse a JSON request body. Returns `undefined` for an empty body. */ +async function readJsonBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + if (chunks.length === 0) return undefined; + return JSON.parse(Buffer.concat(chunks).toString('utf8')); +} + +function sendJsonRpcError( + res: ServerResponse, + status: number, + code: number, + message: string +): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id: null })); +} + +async function startHttpServer(port: number, host: string): Promise { process.stderr.write(`Indigo MCP starting HTTP server...\n`); - // Start HTTP server immediately so Fly.io sees the port open + /** + * One transport (and one MCP server) per client session. A client that + * reconnects — after a restart, say — simply sends a fresh `initialize` and + * gets a new session, rather than being locked out until this process is + * restarted. + */ + const transports = new Map(); + + async function handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise { + let body: unknown; + if (req.method === 'POST') { + try { + body = await readJsonBody(req); + } catch { + sendJsonRpcError(res, 400, -32700, 'Parse error: request body is not valid JSON'); + return; + } + } + + const sessionId = req.headers['mcp-session-id']; + const existing = typeof sessionId === 'string' ? transports.get(sessionId) : undefined; + + if (existing) { + await existing.handleRequest(req, res, body); + return; + } + + // No live session for this request. An `initialize` always starts a fresh + // one — including when the client replayed a session id this process no + // longer knows about, so a restarted client can always get back in. + if (req.method !== 'POST' || !isInitializeRequest(body)) { + if (typeof sessionId === 'string') { + sendJsonRpcError( + res, + 404, + -32001, + `Session not found: ${sessionId}. Send an initialize request to start a new session.` + ); + } else { + sendJsonRpcError(res, 400, -32000, 'Bad Request: Mcp-Session-Id header is required'); + } + return; + } + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + transports.set(id, transport); + }, + onsessionclosed: (id) => { + transports.delete(id); + }, + }); + transport.onclose = () => { + if (transport.sessionId) transports.delete(transport.sessionId); + }; + + const server = createServer(); + await server.connect(transport); + await transport.handleRequest(req, res, body); + } + const httpServer = createHttpServer((req, res) => { const url = new URL(req.url ?? '/', `http://${req.headers.host}`); @@ -38,12 +124,14 @@ async function startHttpServer(port: number): Promise { } if (url.pathname === '/mcp') { - if (!transport) { - res.writeHead(503, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Server initializing' })); - return; - } - transport.handleRequest(req, res); + handleMcpRequest(req, res).catch((error: unknown) => { + process.stderr.write(`Indigo MCP request error: ${error}\n`); + if (!res.headersSent) { + sendJsonRpcError(res, 500, -32603, 'Internal server error'); + } else { + res.end(); + } + }); return; } @@ -51,18 +139,8 @@ async function startHttpServer(port: number): Promise { res.end('Not found'); }); - let transport: StreamableHTTPServerTransport | null = null; - - httpServer.listen(port, '0.0.0.0', async () => { - process.stderr.write(`Indigo MCP HTTP server listening on 0.0.0.0:${port}\n`); - - // Initialize MCP server after port is open - const server = createServer(); - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - }); - await server.connect(transport); - process.stderr.write(`Indigo MCP server ready\n`); + httpServer.listen(port, host, () => { + process.stderr.write(`Indigo MCP HTTP server listening on ${host}:${port}/mcp\n`); }); } @@ -76,8 +154,9 @@ async function main(): Promise { const mode = process.env.MCP_TRANSPORT ?? 'stdio'; if (mode === 'http') { - const port = parseInt(process.env.PORT ?? '3000', 10); - await startHttpServer(port); + const port = parseInt(process.env.PORT ?? process.env.MCP_PORT ?? '3000', 10); + const host = process.env.HOST ?? '0.0.0.0'; + await startHttpServer(port, host); } else { await startStdioServer(); } diff --git a/src/tests/unit/tools/pyth-write-wiring.test.ts b/src/tests/unit/tools/pyth-write-wiring.test.ts new file mode 100644 index 0000000..ed135a8 --- /dev/null +++ b/src/tests/unit/tools/pyth-write-wiring.test.ts @@ -0,0 +1,240 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +/** + * Guards the wiring that unblocked Pyth-priced iAssets: every price-dependent + * write tool must forward the signed Pyth message and the Pyth state OutRef to + * the SDK builder, in the trailing argument positions the builders expect, and + * must pass `undefined` for the price-oracle OutRef while doing so. + */ + +vi.mock('@indigo-labs/indigo-sdk', () => + Object.fromEntries( + [ + 'openCdp', + 'depositCdp', + 'withdrawCdp', + 'closeCdp', + 'mintCdp', + 'burnCdp', + 'liquidateCdp', + 'redeemCdp', + 'freezeCdp', + 'mergeCdps', + 'leverageCdpWithRob', + 'openRob', + 'cancelRob', + 'adjustRob', + 'claimRob', + 'redeemRob', + ].map((name) => [name, vi.fn(() => Promise.resolve('tx-builder'))]) + ) +); + +vi.mock('../../../utils/sdk-config.js', () => ({ + getSystemParams: vi.fn(() => Promise.resolve({ params: true })), +})); + +const utxo = (txHash: string) => ({ txHash, outputIndex: 0 }); +const oref = (txHash: string) => ({ txHash, outputIndex: 0 }); + +vi.mock('../../../utils/v3-finders.js', () => ({ + ADA_COLLATERAL: { currencySymbol: new Uint8Array(), tokenName: new Uint8Array() }, + toOutRef: (u: { txHash: string; outputIndex: number }) => ({ + txHash: u.txHash, + outputIndex: u.outputIndex, + }), + findIAsset: vi.fn(() => Promise.resolve({ utxo: utxo('iasset'), datum: {} })), + findCollateralAsset: vi.fn(() => Promise.resolve({ utxo: utxo('collateral'), datum: {} })), + findCdpCreatorOref: vi.fn(() => Promise.resolve(oref('cdp-creator'))), + findInterestOracleOref: vi.fn(() => Promise.resolve(oref('interest-oracle'))), + findInterestCollectorOref: vi.fn(() => Promise.resolve(oref('interest-collector'))), + findTreasuryOref: vi.fn(() => Promise.resolve(oref('treasury'))), + findGov: vi.fn(() => Promise.resolve({ utxo: utxo('gov'), datum: {} })), + findStabilityPool: vi.fn(() => Promise.resolve({ utxo: utxo('stability-pool'), datum: {} })), + findAllRobs: vi.fn(() => Promise.resolve([[utxo('rob'), {}]])), +})); + +const PYTH_MESSAGE = 'b9011a82deadbeef'; +const PYTH_STATE_OREF = { txHash: 'pyth-state', outputIndex: 0 }; +const PYTH_SUMMARY = { + price: '4.739746222611', + priceTimestamp: '2026-08-21T09:13:01.000Z', + submitBefore: '2026-08-21T09:17:41.000Z', +}; + +vi.mock('../../../utils/pyth.js', () => ({ + resolvePriceSource: vi.fn(() => + Promise.resolve({ + priceOracleOref: undefined, + pythMessage: PYTH_MESSAGE, + pythStateOref: PYTH_STATE_OREF, + pythFeed: { price: PYTH_SUMMARY.price }, + }) + ), + pythSummary: vi.fn(() => PYTH_SUMMARY), +})); + +// Run the tool's build closure the way the real buildUnsignedTx does, so the +// Pyth summary the closure writes into the context reaches the result. +vi.mock('../../../utils/tx-builder.js', () => ({ + buildUnsignedTx: vi.fn( + async ( + _address: string, + buildFn: (lucid: unknown, ctx: { pyth?: unknown }) => Promise, + summary: Record + ) => { + const ctx: { pyth?: unknown } = {}; + await buildFn({ currentSlot: () => 123 }, ctx); + return { + unsignedTx: 'cbor', + txHash: 'hash', + fee: '0', + summary: ctx.pyth ? { ...summary, pyth: ctx.pyth } : summary, + }; + } + ), +})); + +import * as sdk from '@indigo-labs/indigo-sdk'; +import { registerCdpWriteTools } from '../../../tools/cdp-write-tools.js'; +import { registerCdpMintBurnTools } from '../../../tools/cdp-mint-burn-tools.js'; +import { registerCdpLiquidationTools } from '../../../tools/cdp-liquidation-tools.js'; +import { registerLeverageCdpTools } from '../../../tools/leverage-cdp-tools.js'; +import { registerRobWriteTools } from '../../../tools/rob-write-tools.js'; + +type ToolHandler = (args: Record) => Promise<{ + content: { type: string; text: string }[]; + isError?: boolean; +}>; + +function createTestServer() { + const tools = new Map(); + const server = { + tool: (name: string, _desc: string, _schema: unknown, handler: ToolHandler) => { + tools.set(name, handler); + }, + } as unknown as McpServer; + return { server, tools }; +} + +const ADDRESS = 'addr1qxy'; +const CDP = { cdpTxHash: 'cdp', cdpOutputIndex: 0 }; + +/** + * Each case: the tool, the arguments it takes, the SDK builder it delegates to, + * and the argument index where `pythMessage` lands (the state OutRef follows). + */ +const CASES = [ + { + tool: 'open_cdp', + args: { address: ADDRESS, asset: 'iUSD', collateralAmount: '100000000', mintAmount: '1000000' }, + builder: 'openCdp', + pythIndex: 11, + priceOracleIndex: 6, + }, + { + tool: 'withdraw_cdp', + args: { address: ADDRESS, asset: 'iUSD', ...CDP, amount: '5000000' }, + builder: 'withdrawCdp', + pythIndex: 11, + priceOracleIndex: 4, + }, + { + tool: 'mint_cdp', + args: { address: ADDRESS, asset: 'iUSD', ...CDP, amount: '1000000' }, + builder: 'mintCdp', + pythIndex: 11, + priceOracleIndex: 4, + }, + { + tool: 'redeem_cdp', + args: { address: ADDRESS, asset: 'iUSD', ...CDP, amount: '1000000' }, + builder: 'redeemCdp', + pythIndex: 12, + priceOracleIndex: 4, + }, + { + tool: 'freeze_cdp', + args: { address: ADDRESS, asset: 'iUSD', ...CDP }, + builder: 'freezeCdp', + pythIndex: 8, + priceOracleIndex: 3, + }, + { + tool: 'leverage_cdp', + args: { address: ADDRESS, asset: 'iUSD', leverage: 2, baseCollateral: '100000000' }, + builder: 'leverageCdpWithRob', + pythIndex: 12, + priceOracleIndex: 2, + }, + { + tool: 'redeem_rob', + args: { + address: ADDRESS, + asset: 'iUSD', + redemptionRobs: [{ txHash: 'rob', outputIndex: 0, amount: '1000000' }], + }, + builder: 'redeemRob', + pythIndex: 7, + priceOracleIndex: 1, + }, +] as const; + +describe('Pyth arguments reach the SDK transaction builders', () => { + let tools: Map; + + beforeEach(() => { + vi.clearAllMocks(); + const created = createTestServer(); + tools = created.tools; + registerCdpWriteTools(created.server); + registerCdpMintBurnTools(created.server); + registerCdpLiquidationTools(created.server); + registerLeverageCdpTools(created.server); + registerRobWriteTools(created.server); + }); + + for (const testCase of CASES) { + it(`${testCase.tool} forwards the Pyth message and state OutRef to ${testCase.builder}`, async () => { + const handler = tools.get(testCase.tool); + expect(handler, `${testCase.tool} is registered`).toBeDefined(); + + const result = await handler!({ ...testCase.args }); + expect(result.isError, result.content[0]?.text).toBeFalsy(); + + const builder = vi.mocked(sdk[testCase.builder] as (...args: unknown[]) => unknown); + expect(builder).toHaveBeenCalledTimes(1); + + const args = builder.mock.calls[0]; + expect(args[testCase.pythIndex]).toBe(PYTH_MESSAGE); + expect(args[testCase.pythIndex + 1]).toEqual(PYTH_STATE_OREF); + // The builders reject a price-oracle OutRef alongside a Pyth message. + expect(args[testCase.priceOracleIndex]).toBeUndefined(); + }); + } + + it('reports the Pyth price and submission deadline in the transaction summary', async () => { + const handler = tools.get('open_cdp'); + const result = await handler!({ + address: ADDRESS, + asset: 'iUSD', + collateralAmount: '100000000', + mintAmount: '1000000', + }); + + const payload = JSON.parse(result.content[0].text) as { summary: { pyth?: unknown } }; + expect(payload.summary.pyth).toEqual(PYTH_SUMMARY); + }); + + it('leaves Pyth-free tools untouched', async () => { + const handler = tools.get('deposit_cdp'); + const result = await handler!({ address: ADDRESS, asset: 'iUSD', ...CDP, amount: '5000000' }); + + expect(result.isError).toBeFalsy(); + const depositCdp = vi.mocked(sdk.depositCdp as (...args: unknown[]) => unknown); + expect(depositCdp).toHaveBeenCalledTimes(1); + // depositCdp has no price dependency: 10 args, no Pyth tail. + expect(depositCdp.mock.calls[0]).toHaveLength(10); + }); +}); diff --git a/src/tests/unit/utils/pyth.test.ts b/src/tests/unit/utils/pyth.test.ts new file mode 100644 index 0000000..0da54e9 --- /dev/null +++ b/src/tests/unit/utils/pyth.test.ts @@ -0,0 +1,260 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +vi.mock('../../../utils/indexer-client.js', () => ({ + getIndexerClient: vi.fn(), +})); + +vi.mock('../../../utils/v3-finders.js', () => ({ + findPriceOracleOref: vi.fn(), +})); + +vi.mock('../../../utils/sdk-config.js', () => ({ + getSystemParams: vi.fn(), +})); + +// Keep the SDK (and its libsodium/WASM dependencies) out of a unit test; only +// the asset-class-to-unit conversion is used here. +vi.mock('@indigo-labs/indigo-sdk', () => ({ + fromSystemParamsAsset: (asset: { unCurrencySymbol: string; unTokenName: string }) => asset, +})); + +vi.mock('@3rd-eye-labs/cardano-offchain-common', () => ({ + assetClassToUnit: (asset: { unCurrencySymbol: string; unTokenName: string }) => + `${asset.unCurrencySymbol}${asset.unTokenName}`, +})); + +import { getIndexerClient } from '../../../utils/indexer-client.js'; +import { findPriceOracleOref } from '../../../utils/v3-finders.js'; +import { getSystemParams } from '../../../utils/sdk-config.js'; +import { + fetchPythPriceFeed, + fetchPythStateOref, + resolvePythStateOref, + assertPythFeedFresh, + resolvePriceSource, + pythSummary, + PYTH_MAX_DELAY_MS, +} from '../../../utils/pyth.js'; + +// A real (truncated) Solana-format Pyth message: magic b9011a82 + signed payload. +const PYTH_PAYLOAD = 'b9011a82eb8fd5c098cdde39b3e03ba107081d59b5f7dbd3830f9194e5d7681a8d0dccd2'; +const PRICE_TIMESTAMP_S = 1787303581; +const PRICE_TIMESTAMP_MS = PRICE_TIMESTAMP_S * 1_000; + +const priceResponse = { + data: { + price: '4.739746222611', + expiration: PRICE_TIMESTAMP_MS + 3_600_000, + timestamp: PRICE_TIMESTAMP_S, + pythPayload: PYTH_PAYLOAD, + }, +}; + +const stateResponse = { + data: { + outputHash: '20dedb9c6e51112ac3059366b65c31ee59fc136aa400a16f9d01554fe0da4c6f', + outputIndex: 0, + }, +}; + +const mockGet = vi.fn(); + +/** Route the two analytics endpoints the Pyth helpers depend on. */ +function routed(url: string) { + if (url === '/v3/assets/iUSD/ada/price') return Promise.resolve(priceResponse); + if (url === '/v3/pyth-state/utxo') return Promise.resolve(stateResponse); + return Promise.reject(new Error('Unknown endpoint ' + url)); +} + +/** Freeze the clock just after the price update, well inside the delay window. */ +function freezeClockAtPriceTime(offsetMs = 1_000): void { + vi.useFakeTimers(); + vi.setSystemTime(new Date(PRICE_TIMESTAMP_MS + offsetMs)); +} + +describe('pyth helpers', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGet.mockImplementation(routed); + vi.mocked(getIndexerClient).mockReturnValue({ get: mockGet } as never); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('fetchPythPriceFeed', () => { + it('returns the signed message and its validity window', async () => { + const feed = await fetchPythPriceFeed('iUSD'); + + expect(mockGet).toHaveBeenCalledWith('/v3/assets/iUSD/ada/price'); + expect(feed.pythMessage).toBe(PYTH_PAYLOAD); + expect(feed.price).toBe('4.739746222611'); + expect(feed.timestampMs).toBe(PRICE_TIMESTAMP_MS); + expect(feed.validUntilMs).toBe(PRICE_TIMESTAMP_MS + PYTH_MAX_DELAY_MS); + }); + + it('uses the requested collateral in the path', async () => { + mockGet.mockResolvedValueOnce(priceResponse); + await fetchPythPriceFeed('iBTC', 'ada'); + expect(mockGet).toHaveBeenCalledWith('/v3/assets/iBTC/ada/price'); + }); + + it('throws when the API returns no payload', async () => { + mockGet.mockResolvedValueOnce({ data: { price: '1.0', timestamp: PRICE_TIMESTAMP_S } }); + await expect(fetchPythPriceFeed('iUSD')).rejects.toThrow(/No Pyth payload/); + }); + }); + + describe('fetchPythStateOref', () => { + it('maps the analytics response onto an OutRef', async () => { + await expect(fetchPythStateOref()).resolves.toEqual({ + txHash: stateResponse.data.outputHash, + outputIndex: 0, + }); + }); + + it('throws when the API returns no state UTxO', async () => { + mockGet.mockResolvedValueOnce({ data: {} }); + await expect(fetchPythStateOref()).rejects.toThrow(/did not return a Pyth state UTxO/); + }); + }); + + describe('resolvePythStateOref', () => { + // The state UTxO is whichever UTxO holds the pythStateAssetClass NFT, so + // the chain can answer when the analytics API cannot. + const pythStateAssetClass = { + unCurrencySymbol: 'c935c937d0deda8975142c7b77aeef8f8cd48791e89a8ca7a0edc154', + unTokenName: 'Pyth State', + }; + const lucid = { utxoByUnit: vi.fn() }; + + beforeEach(() => { + lucid.utxoByUnit.mockReset(); + vi.mocked(getSystemParams).mockResolvedValue({ + pythConfig: { pythStateAssetClass }, + } as never); + }); + + it('prefers the analytics API and does not touch the chain', async () => { + await expect(resolvePythStateOref(lucid as never)).resolves.toEqual({ + txHash: stateResponse.data.outputHash, + outputIndex: 0, + }); + expect(lucid.utxoByUnit).not.toHaveBeenCalled(); + }); + + it('falls back to the on-chain Pyth state NFT when the API fails', async () => { + mockGet.mockRejectedValueOnce(new Error('503 Service Unavailable')); + lucid.utxoByUnit.mockResolvedValue({ txHash: 'on-chain-hash', outputIndex: 2 }); + + await expect(resolvePythStateOref(lucid as never)).resolves.toEqual({ + txHash: 'on-chain-hash', + outputIndex: 2, + }); + expect(lucid.utxoByUnit).toHaveBeenCalledTimes(1); + }); + + it('reports both failures when neither source can answer', async () => { + mockGet.mockRejectedValueOnce(new Error('503 Service Unavailable')); + lucid.utxoByUnit.mockRejectedValue(new Error('no utxo found')); + + await expect(resolvePythStateOref(lucid as never)).rejects.toThrow( + /503 Service Unavailable.*no utxo found/s + ); + }); + }); + + describe('assertPythFeedFresh', () => { + const feed = { + price: '1.0', + pythMessage: PYTH_PAYLOAD, + timestampMs: PRICE_TIMESTAMP_MS, + validUntilMs: PRICE_TIMESTAMP_MS + PYTH_MAX_DELAY_MS, + }; + + it('accepts an update inside the on-chain delay window', () => { + freezeClockAtPriceTime(PYTH_MAX_DELAY_MS - 1_000); + expect(() => assertPythFeedFresh(feed, 'iUSD')).not.toThrow(); + }); + + it('rejects an update past the on-chain delay window', () => { + freezeClockAtPriceTime(PYTH_MAX_DELAY_MS + 60_000); + expect(() => assertPythFeedFresh(feed, 'iUSD')).toThrow(/beyond the 280s/); + }); + }); + + describe('resolvePriceSource', () => { + const collateralOut = { utxo: {}, datum: {} } as never; + + it('returns the oracle OutRef for OracleNft-priced assets and skips Pyth', async () => { + const oracleOref = { txHash: 'abc', outputIndex: 1 }; + vi.mocked(findPriceOracleOref).mockResolvedValue(oracleOref); + + const source = await resolvePriceSource({} as never, collateralOut, 'iUSD'); + + expect(source.priceOracleOref).toEqual(oracleOref); + expect(source.pythMessage).toBeUndefined(); + expect(source.pythStateOref).toBeUndefined(); + expect(source.pythFeed).toBeUndefined(); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('fetches the message and state UTxO for Pyth-priced assets', async () => { + freezeClockAtPriceTime(); + vi.mocked(findPriceOracleOref).mockResolvedValue(undefined); + + const source = await resolvePriceSource({} as never, collateralOut, 'iUSD'); + + expect(source.priceOracleOref).toBeUndefined(); + expect(source.pythMessage).toBe(PYTH_PAYLOAD); + expect(source.pythStateOref).toEqual({ + txHash: stateResponse.data.outputHash, + outputIndex: 0, + }); + expect(source.pythFeed?.price).toBe('4.739746222611'); + }); + + it('fails loudly when the available Pyth update is already too old', async () => { + freezeClockAtPriceTime(PYTH_MAX_DELAY_MS + 60_000); + vi.mocked(findPriceOracleOref).mockResolvedValue(undefined); + + await expect(resolvePriceSource({} as never, collateralOut, 'iUSD')).rejects.toThrow( + /beyond the 280s on-chain validity window/ + ); + }); + }); + + describe('pythSummary', () => { + it('is undefined for oracle-priced transactions', () => { + expect( + pythSummary({ + priceOracleOref: { txHash: 'abc', outputIndex: 0 }, + pythMessage: undefined, + pythStateOref: undefined, + pythFeed: undefined, + }) + ).toBeUndefined(); + }); + + it('reports the price and the submission deadline', () => { + const summary = pythSummary({ + priceOracleOref: undefined, + pythMessage: PYTH_PAYLOAD, + pythStateOref: { txHash: 'abc', outputIndex: 0 }, + pythFeed: { + price: '4.739746222611', + pythMessage: PYTH_PAYLOAD, + timestampMs: PRICE_TIMESTAMP_MS, + validUntilMs: PRICE_TIMESTAMP_MS + PYTH_MAX_DELAY_MS, + }, + }); + + expect(summary).toEqual({ + price: '4.739746222611', + priceTimestamp: new Date(PRICE_TIMESTAMP_MS).toISOString(), + submitBefore: new Date(PRICE_TIMESTAMP_MS + PYTH_MAX_DELAY_MS).toISOString(), + }); + }); + }); +}); diff --git a/src/tests/unit/utils/tx-builder.test.ts b/src/tests/unit/utils/tx-builder.test.ts new file mode 100644 index 0000000..3bea1ab --- /dev/null +++ b/src/tests/unit/utils/tx-builder.test.ts @@ -0,0 +1,121 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +vi.mock('../../../utils/lucid-provider.js', () => ({ + getLucid: vi.fn(), +})); + +import { getLucid } from '../../../utils/lucid-provider.js'; +import { buildUnsignedTx } from '../../../utils/tx-builder.js'; + +/** Minimal stand-in for a completed Lucid transaction. */ +function completedTx(fee: string) { + return { + toCBOR: () => 'cbor-hex', + toHash: () => 'tx-hash', + toTransaction: () => ({ body: () => ({ fee: () => fee }) }), + }; +} + +/** + * Stand-in for a Lucid TxBuilder. `failWith` is thrown by the first + * `complete()` of each builder instance unless a minimum fee has been set. + */ +function makeTxBuilder(failWith?: unknown) { + const state = { minFee: undefined as bigint | undefined, metadata: [] as unknown[] }; + const builder = { + state, + attachMetadata: vi.fn((label: number, msg: unknown) => { + state.metadata.push([label, msg]); + return builder; + }), + setMinFee: vi.fn((fee: bigint) => { + state.minFee = fee; + return builder; + }), + complete: vi.fn(() => { + if (failWith !== undefined && state.minFee === undefined) return Promise.reject(failWith); + return Promise.resolve(completedTx(state.minFee ? state.minFee.toString() : '170000')); + }), + }; + return builder; +} + +const SUMMARY = { type: 'open_cdp', description: 'Open iUSD CDP', inputs: { asset: 'iUSD' } }; + +describe('buildUnsignedTx', () => { + const lucid = { + utxosAt: vi.fn(() => Promise.resolve([])), + selectWallet: { fromAddress: vi.fn() }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getLucid).mockResolvedValue(lucid as never); + }); + + it('returns the completed transaction with its summary', async () => { + const builder = makeTxBuilder(); + const result = await buildUnsignedTx('addr1', () => Promise.resolve(builder as never), { + ...SUMMARY, + }); + + expect(result).toMatchObject({ unsignedTx: 'cbor-hex', txHash: 'tx-hash', fee: '170000' }); + expect(builder.attachMetadata).toHaveBeenCalledTimes(1); + expect(builder.setMinFee).not.toHaveBeenCalled(); + }); + + it('carries the Pyth pricing the build function reports into the summary', async () => { + const pyth = { + price: '4.61', + priceTimestamp: '2026-08-21T09:52:41.000Z', + submitBefore: '2026-08-21T09:57:21.000Z', + }; + + const result = await buildUnsignedTx( + 'addr1', + (_lucid, ctx) => { + ctx.pyth = pyth; + return Promise.resolve(makeTxBuilder() as never); + }, + { ...SUMMARY } + ); + + expect(result.summary.pyth).toEqual(pyth); + }); + + it('rebuilds at the suggested minimum fee when coin selection shifts', async () => { + // Lucid throws a plain object here, not an Error. + const lucidError = { + Complete: + 'RedeemerBuilder: Coin selection had to be updated after building redeemers, ' + + 'possibly leading to incorrect indices. Try setting a minimum fee of 1115401 lovelaces.', + }; + + const builders: ReturnType[] = []; + const result = await buildUnsignedTx( + 'addr1', + () => { + const builder = makeTxBuilder(lucidError); + builders.push(builder); + return Promise.resolve(builder as never); + }, + { ...SUMMARY } + ); + + // A failed builder still holds its mints and redeemers, so the retry must + // assemble a fresh one rather than completing the same builder twice. + expect(builders).toHaveLength(2); + expect(builders[0].setMinFee).not.toHaveBeenCalled(); + expect(builders[1].setMinFee).toHaveBeenCalledWith(1115401n); + expect(builders[1].complete).toHaveBeenCalledTimes(1); + expect(result.fee).toBe('1115401'); + }); + + it('propagates errors that are not a minimum-fee hint', async () => { + const builder = makeTxBuilder(new Error('Insufficient input in transaction')); + + await expect( + buildUnsignedTx('addr1', () => Promise.resolve(builder as never), { ...SUMMARY }) + ).rejects.toThrow(/Insufficient input/); + }); +}); diff --git a/src/tests/unit/utils/validators.test.ts b/src/tests/unit/utils/validators.test.ts index a4a444b..c04b899 100644 --- a/src/tests/unit/utils/validators.test.ts +++ b/src/tests/unit/utils/validators.test.ts @@ -18,6 +18,10 @@ describe('AssetParam validator', () => { expect(AssetParam.parse('iSOL')).toBe('iSOL'); }); + it('should accept iADA', () => { + expect(AssetParam.parse('iADA')).toBe('iADA'); + }); + it('should reject invalid asset names', () => { expect(() => AssetParam.parse('invalid')).toThrow(); }); diff --git a/src/tools/cdp-liquidation-tools.ts b/src/tools/cdp-liquidation-tools.ts index cc99777..c3125fa 100644 --- a/src/tools/cdp-liquidation-tools.ts +++ b/src/tools/cdp-liquidation-tools.ts @@ -8,17 +8,13 @@ import { findIAsset, findCollateralAsset, findInterestOracleOref, - findPriceOracleOref, findInterestCollectorOref, findTreasuryOref, findStabilityPool, findGov, toOutRef, } from '../utils/v3-finders.js'; - -const PYTH_UNSUPPORTED = - 'This iAsset is priced via Pyth, which requires a signed Pyth price message. ' + - 'Pyth-priced operations are not yet supported by this server.'; +import { resolvePriceSource, pythSummary } from '../utils/pyth.js'; export function registerCdpLiquidationTools(server: McpServer): void { server.tool( @@ -95,7 +91,7 @@ export function registerCdpLiquidationTools(server: McpServer): void { try { const result = await buildUnsignedTx( address, - async (lucid) => { + async (lucid, ctx) => { const params = await getSystemParams(); const currentSlot = lucid.currentSlot(); const cdpOref = { txHash: cdpTxHash, outputIndex: cdpOutputIndex }; @@ -109,25 +105,27 @@ export function registerCdpLiquidationTools(server: McpServer): void { findGov(lucid, params), ]); - const [priceOracleOref, interestOracleOref] = await Promise.all([ - findPriceOracleOref(lucid, collateralOut), + const [priceSource, interestOracleOref] = await Promise.all([ + resolvePriceSource(lucid, collateralOut, asset), findInterestOracleOref(lucid, collateralOut), ]); - if (priceOracleOref === undefined) throw new Error(PYTH_UNSUPPORTED); + ctx.pyth = pythSummary(priceSource); return redeemCdp( BigInt(amount), cdpOref, toOutRef(iassetOut.utxo), toOutRef(collateralOut.utxo), - priceOracleOref, + priceSource.priceOracleOref, interestOracleOref, interestCollectorOref, treasuryOref, toOutRef(gov.utxo), params, lucid, - currentSlot + currentSlot, + priceSource.pythMessage, + priceSource.pythStateOref ); }, { @@ -167,7 +165,7 @@ export function registerCdpLiquidationTools(server: McpServer): void { try { const result = await buildUnsignedTx( address, - async (lucid) => { + async (lucid, ctx) => { const params = await getSystemParams(); const currentSlot = lucid.currentSlot(); const cdpOref = { txHash: cdpTxHash, outputIndex: cdpOutputIndex }; @@ -177,21 +175,23 @@ export function registerCdpLiquidationTools(server: McpServer): void { findCollateralAsset(lucid, params, asset), ]); - const [priceOracleOref, interestOracleOref] = await Promise.all([ - findPriceOracleOref(lucid, collateralOut), + const [priceSource, interestOracleOref] = await Promise.all([ + resolvePriceSource(lucid, collateralOut, asset), findInterestOracleOref(lucid, collateralOut), ]); - if (priceOracleOref === undefined) throw new Error(PYTH_UNSUPPORTED); + ctx.pyth = pythSummary(priceSource); return freezeCdp( cdpOref, toOutRef(iassetOut.utxo), toOutRef(collateralOut.utxo), - priceOracleOref, + priceSource.priceOracleOref, interestOracleOref, params, lucid, - currentSlot + currentSlot, + priceSource.pythMessage, + priceSource.pythStateOref ); }, { diff --git a/src/tools/cdp-mint-burn-tools.ts b/src/tools/cdp-mint-burn-tools.ts index c8495cc..9621cfc 100644 --- a/src/tools/cdp-mint-burn-tools.ts +++ b/src/tools/cdp-mint-burn-tools.ts @@ -8,15 +8,11 @@ import { findIAsset, findCollateralAsset, findInterestOracleOref, - findPriceOracleOref, findInterestCollectorOref, findTreasuryOref, toOutRef, } from '../utils/v3-finders.js'; - -const PYTH_UNSUPPORTED = - 'This iAsset is priced via Pyth, which requires a signed Pyth price message. ' + - 'Pyth-priced operations are not yet supported by this server.'; +import { resolvePriceSource, pythSummary } from '../utils/pyth.js'; export function registerCdpMintBurnTools(server: McpServer): void { server.tool( @@ -33,7 +29,7 @@ export function registerCdpMintBurnTools(server: McpServer): void { try { const result = await buildUnsignedTx( address, - async (lucid) => { + async (lucid, ctx) => { const params = await getSystemParams(); const currentSlot = lucid.currentSlot(); const cdpOref = { txHash: cdpTxHash, outputIndex: cdpOutputIndex }; @@ -46,24 +42,26 @@ export function registerCdpMintBurnTools(server: McpServer): void { findTreasuryOref(lucid, params), ]); - const [priceOracleOref, interestOracleOref] = await Promise.all([ - findPriceOracleOref(lucid, collateralOut), + const [priceSource, interestOracleOref] = await Promise.all([ + resolvePriceSource(lucid, collateralOut, asset), findInterestOracleOref(lucid, collateralOut), ]); - if (priceOracleOref === undefined) throw new Error(PYTH_UNSUPPORTED); + ctx.pyth = pythSummary(priceSource); return mintCdp( BigInt(amount), cdpOref, toOutRef(iassetOut.utxo), toOutRef(collateralOut.utxo), - priceOracleOref, + priceSource.priceOracleOref, interestOracleOref, treasuryOref, interestCollectorOref, params, lucid, - currentSlot + currentSlot, + priceSource.pythMessage, + priceSource.pythStateOref ); }, { diff --git a/src/tools/cdp-write-tools.ts b/src/tools/cdp-write-tools.ts index 2d190eb..497fa8d 100644 --- a/src/tools/cdp-write-tools.ts +++ b/src/tools/cdp-write-tools.ts @@ -9,15 +9,11 @@ import { findCollateralAsset, findCdpCreatorOref, findInterestOracleOref, - findPriceOracleOref, findInterestCollectorOref, findTreasuryOref, toOutRef, } from '../utils/v3-finders.js'; - -const PYTH_UNSUPPORTED = - 'This iAsset is priced via Pyth, which requires a signed Pyth price message. ' + - 'Pyth-priced operations are not yet supported by this server.'; +import { resolvePriceSource, pythSummary } from '../utils/pyth.js'; export function registerCdpWriteTools(server: McpServer): void { server.tool( @@ -33,7 +29,7 @@ export function registerCdpWriteTools(server: McpServer): void { try { const result = await buildUnsignedTx( address, - async (lucid) => { + async (lucid, ctx) => { const params = await getSystemParams(); const currentSlot = lucid.currentSlot(); @@ -44,11 +40,11 @@ export function registerCdpWriteTools(server: McpServer): void { findTreasuryOref(lucid, params), ]); - const [priceOracleOref, interestOracleOref] = await Promise.all([ - findPriceOracleOref(lucid, collateralOut), + const [priceSource, interestOracleOref] = await Promise.all([ + resolvePriceSource(lucid, collateralOut, asset), findInterestOracleOref(lucid, collateralOut), ]); - if (priceOracleOref === undefined) throw new Error(PYTH_UNSUPPORTED); + ctx.pyth = pythSummary(priceSource); return openCdp( BigInt(collateralAmount), @@ -57,11 +53,13 @@ export function registerCdpWriteTools(server: McpServer): void { cdpCreatorOref, toOutRef(iassetOut.utxo), toOutRef(collateralOut.utxo), - priceOracleOref, + priceSource.priceOracleOref, interestOracleOref, treasuryOref, lucid, - currentSlot + currentSlot, + priceSource.pythMessage, + priceSource.pythStateOref ); }, { @@ -168,7 +166,7 @@ export function registerCdpWriteTools(server: McpServer): void { try { const result = await buildUnsignedTx( address, - async (lucid) => { + async (lucid, ctx) => { const params = await getSystemParams(); const currentSlot = lucid.currentSlot(); const cdpOref = { txHash: cdpTxHash, outputIndex: cdpOutputIndex }; @@ -181,24 +179,26 @@ export function registerCdpWriteTools(server: McpServer): void { findTreasuryOref(lucid, params), ]); - const [priceOracleOref, interestOracleOref] = await Promise.all([ - findPriceOracleOref(lucid, collateralOut), + const [priceSource, interestOracleOref] = await Promise.all([ + resolvePriceSource(lucid, collateralOut, asset), findInterestOracleOref(lucid, collateralOut), ]); - if (priceOracleOref === undefined) throw new Error(PYTH_UNSUPPORTED); + ctx.pyth = pythSummary(priceSource); return withdrawCdp( BigInt(amount), cdpOref, toOutRef(iassetOut.utxo), toOutRef(collateralOut.utxo), - priceOracleOref, + priceSource.priceOracleOref, interestOracleOref, treasuryOref, interestCollectorOref, params, lucid, - currentSlot + currentSlot, + priceSource.pythMessage, + priceSource.pythStateOref ); }, { diff --git a/src/tools/leverage-cdp-tools.ts b/src/tools/leverage-cdp-tools.ts index 302c555..7d38679 100644 --- a/src/tools/leverage-cdp-tools.ts +++ b/src/tools/leverage-cdp-tools.ts @@ -9,15 +9,11 @@ import { findCollateralAsset, findCdpCreatorOref, findInterestOracleOref, - findPriceOracleOref, findTreasuryOref, findAllRobs, toOutRef, } from '../utils/v3-finders.js'; - -const PYTH_UNSUPPORTED = - 'This iAsset is priced via Pyth, which requires a signed Pyth price message. ' + - 'Pyth-priced operations are not yet supported by this server.'; +import { resolvePriceSource, pythSummary } from '../utils/pyth.js'; export function registerLeverageCdpTools(server: McpServer): void { server.tool( @@ -33,7 +29,7 @@ export function registerLeverageCdpTools(server: McpServer): void { try { const result = await buildUnsignedTx( address, - async (lucid) => { + async (lucid, ctx) => { const params = await getSystemParams(); const currentSlot = lucid.currentSlot(); @@ -53,16 +49,16 @@ export function registerLeverageCdpTools(server: McpServer): void { throw new Error('No ADA-only treasury UTxO available for leverage operation'); } - const [priceOracleOref, interestOracleOref] = await Promise.all([ - findPriceOracleOref(lucid, collateralOut), + const [priceSource, interestOracleOref] = await Promise.all([ + resolvePriceSource(lucid, collateralOut, asset), findInterestOracleOref(lucid, collateralOut), ]); - if (priceOracleOref === undefined) throw new Error(PYTH_UNSUPPORTED); + ctx.pyth = pythSummary(priceSource); return leverageCdpWithRob( leverage, BigInt(baseCollateral), - priceOracleOref, + priceSource.priceOracleOref, toOutRef(iassetOut.utxo), toOutRef(collateralOut.utxo), cdpCreatorOref, @@ -71,7 +67,9 @@ export function registerLeverageCdpTools(server: McpServer): void { params, lucid, allRobs, - currentSlot + currentSlot, + priceSource.pythMessage, + priceSource.pythStateOref ); }, { diff --git a/src/tools/oracle-tools.ts b/src/tools/oracle-tools.ts index 54c4000..984bde7 100644 --- a/src/tools/oracle-tools.ts +++ b/src/tools/oracle-tools.ts @@ -15,6 +15,7 @@ import { buildUnsignedTx } from '../utils/tx-builder.js'; import { getSystemParams } from '../utils/sdk-config.js'; import { AssetParam } from '../utils/validators.js'; import { ADA_COLLATERAL, findCollateralAsset, findPriceOracleOref } from '../utils/v3-finders.js'; +import { fetchPythPriceFeed, PYTH_MAX_DELAY_MS } from '../utils/pyth.js'; import { getLucid } from '../utils/lucid-provider.js'; export function registerOracleTools(server: McpServer): void { @@ -110,12 +111,9 @@ export function registerOracleTools(server: McpServer): void { server.tool( 'get_pyth_price', - 'Best-effort read of the Pyth price feed config for an iAsset. ' + - 'Reads the on-chain Pyth state UTxO and returns feed configuration derived from system params. ' + - 'NOTE: Pyth price values are not readable from the on-chain Pyth state datum alone — ' + - 'the actual latest price must be fetched from the Pyth Lazer off-chain API and pushed ' + - 'on-chain via a signed PythMessage. This tool surfaces the feed config so callers can ' + - 'identify the correct Pyth feed ID to query externally.', + 'Get the current Pyth price for an iAsset, together with its on-chain feed configuration. ' + + 'The price is the latest signed Pyth update served by the Indigo analytics API — the same ' + + 'payload the CDP write tools embed on-chain — and is only valid on-chain until validUntil.', { asset: AssetParam }, async ({ asset }) => { try { @@ -234,19 +232,36 @@ export function registerOracleTools(server: McpServer): void { } /** - * Shared implementation for Pyth price info lookup. Reads the Pyth state UTxO - * from the chain (via the pythStateAssetClass in system params) and returns - * the feed config for the asset. + * Shared implementation for Pyth price lookup. * - * Limitation: the on-chain Pyth state datum holds governance / trusted-signer - * configuration, not the latest price values. The live price must be fetched - * from the Pyth Lazer off-chain API (identified by the feedId returned here). + * The live price is not readable from the on-chain Pyth state datum — that + * holds governance / trusted-signer configuration only. The current signed + * price update comes from the Indigo analytics API, which serves the same + * payload the transaction builders embed on-chain; the feed config and Pyth + * state datum are read from the chain alongside it for context. */ async function getPythPriceForAsset(asset: string): Promise> { const lucid = await getLucid(); const params = await getSystemParams(); const pythConfig = params.pythConfig; + // Live price update, straight from the feed the tx builders use. + let livePrice: Record | undefined; + try { + const feed = await fetchPythPriceFeed(asset); + livePrice = { + price: feed.price, + priceTimestamp: new Date(feed.timestampMs).toISOString(), + validUntil: new Date(feed.validUntilMs).toISOString(), + stale: Date.now() > feed.validUntilMs, + }; + } catch (error) { + // Non-fatal: still return the on-chain feed configuration below. + livePrice = { + error: `Could not fetch the live Pyth price: ${error instanceof Error ? error.message : String(error)}`, + }; + } + // Resolve iasset bytes for getPythFeedConfig key lookup. const iassetBytes = fromText(asset); const iassetUint8 = Buffer.from(iassetBytes, 'hex'); @@ -257,6 +272,7 @@ async function getPythPriceForAsset(asset: string): Promise { + async (lucid, ctx) => { const params = await getSystemParams(); const currentSlot = lucid.currentSlot(); @@ -259,8 +250,8 @@ export function registerRobWriteTools(server: McpServer): void { findIAsset(lucid, params, asset), findCollateralAsset(lucid, params, asset), ]); - const priceOracleOref = await findPriceOracleOref(lucid, collateralOut); - if (priceOracleOref === undefined) throw new Error(PYTH_UNSUPPORTED); + const priceSource = await resolvePriceSource(lucid, collateralOut, asset); + ctx.pyth = pythSummary(priceSource); const redemptionRobsData: [{ txHash: string; outputIndex: number }, bigint][] = redemptionRobs.map((rob) => [ @@ -270,12 +261,14 @@ export function registerRobWriteTools(server: McpServer): void { return redeemRob( redemptionRobsData, - priceOracleOref, + priceSource.priceOracleOref, toOutRef(iassetOut.utxo), toOutRef(collateralOut.utxo), lucid, params, - currentSlot + currentSlot, + priceSource.pythMessage, + priceSource.pythStateOref ); }, { diff --git a/src/types/tx-types.ts b/src/types/tx-types.ts index d55f6a6..73e4f53 100644 --- a/src/types/tx-types.ts +++ b/src/types/tx-types.ts @@ -1,7 +1,22 @@ +export interface PythPricingSummary { + /** Price the transaction was built at, in the collateral asset. */ + price: string; + /** ISO timestamp of the Pyth price update embedded in the transaction. */ + priceTimestamp: string; + /** + * ISO deadline for submitting the signed transaction. The on-chain Pyth feed + * validator caps a transaction's validity window at the price timestamp plus + * 280 seconds; after this the transaction must be rebuilt. + */ + submitBefore: string; +} + export interface TxSummary { type: string; description: string; inputs: Record; + /** Present only for Pyth-priced iAssets. */ + pyth?: PythPricingSummary; } export interface UnsignedTxResult { diff --git a/src/utils/index.ts b/src/utils/index.ts index 78af447..bddefd2 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -4,3 +4,13 @@ export { extractPaymentCredential } from './address.js'; export { getLucid, getLucidNetwork, resetLucid } from './lucid-provider.js'; export { getSystemParams, resetSystemParamsCache } from './sdk-config.js'; export { buildUnsignedTx } from './tx-builder.js'; +export { + fetchPythPriceFeed, + fetchPythStateOref, + resolvePythStateOref, + assertPythFeedFresh, + resolvePriceSource, + pythSummary, + PYTH_MAX_DELAY_MS, + DEFAULT_COLLATERAL, +} from './pyth.js'; diff --git a/src/utils/pyth.ts b/src/utils/pyth.ts new file mode 100644 index 0000000..5db1070 --- /dev/null +++ b/src/utils/pyth.ts @@ -0,0 +1,220 @@ +import type { LucidEvolution, OutRef } from '@lucid-evolution/lucid'; +import type { CollateralAssetOutput } from '@indigo-labs/indigo-sdk'; +import { fromSystemParamsAsset } from '@indigo-labs/indigo-sdk'; +import { assetClassToUnit } from '@3rd-eye-labs/cardano-offchain-common'; +import type { PythPricingSummary } from '../types/tx-types.js'; +import { getIndexerClient } from './indexer-client.js'; +import { getSystemParams } from './sdk-config.js'; +import { findPriceOracleOref } from './v3-finders.js'; + +/** + * Pyth pricing support. + * + * In Indigo v3 most iAssets are priced via Pyth (`DeferredValidation` on the + * collateral-asset datum) rather than by an on-chain oracle NFT. Those + * transactions carry a signed Pyth price message plus a reference to the Pyth + * state UTxO; the SDK builders accept both as trailing arguments. + * + * Neither value can be derived from the chain alone — the signed message comes + * from Pyth Lazer and is served, already signed and in the Solana wire format + * the on-chain feed validator expects, by the Indigo analytics API. + */ + +/** Default collateral used in the analytics price path. */ +export const DEFAULT_COLLATERAL = 'ada'; + +/** + * Window the on-chain Pyth feed validator allows between the price timestamp + * and the transaction validity upper bound. Mirrors `pythMaxDelay` in the SDK's + * `attachOracle`: a transaction built from an older payload cannot be submitted. + */ +export const PYTH_MAX_DELAY_MS = 280 * 1_000; + +/** Raw shape returned by `GET /v3/assets/{iasset}/{collateral}/price`. */ +interface PythPriceResponse { + price: string; + /** Milliseconds since epoch. */ + expiration: number; + /** Seconds since epoch. */ + timestamp: number; + /** Hex-encoded, signed Solana-format Pyth message. */ + pythPayload: string; +} + +/** Raw shape returned by `GET /v3/pyth-state/utxo`. */ +interface PythStateUtxoResponse { + outputHash: string; + outputIndex: number; +} + +export interface PythPriceFeed { + /** Decimal price of the iAsset in the collateral asset. */ + price: string; + /** Signed Pyth message (hex) to pass to the SDK builders as `pythMessage`. */ + pythMessage: string; + /** Price timestamp in milliseconds since epoch. */ + timestampMs: number; + /** Latest time the resulting transaction may be submitted, ms since epoch. */ + validUntilMs: number; +} + +/** + * A resolved price source for a transaction. Exactly one of `priceOracleOref` + * (oracle-NFT assets) and `pythMessage`/`pythStateOref` (Pyth assets) is set — + * the SDK builders reject being handed both. + */ +export interface PriceSource { + priceOracleOref: OutRef | undefined; + pythMessage: string | undefined; + pythStateOref: OutRef | undefined; + /** Present for Pyth-priced assets: the price the transaction was built at. */ + pythFeed: PythPriceFeed | undefined; +} + +/** + * Fetch the current signed Pyth message and price for an (iAsset, collateral) + * pair from the analytics API. + */ +export async function fetchPythPriceFeed( + asset: string, + collateral: string = DEFAULT_COLLATERAL +): Promise { + const client = getIndexerClient(); + const path = `/v3/assets/${encodeURIComponent(asset)}/${encodeURIComponent(collateral)}/price`; + + const response = await client.get(path); + const { price, timestamp, pythPayload } = response.data ?? {}; + + if (!pythPayload) { + throw new Error( + `No Pyth payload returned for ${asset}/${collateral} by the Indigo analytics API. ` + + 'The asset may not be priced via Pyth, or the feed is temporarily unavailable.' + ); + } + + const timestampMs = timestamp * 1_000; + + return { + price, + pythMessage: pythPayload, + timestampMs, + validUntilMs: timestampMs + PYTH_MAX_DELAY_MS, + }; +} + +/** + * Reject a price update that is already outside the on-chain validity window — + * a transaction built from it could never be submitted, so failing here gives a + * far clearer error than a rejected submission would. + */ +export function assertPythFeedFresh( + feed: PythPriceFeed, + asset: string, + collateral: string = DEFAULT_COLLATERAL +): void { + const ageMs = Date.now() - feed.timestampMs; + if (ageMs <= PYTH_MAX_DELAY_MS) return; + + throw new Error( + `The Pyth price message for ${asset}/${collateral} is ${Math.round(ageMs / 1_000)}s old, ` + + `beyond the ${PYTH_MAX_DELAY_MS / 1_000}s on-chain validity window. ` + + 'Retry to pick up a fresher price update.' + ); +} + +/** + * Fetch the OutRef of the current Pyth state UTxO, referenced by every + * Pyth-priced transaction. + */ +export async function fetchPythStateOref(): Promise { + const client = getIndexerClient(); + const response = await client.get('/v3/pyth-state/utxo'); + const { outputHash, outputIndex } = response.data ?? {}; + + if (!outputHash || outputIndex === undefined || outputIndex === null) { + throw new Error('The Indigo analytics API did not return a Pyth state UTxO'); + } + + return { txHash: outputHash, outputIndex }; +} + +/** + * Resolve the Pyth state OutRef, preferring the analytics API and falling back + * to the chain. + * + * The state UTxO is simply whichever UTxO holds the `pythStateAssetClass` NFT + * named in system params — the query the indigo-sdk acceptance tests use — so + * an analytics outage need not block transaction building. + */ +export async function resolvePythStateOref(lucid: LucidEvolution): Promise { + try { + return await fetchPythStateOref(); + } catch (apiError) { + try { + const params = await getSystemParams(); + const unit = assetClassToUnit(fromSystemParamsAsset(params.pythConfig.pythStateAssetClass)); + const utxo = await lucid.utxoByUnit(unit); + return { txHash: utxo.txHash, outputIndex: utxo.outputIndex }; + } catch (chainError) { + throw new Error( + 'Could not resolve the Pyth state UTxO from the analytics API ' + + `(${apiError instanceof Error ? apiError.message : String(apiError)}) ` + + `or from the chain (${chainError instanceof Error ? chainError.message : String(chainError)})` + ); + } + } +} + +/** + * Resolve everything a transaction builder needs to price an (iAsset, + * collateral) pair, whichever oracle the asset uses. + * + * - `OracleNft` → the oracle UTxO's OutRef. + * - Pyth (`DeferredValidation`) → a freshly fetched signed message plus the + * Pyth state OutRef. + * - `Delisted` → throws (via {@link findPriceOracleOref}). + */ +export async function resolvePriceSource( + lucid: LucidEvolution, + collateralAsset: CollateralAssetOutput, + asset: string, + collateral: string = DEFAULT_COLLATERAL +): Promise { + const priceOracleOref = await findPriceOracleOref(lucid, collateralAsset); + + if (priceOracleOref !== undefined) { + return { + priceOracleOref, + pythMessage: undefined, + pythStateOref: undefined, + pythFeed: undefined, + }; + } + + const [pythFeed, pythStateOref] = await Promise.all([ + fetchPythPriceFeed(asset, collateral), + resolvePythStateOref(lucid), + ]); + assertPythFeedFresh(pythFeed, asset, collateral); + + return { + priceOracleOref: undefined, + pythMessage: pythFeed.pythMessage, + pythStateOref, + pythFeed, + }; +} + +/** + * Summarise the Pyth price a transaction was built at, so a caller signing on a + * hardware wallet knows how long it has to submit. `undefined` for oracle-NFT + * priced assets. + */ +export function pythSummary(source: PriceSource): PythPricingSummary | undefined { + if (!source.pythFeed) return undefined; + return { + price: source.pythFeed.price, + priceTimestamp: new Date(source.pythFeed.timestampMs).toISOString(), + submitBefore: new Date(source.pythFeed.validUntilMs).toISOString(), + }; +} diff --git a/src/utils/tx-builder.ts b/src/utils/tx-builder.ts index 9ba3409..7e73ba1 100644 --- a/src/utils/tx-builder.ts +++ b/src/utils/tx-builder.ts @@ -1,5 +1,5 @@ import type { LucidEvolution, TxBuilder } from '@lucid-evolution/lucid'; -import type { UnsignedTxResult, TxSummary } from '../types/tx-types.js'; +import type { UnsignedTxResult, TxSummary, PythPricingSummary } from '../types/tx-types.js'; import { getLucid } from './lucid-provider.js'; /** @@ -8,6 +8,29 @@ import { getLucid } from './lucid-provider.js'; */ const CIP20_METADATA_LABEL = 674; +/** + * Lucid reports the fee that makes coin selection stable when selection had to + * change after the redeemers were built — which invalidates redeemer indices. + * Script-heavy transactions, which every Pyth-priced CDP operation is, hit this + * regularly. + */ +const MIN_FEE_HINT = /minimum fee of (\d+) lovelace/i; + +/** + * Scratch space handed to a build function so it can report pricing details + * that are only known once the transaction has been assembled. + */ +export interface TxBuildContext { + /** Set when the transaction is priced via a signed Pyth message. */ + pyth?: PythPricingSummary; +} + +function describeError(error: unknown): string { + if (error instanceof Error) return error.message; + const text = String(error); + return text === '[object Object]' ? JSON.stringify(error) : text; +} + /** * Build CIP-20 metadata message lines from a TxSummary. * Each line is capped at 64 bytes (CIP-20 requirement). @@ -19,7 +42,7 @@ function buildCip20Message(summary: TxSummary): string[] { export async function buildUnsignedTx( address: string, - buildFn: (lucid: LucidEvolution) => Promise, + buildFn: (lucid: LucidEvolution, ctx: TxBuildContext) => Promise, summary: TxSummary ): Promise { const lucid = await getLucid(); @@ -27,18 +50,35 @@ export async function buildUnsignedTx( const utxos = await lucid.utxosAt(address); lucid.selectWallet.fromAddress(address, utxos); - const txBuilder = await buildFn(lucid); + // Assemble from scratch each time: a TxBuilder that failed to complete still + // holds its mints and redeemers, so completing it twice duplicates them. + const assemble = async (minFee?: bigint) => { + const ctx: TxBuildContext = {}; + const txBuilder = await buildFn(lucid, ctx); + + txBuilder.attachMetadata(CIP20_METADATA_LABEL, { + msg: buildCip20Message(summary), + }); + if (minFee !== undefined) txBuilder.setMinFee(minFee); + + return { tx: await txBuilder.complete(), ctx }; + }; - txBuilder.attachMetadata(CIP20_METADATA_LABEL, { - msg: buildCip20Message(summary), - }); + let built: Awaited>; + try { + built = await assemble(); + } catch (error) { + const hint = MIN_FEE_HINT.exec(describeError(error)); + if (!hint) throw error; + built = await assemble(BigInt(hint[1])); + } - const tx = await txBuilder.complete(); + const { tx, ctx } = built; return { unsignedTx: tx.toCBOR(), txHash: tx.toHash(), fee: tx.toTransaction().body().fee().toString(), - summary, + summary: ctx.pyth ? { ...summary, pyth: ctx.pyth } : summary, }; } diff --git a/src/utils/v3-finders.ts b/src/utils/v3-finders.ts index 6ccee56..d2b3f4e 100644 --- a/src/utils/v3-finders.ts +++ b/src/utils/v3-finders.ts @@ -33,7 +33,7 @@ import { * * In Indigo v3 the price and interest oracle references no longer live on the * iAsset datum — they sit on the per-collateral-asset datum, and prices may be - * served by Pyth. The protocol's transaction builders also take {@link OutRef}s + * served by Pyth (see `utils/pyth.ts`). The protocol's transaction builders also take {@link OutRef}s * rather than full UTxOs. These helpers mirror the canonical query patterns used * by the indigo-sdk-v3 acceptance tests. */ @@ -124,8 +124,9 @@ export async function findInterestOracleOref( * Resolve the price oracle OutRef for a collateral asset. * - `OracleNft`: returns the oracle UTxO's OutRef. * - `Delisted`: throws — the asset cannot be used as collateral. - * - Pyth (`DeferredValidation`): returns `undefined`. Pyth-priced operations - * require a signed Pyth message, which is not yet produced by this server. + * - Pyth (`DeferredValidation`): returns `undefined` — the price comes from a + * signed Pyth message instead. Use {@link resolvePriceSource} from + * `utils/pyth.ts`, which handles both cases. */ export async function findPriceOracleOref( lucid: LucidEvolution, diff --git a/src/utils/validators.ts b/src/utils/validators.ts index 4e00555..1ac48c0 100644 --- a/src/utils/validators.ts +++ b/src/utils/validators.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -// All iAssets currently minted on Indigo. v3 added iEUR and iJPY alongside the -// original iUSD/iBTC/iETH/iSOL set. -export const AssetParam = z.enum(['iUSD', 'iBTC', 'iETH', 'iSOL', 'iEUR', 'iJPY']); +// All iAssets currently minted on Indigo. v3 added iEUR, iJPY and iADA +// alongside the original iUSD/iBTC/iETH/iSOL set. +export const AssetParam = z.enum(['iUSD', 'iBTC', 'iETH', 'iSOL', 'iEUR', 'iJPY', 'iADA']);