Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-koios-asset-list-timeout.md
Original file line number Diff line number Diff line change
@@ -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).
7 changes: 5 additions & 2 deletions packages/evolution/src/sdk/provider/Blockfrost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,11 @@ export class BlockfrostProvider implements Provider {

getDatum = (datumHash: Parameters<Provider["getDatum"]>[0]) => Effect.runPromise(this.Effect.getDatum(datumHash))

awaitTx = (txHash: Parameters<Provider["awaitTx"]>[0], checkInterval?: Parameters<Provider["awaitTx"]>[1]) =>
Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval))
awaitTx = (
txHash: Parameters<Provider["awaitTx"]>[0],
checkInterval?: Parameters<Provider["awaitTx"]>[1],
timeout?: Parameters<Provider["awaitTx"]>[2]
) => Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval, timeout))

submitTx = (cbor: Parameters<Provider["submitTx"]>[0]) => Effect.runPromise(this.Effect.submitTx(cbor))

Expand Down
7 changes: 5 additions & 2 deletions packages/evolution/src/sdk/provider/Koios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ export class Koios implements Provider {

getDatum = (datumHash: Parameters<Provider["getDatum"]>[0]) => Effect.runPromise(this.Effect.getDatum(datumHash))

awaitTx = (txHash: Parameters<Provider["awaitTx"]>[0], checkInterval?: Parameters<Provider["awaitTx"]>[1]) =>
Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval))
awaitTx = (
txHash: Parameters<Provider["awaitTx"]>[0],
checkInterval?: Parameters<Provider["awaitTx"]>[1],
timeout?: Parameters<Provider["awaitTx"]>[2]
) => Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval, timeout))

submitTx = (tx: Parameters<Provider["submitTx"]>[0]) => Effect.runPromise(this.Effect.submitTx(tx))

Expand Down
7 changes: 5 additions & 2 deletions packages/evolution/src/sdk/provider/Kupmios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,11 @@ export class KupmiosProvider implements Provider {

getDatum = (datumHash: Parameters<Provider["getDatum"]>[0]) => Effect.runPromise(this.Effect.getDatum(datumHash))

awaitTx = (txHash: Parameters<Provider["awaitTx"]>[0], checkInterval?: Parameters<Provider["awaitTx"]>[1]) =>
Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval))
awaitTx = (
txHash: Parameters<Provider["awaitTx"]>[0],
checkInterval?: Parameters<Provider["awaitTx"]>[1],
timeout?: Parameters<Provider["awaitTx"]>[2]
) => Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval, timeout))

evaluateTx = (tx: Parameters<Provider["evaluateTx"]>[0], additionalUTxOs?: Parameters<Provider["evaluateTx"]>[1]) =>
Effect.runPromise(this.Effect.evaluateTx(tx, additionalUTxOs))
Expand Down
7 changes: 5 additions & 2 deletions packages/evolution/src/sdk/provider/Maestro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,11 @@ export class MaestroProvider implements Provider {

getDatum = (datumHash: Parameters<Provider["getDatum"]>[0]) => Effect.runPromise(this.Effect.getDatum(datumHash))

awaitTx = (txHash: Parameters<Provider["awaitTx"]>[0], checkInterval?: Parameters<Provider["awaitTx"]>[1]) =>
Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval))
awaitTx = (
txHash: Parameters<Provider["awaitTx"]>[0],
checkInterval?: Parameters<Provider["awaitTx"]>[1],
timeout?: Parameters<Provider["awaitTx"]>[2]
) => Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval, timeout))

submitTx = (cbor: Parameters<Provider["submitTx"]>[0]) => Effect.runPromise(this.Effect.submitTx(cbor))

Expand Down
3 changes: 2 additions & 1 deletion packages/evolution/src/sdk/provider/Provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ export interface ProviderEffect {
*/
readonly awaitTx: (
txHash: TransactionHash.TransactionHash,
checkInterval?: number
checkInterval?: number,
timeout?: number
) => Effect.Effect<boolean, ProviderError>
/**
* Submit a signed transaction to the blockchain.
Expand Down
14 changes: 6 additions & 8 deletions packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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" })
)
)
Comment on lines +595 to 600
}
Comment on lines +595 to 601

Expand Down
7 changes: 6 additions & 1 deletion packages/evolution/src/sdk/provider/internal/Koios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof InputOutputSchema> {}
Expand Down
4 changes: 2 additions & 2 deletions packages/evolution/src/sdk/provider/internal/KoiosEffect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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" })
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ export const evaluateTxEffect = (ogmiosUrl: string, headers?: { ogmiosHeader?: R
})

export const awaitTxEffect = (kupoUrl: string, headers?: { kupoHeader?: Record<string, string> }) =>
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({
Expand All @@ -416,7 +416,7 @@ export const awaitTxEffect = (kupoUrl: string, headers?: { kupoHeader?: Record<s
schedule: Schedule.exponential(checkInterval),
until: (result) => 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)
)
Expand Down
7 changes: 5 additions & 2 deletions packages/evolution/src/sdk/provider/internal/MaestroEffect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" })
))
}

// ============================================================================
Expand Down
9 changes: 7 additions & 2 deletions packages/evolution/test/provider/conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,6 +15,7 @@ import {
preprodScriptAddress,
preprodStakeAddress,
preprodTxHash,
previewTxHash,
} from "./fixtures/constants.js"

/**
Expand Down Expand Up @@ -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(ProviderError)
})

// submitTx and evaluateTx require a valid signed CBOR tx — skipped until we have tx fixtures
it.skip("submitTx", async () => {})
it.skip("evaluateTx", async () => {})
Expand Down
8 changes: 8 additions & 0 deletions packages/evolution/test/provider/fixtures/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ 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 AND Koios
* asset_list string decoding (collateral outputs with many assets). */
export const PREVIEW_TX_HASH_HEX =
"bfab055361b02a6da919525bfd0447773c640c4e7d65e84783fb3f3b03675688"

/** Datum hash from the oracle UTxO above */
export const PREPROD_DATUM_HASH_HEX =
"facfe6aa45fa8023a97a3f13afb823f3966313533f6d68821e65d8431b5a4918"
Expand All @@ -50,6 +56,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 = () =>
Expand Down
16 changes: 15 additions & 1 deletion packages/evolution/test/provider/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"
Expand All @@ -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))
Expand Down
Loading