From b5d8262f28fe8e622e81d2cf90a6975f4b20c08b Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Fri, 13 Mar 2026 08:37:54 -0300 Subject: [PATCH 1/4] fix(provider): tolerate Haskell show string in Koios asset_list and add awaitTx timeout option --- packages/evolution/src/sdk/provider/Blockfrost.ts | 7 +++++-- packages/evolution/src/sdk/provider/Koios.ts | 7 +++++-- packages/evolution/src/sdk/provider/Kupmios.ts | 7 +++++-- packages/evolution/src/sdk/provider/Maestro.ts | 7 +++++-- packages/evolution/src/sdk/provider/Provider.ts | 3 ++- .../src/sdk/provider/internal/BlockfrostEffect.ts | 14 ++++++-------- .../evolution/src/sdk/provider/internal/Koios.ts | 7 ++++++- .../src/sdk/provider/internal/KoiosEffect.ts | 4 ++-- .../src/sdk/provider/internal/KupmiosEffects.ts | 4 ++-- .../src/sdk/provider/internal/MaestroEffect.ts | 7 +++++-- 10 files changed, 43 insertions(+), 24 deletions(-) diff --git a/packages/evolution/src/sdk/provider/Blockfrost.ts b/packages/evolution/src/sdk/provider/Blockfrost.ts index a1321449..718fe08b 100644 --- a/packages/evolution/src/sdk/provider/Blockfrost.ts +++ b/packages/evolution/src/sdk/provider/Blockfrost.ts @@ -54,8 +54,11 @@ export class BlockfrostProvider implements Provider { getDatum = (datumHash: Parameters[0]) => Effect.runPromise(this.Effect.getDatum(datumHash)) - awaitTx = (txHash: Parameters[0], checkInterval?: Parameters[1]) => - Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval)) + awaitTx = ( + txHash: Parameters[0], + checkInterval?: Parameters[1], + timeout?: Parameters[2] + ) => Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval, timeout)) submitTx = (cbor: Parameters[0]) => Effect.runPromise(this.Effect.submitTx(cbor)) diff --git a/packages/evolution/src/sdk/provider/Koios.ts b/packages/evolution/src/sdk/provider/Koios.ts index 6de17985..fd1c1c9c 100644 --- a/packages/evolution/src/sdk/provider/Koios.ts +++ b/packages/evolution/src/sdk/provider/Koios.ts @@ -55,8 +55,11 @@ export class Koios implements Provider { getDatum = (datumHash: Parameters[0]) => Effect.runPromise(this.Effect.getDatum(datumHash)) - awaitTx = (txHash: Parameters[0], checkInterval?: Parameters[1]) => - Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval)) + awaitTx = ( + txHash: Parameters[0], + checkInterval?: Parameters[1], + timeout?: Parameters[2] + ) => Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval, timeout)) submitTx = (tx: Parameters[0]) => Effect.runPromise(this.Effect.submitTx(tx)) diff --git a/packages/evolution/src/sdk/provider/Kupmios.ts b/packages/evolution/src/sdk/provider/Kupmios.ts index aa4956cd..f8cace9d 100644 --- a/packages/evolution/src/sdk/provider/Kupmios.ts +++ b/packages/evolution/src/sdk/provider/Kupmios.ts @@ -67,8 +67,11 @@ export class KupmiosProvider implements Provider { getDatum = (datumHash: Parameters[0]) => Effect.runPromise(this.Effect.getDatum(datumHash)) - awaitTx = (txHash: Parameters[0], checkInterval?: Parameters[1]) => - Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval)) + awaitTx = ( + txHash: Parameters[0], + checkInterval?: Parameters[1], + timeout?: Parameters[2] + ) => Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval, timeout)) evaluateTx = (tx: Parameters[0], additionalUTxOs?: Parameters[1]) => Effect.runPromise(this.Effect.evaluateTx(tx, additionalUTxOs)) diff --git a/packages/evolution/src/sdk/provider/Maestro.ts b/packages/evolution/src/sdk/provider/Maestro.ts index 324dfa9d..44e2fe9b 100644 --- a/packages/evolution/src/sdk/provider/Maestro.ts +++ b/packages/evolution/src/sdk/provider/Maestro.ts @@ -54,8 +54,11 @@ export class MaestroProvider implements Provider { getDatum = (datumHash: Parameters[0]) => Effect.runPromise(this.Effect.getDatum(datumHash)) - awaitTx = (txHash: Parameters[0], checkInterval?: Parameters[1]) => - Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval)) + awaitTx = ( + txHash: Parameters[0], + checkInterval?: Parameters[1], + timeout?: Parameters[2] + ) => Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval, timeout)) submitTx = (cbor: Parameters[0]) => Effect.runPromise(this.Effect.submitTx(cbor)) diff --git a/packages/evolution/src/sdk/provider/Provider.ts b/packages/evolution/src/sdk/provider/Provider.ts index e0e4214c..2e8e7cbc 100644 --- a/packages/evolution/src/sdk/provider/Provider.ts +++ b/packages/evolution/src/sdk/provider/Provider.ts @@ -118,7 +118,8 @@ export interface ProviderEffect { */ readonly awaitTx: ( txHash: TransactionHash.TransactionHash, - checkInterval?: number + checkInterval?: number, + timeout?: number ) => Effect.Effect /** * Submit a signed transaction to the blockchain. diff --git a/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts b/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts index 563ea93e..533cfdaa 100644 --- a/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts +++ b/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts @@ -579,7 +579,7 @@ export const getDatum = (baseUrl: string, projectId?: string) => (datumHash: Dat */ export const awaitTx = (baseUrl: string, projectId?: string) => - (txHash: TransactionHash.TransactionHash, checkInterval: number = 5000) => { + (txHash: TransactionHash.TransactionHash, checkInterval: number = 5000, timeout: number = 300_000) => { const txHashHex = TransactionHash.toHex(txHash) const checkTx = withRateLimit( HttpUtils.get( @@ -592,13 +592,11 @@ export const awaitTx = ) ) - // Poll every checkInterval milliseconds until transaction is found - const pollSchedule = Schedule.fixed(`${checkInterval} millis`).pipe( - Schedule.compose(Schedule.recurs(60)) // Max 60 attempts (5 minutes with 5s interval) - ) - - return Effect.retry(checkTx, pollSchedule).pipe( - Effect.orElse(() => Effect.succeed(false)) // Return false if not found after max attempts + return Effect.retry(checkTx, Schedule.fixed(`${checkInterval} millis`)).pipe( + Effect.timeout(timeout), + Effect.catchAllCause( + (cause) => new ProviderError({ cause, message: "Failed to await transaction confirmation" }) + ) ) } diff --git a/packages/evolution/src/sdk/provider/internal/Koios.ts b/packages/evolution/src/sdk/provider/internal/Koios.ts index 45d2c77d..098f926c 100644 --- a/packages/evolution/src/sdk/provider/internal/Koios.ts +++ b/packages/evolution/src/sdk/provider/internal/Koios.ts @@ -152,7 +152,12 @@ export const InputOutputSchema = Schema.Struct({ }) ), reference_script: Schema.NullOr(ReferenceScriptSchema), - asset_list: Schema.Array(AssetSchema) + // Koios can return asset_list as a Haskell show-formatted string on some endpoints (e.g. collateral + // outputs with many assets). Treat any string as null to avoid a parse failure in those cases. + asset_list: Schema.Union( + Schema.NullOr(Schema.Array(AssetSchema)), + Schema.transform(Schema.String, Schema.Null, { decode: () => null, encode: () => "" }) + ) }) export interface InputOutput extends Schema.Schema.Type {} diff --git a/packages/evolution/src/sdk/provider/internal/KoiosEffect.ts b/packages/evolution/src/sdk/provider/internal/KoiosEffect.ts index dee6bf17..03f9242d 100644 --- a/packages/evolution/src/sdk/provider/internal/KoiosEffect.ts +++ b/packages/evolution/src/sdk/provider/internal/KoiosEffect.ts @@ -268,7 +268,7 @@ export const getDatum = (baseUrl: string, token?: string) => (datumHash: DatumHa export const awaitTx = (baseUrl: string, token?: string) => - (txHash: TransactionHash.TransactionHash, checkInterval = 20000) => + (txHash: TransactionHash.TransactionHash, checkInterval = 20000, timeout = 160_000) => Effect.gen(function* () { const txHashHex = TransactionHash.toHex(txHash) const body = { @@ -284,7 +284,7 @@ export const awaitTx = schedule: Schedule.exponential(checkInterval), until: (result) => result.length > 0 }), - Effect.timeout(160_000), + Effect.timeout(timeout), Effect.catchAllCause( (cause) => new Provider.ProviderError({ cause, message: "Failed to await transaction confirmation" }) ), diff --git a/packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts b/packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts index 5d9fb587..0497c759 100644 --- a/packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts +++ b/packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts @@ -402,7 +402,7 @@ export const evaluateTxEffect = (ogmiosUrl: string, headers?: { ogmiosHeader?: R }) export const awaitTxEffect = (kupoUrl: string, headers?: { kupoHeader?: Record }) => - Effect.fn("awaitTx")(function* (txHash: TransactionHash.TransactionHash, checkInterval = 5000) { + Effect.fn("awaitTx")(function* (txHash: TransactionHash.TransactionHash, checkInterval = 5000, timeout = 160_000) { const txHashHex = TransactionHash.toHex(txHash) const pattern = `${kupoUrl}/matches/*@${txHashHex}?unspent` const schema = Schema.Array(Kupo.UTxOSchema).annotations({ @@ -416,7 +416,7 @@ export const awaitTxEffect = (kupoUrl: string, headers?: { kupoHeader?: Record result.length > 0 }), - Effect.timeout(160_000), + Effect.timeout(timeout), Effect.catchAll((cause) => new Provider.ProviderError({ cause, message: "Failed to await transaction" })), Effect.as(true) ) diff --git a/packages/evolution/src/sdk/provider/internal/MaestroEffect.ts b/packages/evolution/src/sdk/provider/internal/MaestroEffect.ts index 7c23ebbe..331369da 100644 --- a/packages/evolution/src/sdk/provider/internal/MaestroEffect.ts +++ b/packages/evolution/src/sdk/provider/internal/MaestroEffect.ts @@ -260,7 +260,8 @@ export const getDatum = (baseUrl: string, apiKey: string) => (datumHash: DatumHa * Wait for transaction confirmation */ export const awaitTx = - (baseUrl: string, apiKey: string) => (txHash: TransactionHash.TransactionHash, checkInterval?: number) => { + (baseUrl: string, apiKey: string) => + (txHash: TransactionHash.TransactionHash, checkInterval?: number, timeout: number = 160_000) => { const txHashHex = TransactionHash.toHex(txHash) return Effect.gen(function* () { const interval = checkInterval || 5000 // Default 5 seconds @@ -289,7 +290,9 @@ export const awaitTx = // Wait before checking again yield* Effect.sleep(`${interval} millis`) } - }) + }).pipe(Effect.timeout(timeout), Effect.catchAllCause( + (cause) => new ProviderError({ cause, message: "Failed to await transaction confirmation" }) + )) } // ============================================================================ From 365f6e74544eaf62f958900897c61675fcc6b21e Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Fri, 13 Mar 2026 08:38:00 -0300 Subject: [PATCH 2/4] test(provider): add awaitTx timeout conformance test and Koios preview regression test --- packages/evolution/test/provider/conformance.ts | 7 ++++++- .../test/provider/fixtures/constants.ts | 7 +++++++ .../evolution/test/provider/providers.test.ts | 16 +++++++++++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/evolution/test/provider/conformance.ts b/packages/evolution/test/provider/conformance.ts index c07c7b32..5c6c4e81 100644 --- a/packages/evolution/test/provider/conformance.ts +++ b/packages/evolution/test/provider/conformance.ts @@ -15,6 +15,7 @@ import { preprodScriptAddress, preprodStakeAddress, preprodTxHash, + previewTxHash, } from "./fixtures/constants.js" /** @@ -95,11 +96,15 @@ export function registerConformanceTests(factory: () => Provider) { expect(datum).toBeDefined() }) - it("awaitTx", { timeout: 120_000 }, async () => { + it("awaitTx", { timeout: 200_000 }, async () => { const confirmed = await provider.awaitTx(preprodTxHash()) expect(confirmed).toBe(true) }) + it("awaitTx rejects for preview-only tx on preprod", { timeout: 10_000 }, async () => { + await expect(provider.awaitTx(previewTxHash(), 1000, 5000)).rejects.toThrow() + }) + // submitTx and evaluateTx require a valid signed CBOR tx — skipped until we have tx fixtures it.skip("submitTx", async () => {}) it.skip("evaluateTx", async () => {}) diff --git a/packages/evolution/test/provider/fixtures/constants.ts b/packages/evolution/test/provider/fixtures/constants.ts index c90229d8..f3404529 100644 --- a/packages/evolution/test/provider/fixtures/constants.ts +++ b/packages/evolution/test/provider/fixtures/constants.ts @@ -28,6 +28,11 @@ export const PREPROD_STAKE_ADDRESS_BECH32 = export const PREPROD_TX_HASH_HEX = "23f94840ca94f7bb0a5a2b28e5b6a77e61d0414c7427e03d6c4d57b13d5e49b4" +/** Preview-only tx whose collateral output has a Haskell show string asset_list. + * Does NOT exist on preprod — used to test awaitTx timeout behavior. */ +export const PREVIEW_TX_HASH_HEX = + "bfab055361b02a6da919525bfd0447773c640c4e7d65e84783fb3f3b03675688" + /** Datum hash from the oracle UTxO above */ export const PREPROD_DATUM_HASH_HEX = "facfe6aa45fa8023a97a3f13afb823f3966313533f6d68821e65d8431b5a4918" @@ -50,6 +55,8 @@ export const preprodStakeAddress = () => export const preprodTxHash = () => TransactionHash.fromHex(PREPROD_TX_HASH_HEX) +export const previewTxHash = () => TransactionHash.fromHex(PREVIEW_TX_HASH_HEX) + export const preprodDatumHash = () => DatumHash.fromHex(PREPROD_DATUM_HASH_HEX) export const preprodOutRef = () => diff --git a/packages/evolution/test/provider/providers.test.ts b/packages/evolution/test/provider/providers.test.ts index c9629fec..27a32f50 100644 --- a/packages/evolution/test/provider/providers.test.ts +++ b/packages/evolution/test/provider/providers.test.ts @@ -8,13 +8,14 @@ * Add keys to `.env.test.local` at the workspace root (gitignored). * See `.env.test.local.example` for available variables. */ -import { describe } from "vitest" +import { describe, expect, it } from "vitest" import { BlockfrostProvider } from "../../src/sdk/provider/Blockfrost.js" import { Koios } from "../../src/sdk/provider/Koios.js" import { KupmiosProvider } from "../../src/sdk/provider/Kupmios.js" import { MaestroProvider } from "../../src/sdk/provider/Maestro.js" import { registerConformanceTests } from "./conformance.js" +import { previewTxHash } from "./fixtures/constants.js" const isConfigured = (value: string | undefined, placeholder?: string) => Boolean(value && value.trim() !== "" && value !== placeholder) @@ -29,6 +30,7 @@ const parseHeaderJson = (value: string | undefined) => { } const KOIOS_URL = process.env.KOIOS_PREPROD_URL ?? "https://preprod.koios.rest/api/v1" +const KOIOS_PREVIEW_URL = process.env.KOIOS_PREVIEW_URL ?? "https://preview.koios.rest/api/v1" const BLOCKFROST_URL = process.env.BLOCKFROST_PREPROD_URL ?? "https://cardano-preprod.blockfrost.io/api/v0" const BLOCKFROST_KEY = process.env.BLOCKFROST_PREPROD_KEY const MAESTRO_URL = process.env.MAESTRO_PREPROD_URL ?? "https://preprod.gomaestro-api.org/v1" @@ -47,6 +49,18 @@ describe.skipIf(!process.env.KOIOS_ENABLED)("Koios", () => { registerConformanceTests(() => new Koios(KOIOS_URL)) }) +// ── Koios preview: awaitTx with Haskell show string asset_list ──────────────── +// Opt-in via KOIOS_PREVIEW_ENABLED. The tx is confirmed on preview and Koios returns +// a Haskell show string for the collateral output's asset_list. This test fails +// without the InputOutputSchema fix. +describe.skipIf(!process.env.KOIOS_PREVIEW_ENABLED)("Koios (preview)", () => { + it("awaitTx succeeds for tx with Haskell show string asset_list", { timeout: 200_000 }, async () => { + const koios = new Koios(KOIOS_PREVIEW_URL) + const confirmed = await koios.awaitTx(previewTxHash()) + expect(confirmed).toBe(true) + }) +}) + // ── Blockfrost ──────────────────────────────────────────────────────────────── describe.skipIf(!isConfigured(BLOCKFROST_KEY, "preprodXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"))("Blockfrost", () => { registerConformanceTests(() => new BlockfrostProvider(BLOCKFROST_URL, BLOCKFROST_KEY)) From 9701411a17a4a2ef4d9b6c3547d3314801ec616c Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Fri, 13 Mar 2026 08:43:37 -0300 Subject: [PATCH 3/4] release(evolution): fix Koios asset_list parsing and add awaitTx timeout option --- .changeset/fix-koios-asset-list-timeout.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-koios-asset-list-timeout.md diff --git a/.changeset/fix-koios-asset-list-timeout.md b/.changeset/fix-koios-asset-list-timeout.md new file mode 100644 index 00000000..c6948f41 --- /dev/null +++ b/.changeset/fix-koios-asset-list-timeout.md @@ -0,0 +1,5 @@ +--- +"@evolution-sdk/evolution": patch +--- + +Fix `awaitTx` failing with a `ParseError` when Koios returns `asset_list` as a Haskell show-formatted string on collateral outputs. Add configurable `timeout` parameter to `awaitTx` across all providers (Koios, Blockfrost, Maestro, Kupmios). From d6e2147d90c9610100658ff2e471508d10997716 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Fri, 13 Mar 2026 21:58:26 -0300 Subject: [PATCH 4/4] test: strengthen awaitTx rejection assertion and update fixture comment - Assert ProviderError (not just any throw) in the awaitTx rejection test - Update PREVIEW_TX_HASH_HEX comment to document both timeout and Koios asset_list string decoding purposes --- packages/evolution/test/provider/conformance.ts | 4 ++-- packages/evolution/test/provider/fixtures/constants.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/evolution/test/provider/conformance.ts b/packages/evolution/test/provider/conformance.ts index 5c6c4e81..0fde42ab 100644 --- a/packages/evolution/test/provider/conformance.ts +++ b/packages/evolution/test/provider/conformance.ts @@ -4,7 +4,7 @@ import * as Address from "../../src/Address.js" import * as Assets from "../../src/Assets/index.js" import * as AssetsUnit from "../../src/Assets/Unit.js" import * as PoolKeyHash from "../../src/PoolKeyHash.js" -import type { Provider } from "../../src/sdk/provider/Provider.js" +import { type Provider,ProviderError } from "../../src/sdk/provider/Provider.js" import * as TransactionHash from "../../src/TransactionHash.js" import { PREPROD_SCRIPT_ADDRESS_BECH32, @@ -102,7 +102,7 @@ export function registerConformanceTests(factory: () => Provider) { }) it("awaitTx rejects for preview-only tx on preprod", { timeout: 10_000 }, async () => { - await expect(provider.awaitTx(previewTxHash(), 1000, 5000)).rejects.toThrow() + await expect(provider.awaitTx(previewTxHash(), 1000, 5000)).rejects.toThrow(ProviderError) }) // submitTx and evaluateTx require a valid signed CBOR tx — skipped until we have tx fixtures diff --git a/packages/evolution/test/provider/fixtures/constants.ts b/packages/evolution/test/provider/fixtures/constants.ts index f3404529..1b4a15cf 100644 --- a/packages/evolution/test/provider/fixtures/constants.ts +++ b/packages/evolution/test/provider/fixtures/constants.ts @@ -29,7 +29,8 @@ export const PREPROD_TX_HASH_HEX = "23f94840ca94f7bb0a5a2b28e5b6a77e61d0414c7427e03d6c4d57b13d5e49b4" /** Preview-only tx whose collateral output has a Haskell show string asset_list. - * Does NOT exist on preprod — used to test awaitTx timeout behavior. */ + * Does NOT exist on preprod — used to test awaitTx timeout behavior AND Koios + * asset_list string decoding (collateral outputs with many assets). */ export const PREVIEW_TX_HASH_HEX = "bfab055361b02a6da919525bfd0447773c640c4e7d65e84783fb3f3b03675688"