From fddd7c9904d01a6d5a851d35cffa93074d02a09b Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Fri, 21 Aug 2026 12:29:29 +0300 Subject: [PATCH 1/6] feat(pyth): build transactions for Pyth-priced iAssets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every iAsset on mainnet is now priced via Pyth (DeferredValidation), so every price-dependent write tool refused to build a transaction: open_cdp, withdraw_cdp, mint_cdp, redeem_cdp, freeze_cdp, leverage_cdp and redeem_rob all threw "Pyth-priced operations are not yet supported by this server". The SDK builders already accept a signed Pyth message and the Pyth state OutRef as trailing arguments; both are served by the analytics API: GET /v3/assets/{iasset}/{collateral}/price -> pythPayload (signed message) GET /v3/pyth-state/utxo -> Pyth state OutRef resolvePriceSource() resolves whichever oracle an asset uses — an oracle-NFT OutRef, or a freshly fetched Pyth message plus state OutRef — so the tools handle both without branching. A price update older than the on-chain 280s delay window is rejected up front, since a transaction built from it could never be submitted, and the price plus its submission deadline are reported back in the transaction summary for hardware-wallet signing flows. get_pyth_price and get_oracle_price now return the live price alongside the on-chain feed configuration instead of only pointing at the Pyth Lazer API. Refs INDY-131 --- src/tests/unit/utils/pyth.test.ts | 198 +++++++++++++++++++++++++++++ src/tools/cdp-liquidation-tools.ts | 34 ++--- src/tools/cdp-mint-burn-tools.ts | 20 ++- src/tools/cdp-write-tools.ts | 34 ++--- src/tools/leverage-cdp-tools.ts | 20 ++- src/tools/oracle-tools.ts | 48 ++++--- src/tools/rob-write-tools.ts | 25 ++-- src/types/tx-types.ts | 15 +++ src/utils/index.ts | 9 ++ src/utils/pyth.ts | 190 +++++++++++++++++++++++++++ src/utils/tx-builder.ts | 18 ++- src/utils/v3-finders.ts | 7 +- 12 files changed, 524 insertions(+), 94 deletions(-) create mode 100644 src/tests/unit/utils/pyth.test.ts create mode 100644 src/utils/pyth.ts diff --git a/src/tests/unit/utils/pyth.test.ts b/src/tests/unit/utils/pyth.test.ts new file mode 100644 index 0000000..1ce79b5 --- /dev/null +++ b/src/tests/unit/utils/pyth.test.ts @@ -0,0 +1,198 @@ +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(), +})); + +import { getIndexerClient } from '../../../utils/indexer-client.js'; +import { findPriceOracleOref } from '../../../utils/v3-finders.js'; +import { + fetchPythPriceFeed, + fetchPythStateOref, + 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('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/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..7b628e6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -4,3 +4,12 @@ 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, + 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..261e24d --- /dev/null +++ b/src/utils/pyth.ts @@ -0,0 +1,190 @@ +import type { LucidEvolution, OutRef } from '@lucid-evolution/lucid'; +import type { CollateralAssetOutput } from '@indigo-labs/indigo-sdk'; +import type { PythPricingSummary } from '../types/tx-types.js'; +import { getIndexerClient } from './indexer-client.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 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), + fetchPythStateOref(), + ]); + 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..258acb2 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,15 @@ import { getLucid } from './lucid-provider.js'; */ const CIP20_METADATA_LABEL = 674; +/** + * 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; +} + /** * Build CIP-20 metadata message lines from a TxSummary. * Each line is capped at 64 bytes (CIP-20 requirement). @@ -19,7 +28,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,7 +36,8 @@ export async function buildUnsignedTx( const utxos = await lucid.utxosAt(address); lucid.selectWallet.fromAddress(address, utxos); - const txBuilder = await buildFn(lucid); + const ctx: TxBuildContext = {}; + const txBuilder = await buildFn(lucid, ctx); txBuilder.attachMetadata(CIP20_METADATA_LABEL, { msg: buildCip20Message(summary), @@ -39,6 +49,6 @@ export async function buildUnsignedTx( 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, From d8ae123a2d158b31e936ae0ca72d4cac134b133d Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Fri, 21 Aug 2026 12:29:38 +0300 Subject: [PATCH 2/6] fix(http): per-session transports, HOST/MCP_PORT, Node >=20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported by an integrator running the HTTP transport in production: - The server held a single transport for the whole process, so a client that restarted could never rejoin — initialize returned "Server already initialized" and every other call demanded the lost Mcp-Session-Id, with a process restart the only way out. Each client now gets its own transport and MCP server, keyed by session id and dropped on close; an initialize always starts a fresh session, even when the client replays a session id this process no longer knows. - HOST was ignored and the listener always bound 0.0.0.0, which is a poor default for a process holding a Blockfrost key. HOST now selects the bind address, and MCP_PORT is accepted as an alias for PORT. - engines said Node >=18, but undici needs the File global from Node 20 and the server crashes at startup on 18 with "File is not defined". Refs INDY-131 --- README.md | 13 ++++-- package.json | 2 +- src/server.ts | 120 +++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 109 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 753409c..85f5b6e 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/). @@ -457,7 +463,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 +482,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 +514,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..0227efb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,9 @@ 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'; @@ -24,10 +26,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 +121,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 +136,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 +151,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(); } From 6a6d6a84ee049e956d14ad587300f549861cbd6c Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Fri, 21 Aug 2026 12:42:05 +0300 Subject: [PATCH 3/6] test(pyth): cover builder wiring, add on-chain Pyth state fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The indigo-sdk acceptance tests resolve the Pyth state UTxO straight from the chain — it is whichever UTxO holds the pythStateAssetClass NFT named in system params. resolvePythStateOref() now uses that as a fallback when the analytics endpoint is unavailable, so a momentary outage there does not stop transactions being built. New wiring tests assert that each of open_cdp, withdraw_cdp, mint_cdp, redeem_cdp, freeze_cdp, leverage_cdp and redeem_rob hands the SDK builder the Pyth message and state OutRef in the trailing positions, passes undefined for the price oracle alongside them, and reports the price and submission deadline in the transaction summary — with deposit_cdp checked to confirm price-free builders are untouched. Refs INDY-131 --- .../unit/tools/pyth-write-wiring.test.ts | 240 ++++++++++++++++++ src/tests/unit/utils/pyth.test.ts | 62 +++++ src/utils/index.ts | 1 + src/utils/pyth.ts | 32 ++- 4 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 src/tests/unit/tools/pyth-write-wiring.test.ts 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 index 1ce79b5..0da54e9 100644 --- a/src/tests/unit/utils/pyth.test.ts +++ b/src/tests/unit/utils/pyth.test.ts @@ -8,11 +8,28 @@ 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, @@ -103,6 +120,51 @@ describe('pyth helpers', () => { }); }); + 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', diff --git a/src/utils/index.ts b/src/utils/index.ts index 7b628e6..bddefd2 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -7,6 +7,7 @@ export { buildUnsignedTx } from './tx-builder.js'; export { fetchPythPriceFeed, fetchPythStateOref, + resolvePythStateOref, assertPythFeedFresh, resolvePriceSource, pythSummary, diff --git a/src/utils/pyth.ts b/src/utils/pyth.ts index 261e24d..5db1070 100644 --- a/src/utils/pyth.ts +++ b/src/utils/pyth.ts @@ -1,7 +1,10 @@ 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'; /** @@ -135,6 +138,33 @@ export async function fetchPythStateOref(): Promise { 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. @@ -163,7 +193,7 @@ export async function resolvePriceSource( const [pythFeed, pythStateOref] = await Promise.all([ fetchPythPriceFeed(asset, collateral), - fetchPythStateOref(), + resolvePythStateOref(lucid), ]); assertPythFeedFresh(pythFeed, asset, collateral); From 7a5f89d66560443a0a386ade8e36c4a9859e86ea Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Fri, 21 Aug 2026 12:55:34 +0300 Subject: [PATCH 4/6] fix(tx): retry at Lucid's suggested minimum fee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building a Pyth-priced CDP transaction against mainnet fails on the first completion attempt: RedeemerBuilder: Coin selection had to be updated after building redeemers, possibly leading to incorrect indices. Try setting a minimum fee of 1115401 lovelaces. Script-heavy transactions shift coin selection once execution units are known, which invalidates the redeemer indices. Lucid reports the fee that makes selection stable, so buildUnsignedTx now retries once with it. The retry assembles a fresh TxBuilder rather than completing the failed one again — a builder that failed to complete still holds its mints and redeemers, and reusing it fails with "Duplicate Mint Asset" instead. Verified end to end on mainnet: open_cdp for iUSD now returns unsigned CBOR with both Pyth withdrawals (the state withdraw script and the iUSD feed validator), the Pyth state and feed script-ref reference inputs, a 280s validity window and evaluated execution units for all five redeemers. Refs INDY-131 --- src/tests/unit/utils/tx-builder.test.ts | 121 ++++++++++++++++++++++++ src/utils/tx-builder.ts | 42 ++++++-- 2 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 src/tests/unit/utils/tx-builder.test.ts 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/utils/tx-builder.ts b/src/utils/tx-builder.ts index 258acb2..7e73ba1 100644 --- a/src/utils/tx-builder.ts +++ b/src/utils/tx-builder.ts @@ -8,6 +8,14 @@ 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. @@ -17,6 +25,12 @@ export interface TxBuildContext { 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). @@ -36,14 +50,30 @@ export async function buildUnsignedTx( const utxos = await lucid.utxosAt(address); lucid.selectWallet.fromAddress(address, utxos); - const ctx: TxBuildContext = {}; - const txBuilder = await buildFn(lucid, ctx); + // 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(), From f8a29ea88106d77a7c65ee3214f299955c591d34 Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Fri, 21 Aug 2026 13:57:07 +0300 Subject: [PATCH 5/6] feat(assets): expose iADA, report the real server version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iADA is a live iAsset — the indexer lists it, it has a Pyth feed in system params (iADA/.), its price endpoint serves signed payloads, and four iADA CDPs exist on mainnet — but AssetParam omitted it, so every tool taking an asset refused it. Verified against mainnet after adding it: get_pyth_price and get_oracle_price both return 1.000000000000 for iADA/ADA, the latter reading the on-chain collateral-asset datum. The server also advertised version 0.2.0 in `initialize` and on /health while package.json said 0.3.0, which misleads anyone diagnosing a version problem — exactly the situation this branch came out of. README now documents the 280s Pyth submission deadline and the summary.pyth block alongside the write tools, since a client that signs slowly needs to rebuild rather than submit. Refs INDY-131 --- README.md | 24 +++++++++++++++++------- src/server.ts | 3 ++- src/tests/unit/utils/validators.test.ts | 4 ++++ src/utils/validators.ts | 6 +++--- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 85f5b6e..7262b9b 100644 --- a/README.md +++ b/README.md @@ -313,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 | @@ -329,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 | @@ -340,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 @@ -363,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 @@ -420,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 diff --git a/src/server.ts b/src/server.ts index 0227efb..4459b6e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,7 +10,8 @@ import { registerResources } from './resources/index.js'; import { applyPaymentGate } from './payment.js'; const SERVER_NAME = 'indigo-mcp'; -const SERVER_VERSION = '0.2.0'; +// Kept in step with package.json; clients read this from `initialize`. +const SERVER_VERSION = '0.3.0'; export function createServer(): McpServer { const server = new McpServer({ 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/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']); From 29f6cb62cacbe6139be6ae94b16269978f8ea89d Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Fri, 21 Aug 2026 17:59:51 +0300 Subject: [PATCH 6/6] refactor(server): read the advertised version from package.json Hardcoding it is what let `initialize` and /health drift to 0.2.0 against a 0.3.0 package; esbuild inlines the value at build time, so a release bump is now a single-file change. --- src/server.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index 4459b6e..6891154 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,10 +8,12 @@ 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'; -// Kept in step with package.json; clients read this from `initialize`. -const SERVER_VERSION = '0.3.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({