Skip to content

feat(pyth): support Pyth-priced iAssets in transaction builders - #47

Merged
adacapo21 merged 6 commits into
mainfrom
feat/indy-131-pyth-priced-iassets
Aug 21, 2026
Merged

feat(pyth): support Pyth-priced iAssets in transaction builders#47
adacapo21 merged 6 commits into
mainfrom
feat/indy-131-pyth-priced-iassets

Conversation

@adacapo21

Copy link
Copy Markdown
Member

Closes INDY-131.

Reported by an integrator (Discord ticket-2633) building a Python MCP client against @indigoprotocol/indigo-mcp@0.3.0: open_cdp failed for every iAsset tested. Since effectively every iAsset on mainnet is now Pyth-priced, no CDP could be opened through the MCP at all.

Pyth pricing

@indigo-labs/indigo-sdk already accepts a signed Pyth message and the Pyth state OutRef as trailing arguments on the affected builders — the server just never supplied them. Both come from the analytics API:

  • GET /v3/assets/{iasset}/{collateral}/pricepythPayload, the signed Solana-format message
  • GET /v3/pyth-state/utxo → the Pyth state OutRef

New src/utils/pyth.ts wraps both. resolvePriceSource() returns whichever oracle an asset uses — an oracle-NFT OutRef, or a freshly fetched Pyth message plus state OutRef — so open_cdp, withdraw_cdp, mint_cdp, redeem_cdp, freeze_cdp, leverage_cdp and redeem_rob no longer branch on it and the PYTH_UNSUPPORTED error is gone.

Two details worth review:

  • Freshness. The on-chain feed validator caps validity at the price timestamp plus 280s, so a stale payload yields an unsubmittable transaction. A payload already outside that window is rejected up front with a retry hint, and the price plus a submitBefore deadline are returned in the transaction summary — the reporter signs on a hardware wallet, so knowing the deadline matters.
  • State OutRef fallback. The indigo-sdk acceptance tests resolve the Pyth state UTxO straight from the chain (whichever UTxO holds the pythStateAssetClass NFT). That is used as a fallback when the analytics endpoint is unavailable.

get_pyth_price and get_oracle_price now return the live price alongside the on-chain feed configuration.

Minimum-fee retry

Building a Pyth-priced CDP transaction against mainnet failed 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 redeemer indices. buildUnsignedTx retries once at the fee Lucid suggests, assembling a fresh TxBuilder — completing the failed one again fails with Duplicate Mint Asset, since it still holds its mints and redeemers. Without this, Pyth support alone would still not produce a usable transaction.

Transport and config fixes from the same report

  • Per-session HTTP transports. The server held one transport for the whole process, so a restarted client could never rejoin: initialize returned "Server already initialized" and every other call demanded the lost Mcp-Session-Id. 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 and MCP_PORT. The listener always bound 0.0.0.0 regardless of HOST — 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.node>=20. undici needs the File global from Node 20; on Node 18 the server crashed at startup with ReferenceError: File is not defined instead of a clear version error.

Verification

Mainnet, through the HTTP transport:

open_cdp iUSD, 10 ADA collateral → 1 iUSD
txHash 108f0f69b1dba4ce35a7f3e83950c892f6c604649acb3b47c63a1253b990811c, fee 1159200

Decoding the body: two withdrawals (the Pyth state withdraw_script and the iUSD pythFeedValHash), the Pyth state and feed script-ref reference inputs, a 280-slot validity window, the CDP + iUSD mint, and evaluated execution units on all five redeemers — meaning the Plutus scripts, both Pyth withdrawal scripts included, ran and passed.

Also checked without a wallet: for all six iAssets the payload decodes with the SDK's own decoder and the price derived from it matches the analytics price to within 1e-11; the state UTxO and all six feed validator script refs are unspent on-chain with hashes matching system params.

Session behaviour verified live: concurrent sessions, a restarted client re-initializing while replaying a stale session id, 404 for unknown sessions and 400 for missing ones.

25 new tests (Pyth helpers, builder-argument wiring for all seven tools, the min-fee retry). Suite 136 passing; typecheck, lint, format and build clean.

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
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
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
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
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
@adacapo21

Copy link
Copy Markdown
Member Author

Pushed a fifth commit (f8a29ea) with three things found while verifying the branch against mainnet:

iADA was unreachable. AssetParam listed six assets; the protocol has seven. iADA is in the indexer's asset list, has a Pyth feed in system params (iADA/.), its price endpoint serves signed payloads, and four iADA CDPs exist on mainnet — but every tool taking an asset rejected it. Verified after adding: get_pyth_price and get_oracle_price both return 1.000000000000 for iADA/ADA, the latter by reading the on-chain collateral-asset datum.

The server misreported its version. SERVER_VERSION was pinned at 0.2.0 while package.json said 0.3.0, so initialize and /health both lied — awkward for anyone diagnosing which build they are on, which is how this whole branch started.

README. Documents the 280s Pyth submission deadline and the summary.pyth block alongside the write tools, and the asset lists (7 places) now include iADA.

Suite is 137 passing; typecheck, lint, format and build clean.

Separately: INDEXER_URL is still distributing the retired /api/v1 base through the setup wizard, .env.example and fly.toml, which 404s every read tool. That is fixed on fix/setup-wizard-indexer-url — independent of this PR, no overlapping files, and worth merging first.

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.
@adacapo21
adacapo21 merged commit 91bc427 into main Aug 21, 2026
3 checks passed
@adacapo21 adacapo21 mentioned this pull request Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant