From c1948ae42227980e5fc2d45e547a5acbc5b3fce6 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Sat, 10 Jan 2026 15:34:01 -0700 Subject: [PATCH 1/3] fix(ogmios): send raw Plutus script bytes without CBOR envelope to Ogmios v6 Ogmios v6 with explicit JSON notation expects raw Plutus script bytes without CBOR tag envelope. Previously we were sending the full CBOR encoding [tag, bytes] which caused 'couldn't decode plutus script' errors. Now we send: - Plutus scripts: raw bytes only (script.bytes) - Native scripts: inner script structure CBOR This fixes script evaluation when using reference scripts with Kupmios provider. --- .../evolution/src/sdk/provider/internal/Ogmios.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/evolution/src/sdk/provider/internal/Ogmios.ts b/packages/evolution/src/sdk/provider/internal/Ogmios.ts index b7d98982..fb4624a7 100644 --- a/packages/evolution/src/sdk/provider/internal/Ogmios.ts +++ b/packages/evolution/src/sdk/provider/internal/Ogmios.ts @@ -7,6 +7,7 @@ import type * as CoreAssets from "../../../Assets/index.js" import * as Bytes from "../../../Bytes.js" import * as PlutusData from "../../../Data.js" import type * as DatumOption from "../../../DatumOption.js" +import * as NativeScripts from "../../../NativeScripts.js" import * as PolicyId from "../../../PolicyId.js" import * as CoreScript from "../../../Script.js" import * as TransactionHash from "../../../TransactionHash.js" @@ -161,13 +162,16 @@ export const toOgmiosUTxOs = (utxos: Array | undefined): Array Date: Mon, 12 Jan 2026 09:39:29 -0700 Subject: [PATCH 2/3] feat(provider): full UTxO resolution with scripts and datums --- .changeset/sour-bats-grin.md | 27 + packages/evolution/src/PoolKeyHash.ts | 49 +- .../src/sdk/builders/TransactionBuilder.ts | 41 +- .../src/sdk/builders/TxBuilderImpl.ts | 30 +- .../src/sdk/builders/phases/Evaluation.ts | 9 +- .../src/sdk/provider/internal/Blockfrost.ts | 158 +++-- .../sdk/provider/internal/BlockfrostEffect.ts | 570 ++++++++++++++---- .../src/sdk/provider/internal/HttpUtils.ts | 5 +- .../sdk/provider/internal/KupmiosEffects.ts | 22 +- .../src/sdk/provider/internal/Ogmios.ts | 2 +- 10 files changed, 691 insertions(+), 222 deletions(-) create mode 100644 .changeset/sour-bats-grin.md diff --git a/.changeset/sour-bats-grin.md b/.changeset/sour-bats-grin.md new file mode 100644 index 00000000..ffec6eea --- /dev/null +++ b/.changeset/sour-bats-grin.md @@ -0,0 +1,27 @@ +--- +"@evolution-sdk/evolution": patch +--- + +### Provider Improvements: Full UTxO Resolution with Scripts and Datums + +**Blockfrost Provider:** +- Added pagination support for `getUtxos` and `getUtxosWithUnit` (handles addresses with >100 UTxOs) +- Full UTxO resolution now fetches reference scripts and resolves datum hashes +- Updated `BlockfrostDelegation` schema to match actual `/accounts/{stake_address}` endpoint response +- Added `BlockfrostAssetAddress` and `BlockfrostTxUtxos` schemas for proper endpoint handling +- Improved `evaluateTx` to always use the more reliable `/utils/txs/evaluate/utxos` JSON endpoint +- Added `EvaluationFailure` handling in evaluation response schema +- Fixed delegation transformation to use `withdrawable_amount` for rewards +- Added Conway era governance parameters (`drep_deposit`, `gov_action_deposit`) to protocol params + +**Kupmios Provider:** +- Removed unnecessary double CBOR encoding for Plutus scripts (Kupo returns properly encoded scripts) + +**PoolKeyHash:** +- Added `FromBech32` schema for parsing pool IDs in bech32 format (pool1...) +- Added `fromBech32` and `toBech32` helper functions + +**Transaction Builder:** +- Added `passAdditionalUtxos` option to control UTxO passing to provider evaluators (default: false to avoid OverlappingAdditionalUtxo errors) +- Added `scriptDataFormat` option to choose between Conway-era array format and Babbage-era map format for redeemers +- Fixed cost model detection to check reference scripts (not just witness set scripts) for Plutus version detection diff --git a/packages/evolution/src/PoolKeyHash.ts b/packages/evolution/src/PoolKeyHash.ts index e7d1c6a3..7ed55cb1 100644 --- a/packages/evolution/src/PoolKeyHash.ts +++ b/packages/evolution/src/PoolKeyHash.ts @@ -1,4 +1,5 @@ -import { Equal, FastCheck, Hash, Inspectable, Schema } from "effect" +import { bech32 } from "@scure/base" +import { Effect as Eff, Equal, FastCheck, Hash, Inspectable, ParseResult, Schema } from "effect" import * as Bytes from "./Bytes.js" import * as Hash28 from "./Hash28.js" @@ -59,6 +60,36 @@ export const FromHex = Schema.compose(Hash28.BytesFromHex, FromBytes).annotation identifier: "PoolKeyHash.FromHex" }) +/** + * Schema transformer from bech32 string (pool1...) to PoolKeyHash. + * + * @since 2.0.0 + * @category schemas + */ +export const FromBech32 = Schema.transformOrFail(Schema.String, Schema.typeSchema(PoolKeyHash), { + strict: true, + encode: (poolKeyHash) => + Eff.gen(function* () { + const words = bech32.toWords(poolKeyHash.hash) + return bech32.encode("pool", words, false) + }), + decode: (fromA, _, ast) => + Eff.gen(function* () { + const result = yield* Eff.try({ + try: () => { + const decoded = bech32.decode(fromA as any, false) + const bytes = bech32.fromWords(decoded.words) + return new Uint8Array(bytes) + }, + catch: () => new ParseResult.Type(ast, fromA, `Failed to decode Bech32 pool id: ${fromA}`) + }) + return yield* ParseResult.decode(FromBytes)(result) + }) +}).annotations({ + identifier: "PoolKeyHash.FromBech32", + description: "Transforms Bech32 pool id string to PoolKeyHash" +}) + /** * FastCheck arbitrary for generating random PoolKeyHash instances. * @@ -105,3 +136,19 @@ export const toBytes = Schema.encodeSync(FromBytes) * @category encoding */ export const toHex = Schema.encodeSync(FromHex) + +/** + * Parse PoolKeyHash from bech32 string (pool1...). + * + * @since 2.0.0 + * @category parsing + */ +export const fromBech32 = Schema.decodeSync(FromBech32) + +/** + * Encode PoolKeyHash to bech32 string (pool1...). + * + * @since 2.0.0 + * @category encoding + */ +export const toBech32 = Schema.encodeSync(FromBech32) diff --git a/packages/evolution/src/sdk/builders/TransactionBuilder.ts b/packages/evolution/src/sdk/builders/TransactionBuilder.ts index 5d21993a..1ae12276 100644 --- a/packages/evolution/src/sdk/builders/TransactionBuilder.ts +++ b/packages/evolution/src/sdk/builders/TransactionBuilder.ts @@ -381,8 +381,14 @@ const resolveEvaluator = (config: TxBuilderConfig, options?: BuildOptions): Eval additionalUtxos: ReadonlyArray | undefined, _context: EvaluationContext ) => { - // Provider now accepts Transaction directly - return config.provider!.Effect.evaluateTx(tx, additionalUtxos as Array | undefined).pipe( + // Provider-based evaluators (Ogmios, Blockfrost) resolve UTxOs from chain. + // By default, don't pass additionalUtxos to avoid OverlappingAdditionalUtxo errors. + // Use passAdditionalUtxos: true for edge cases (e.g., UTxOs not yet on chain). + const utxosToPass = options?.passAdditionalUtxos + ? (additionalUtxos as Array | undefined) + : undefined + + return config.provider!.Effect.evaluateTx(tx, utxosToPass).pipe( Effect.mapError((providerError) => { // Parse provider error into structured failures const failures = parseProviderError(providerError) @@ -1030,6 +1036,37 @@ export interface BuildOptions { */ readonly evaluator?: Evaluator + /** + * Pass additional UTxOs to provider-based evaluators. + * + * By default, provider evaluators (Ogmios, Blockfrost) don't receive additionalUtxos + * because they can resolve UTxOs from the chain, and passing them causes + * "OverlappingAdditionalUtxo" errors. + * + * Set to `true` for edge cases where you need to evaluate with UTxOs that + * are not yet on chain (e.g., chained transactions, emulator scenarios). + * + * Note: This option has no effect on custom evaluators (Aiken, Scalus) which + * always receive additionalUtxos since they cannot resolve from chain. + * + * @default false + * @since 2.0.0 + */ + readonly passAdditionalUtxos?: boolean + + /** + * Format for encoding redeemers in the script data hash. + * + * - `"array"` (DEFAULT): Conway-era format, redeemers encoded as array + * - `"map"`: Babbage-era format, redeemers encoded as map + * + * Use `"map"` for Babbage compatibility or debugging. + * + * @default "array" + * @since 2.0.0 + */ + readonly scriptDataFormat?: "array" | "map" + /** * Custom slot configuration for script evaluation. * diff --git a/packages/evolution/src/sdk/builders/TxBuilderImpl.ts b/packages/evolution/src/sdk/builders/TxBuilderImpl.ts index 8e0e5936..2d5b64b1 100644 --- a/packages/evolution/src/sdk/builders/TxBuilderImpl.ts +++ b/packages/evolution/src/sdk/builders/TxBuilderImpl.ts @@ -670,9 +670,27 @@ export const assembleTransaction = ( // Only include cost models for Plutus versions actually used in the transaction // The scriptDataHash must use the same languages as the node will compute - const hasPlutusV1 = plutusV1Scripts.length > 0 - const hasPlutusV2 = plutusV2Scripts.length > 0 - const hasPlutusV3 = plutusV3Scripts.length > 0 + // Check both witness set scripts AND reference scripts + let hasPlutusV1 = plutusV1Scripts.length > 0 + let hasPlutusV2 = plutusV2Scripts.length > 0 + let hasPlutusV3 = plutusV3Scripts.length > 0 + + // Also check reference inputs for Plutus scripts + for (const refUtxo of state.referenceInputs) { + if (refUtxo.scriptRef) { + switch (refUtxo.scriptRef._tag) { + case "PlutusV1": + hasPlutusV1 = true + break + case "PlutusV2": + hasPlutusV2 = true + break + case "PlutusV3": + hasPlutusV3 = true + break + } + } + } const plutusV1Costs = hasPlutusV1 ? Object.values(fullProtocolParams.costModels.PlutusV1).map((v) => BigInt(v)) @@ -693,8 +711,10 @@ export const assembleTransaction = ( }) // Compute the hash of script data (redeemers + optional datums + cost models) - scriptDataHash = hashScriptData(redeemers, costModels, plutusDataArray.length > 0 ? plutusDataArray : undefined) - yield* Effect.logDebug(`[Assembly] Computed scriptDataHash: ${scriptDataHash.hash.toString()}`) + const buildOpts = yield* BuildOptionsTag + const scriptDataFmt = buildOpts.scriptDataFormat ?? "array" + scriptDataHash = hashScriptData(redeemers, costModels, plutusDataArray.length > 0 ? plutusDataArray : undefined, scriptDataFmt) + yield* Effect.logDebug(`[Assembly] Computed scriptDataHash (format=${scriptDataFmt}): ${scriptDataHash.hash.toString()}`) } yield* Effect.logDebug(`[Assembly] WitnessSet populated:`) diff --git a/packages/evolution/src/sdk/builders/phases/Evaluation.ts b/packages/evolution/src/sdk/builders/phases/Evaluation.ts index 9907d169..744c65a6 100644 --- a/packages/evolution/src/sdk/builders/phases/Evaluation.ts +++ b/packages/evolution/src/sdk/builders/phases/Evaluation.ts @@ -497,16 +497,17 @@ export const executeEvaluation = (): Effect.Effect< } // Step 7: Call evaluator - // Pass the selected UTxOs AND reference inputs so Ogmios can resolve script hashes - // Reference inputs are needed when scripts reference on-chain validators or datums + // Always pass additionalUtxos - provider-based evaluators ignore them by default + // (they resolve UTxOs from chain). Custom evaluators (Aiken, Scalus) use them. + // Use passAdditionalUtxos: true in BuildOptions to override for edge cases. const additionalUtxos = [ ...Array.from(updatedState.selectedUtxos.values()), ...updatedState.referenceInputs ] - + const evalResults = yield* evaluator.evaluate( transaction, - additionalUtxos, // UTxOs being spent + reference inputs (needed to resolve script hashes and datums) + additionalUtxos, evaluationContext ).pipe( Effect.mapError( diff --git a/packages/evolution/src/sdk/provider/internal/Blockfrost.ts b/packages/evolution/src/sdk/provider/internal/Blockfrost.ts index c3855844..28f6cf12 100644 --- a/packages/evolution/src/sdk/provider/internal/Blockfrost.ts +++ b/packages/evolution/src/sdk/provider/internal/Blockfrost.ts @@ -5,12 +5,9 @@ import { Schema } from "effect" -import * as CoreAddress from "../../../Address.js" import * as CoreAssets from "../../../Assets/index.js" import * as PoolKeyHash from "../../../PoolKeyHash.js" import * as Redeemer from "../../../Redeemer.js" -import * as TransactionHash from "../../../TransactionHash.js" -import * as CoreUTxO from "../../../UTxO.js" import type { EvalRedeemer } from "../../EvalRedeemer.js" import type * as Provider from "../Provider.js" @@ -41,7 +38,10 @@ export const BlockfrostProtocolParameters = Schema.Struct({ collateral_percent: Schema.optional(Schema.Number), max_collateral_inputs: Schema.optional(Schema.Number), coins_per_utxo_size: Schema.optional(Schema.String), - min_fee_ref_script_cost_per_byte: Schema.optional(Schema.Number) + min_fee_ref_script_cost_per_byte: Schema.optional(Schema.Number), + // Conway era governance parameters + drep_deposit: Schema.optional(Schema.String), + gov_action_deposit: Schema.optional(Schema.String) }) export type BlockfrostProtocolParameters = Schema.Schema.Type @@ -73,61 +73,84 @@ export const BlockfrostUTxO = Schema.Struct({ export type BlockfrostUTxO = Schema.Schema.Type /** - * Blockfrost delegation response schema + * Blockfrost delegation/account response schema + * From /accounts/{stake_address} endpoint */ export const BlockfrostDelegation = Schema.Struct({ + stake_address: Schema.String, active: Schema.Boolean, + active_epoch: Schema.NullOr(Schema.Number), pool_id: Schema.NullOr(Schema.String), - live_stake: Schema.String, - active_stake: Schema.String + controlled_amount: Schema.String, + rewards_sum: Schema.String, + withdrawals_sum: Schema.String, + reserves_sum: Schema.String, + treasury_sum: Schema.String, + withdrawable_amount: Schema.String, + drep_id: Schema.NullOr(Schema.String) }) export type BlockfrostDelegation = Schema.Schema.Type /** - * Blockfrost transaction submit response schema + * Blockfrost asset address response schema (from /assets/{unit}/addresses endpoint) */ -export const BlockfrostSubmitResponse = Schema.String +export const BlockfrostAssetAddress = Schema.Struct({ + address: Schema.String, + quantity: Schema.String +}) -export type BlockfrostSubmitResponse = Schema.Schema.Type +export type BlockfrostAssetAddress = Schema.Schema.Type /** - * Blockfrost datum response schema + * Blockfrost transaction UTxO output schema (from /txs/{hash}/utxos endpoint) + * Different from regular UTxO - uses output_index instead of tx_index */ -export const BlockfrostDatum = Schema.Struct({ - json_value: Schema.optional(Schema.Unknown), - cbor: Schema.String +export const BlockfrostTxUtxoOutput = Schema.Struct({ + address: Schema.String, + amount: Schema.Array(BlockfrostAmount), + output_index: Schema.Number, + data_hash: Schema.NullOr(Schema.String), + inline_datum: Schema.NullOr(Schema.String), + reference_script_hash: Schema.NullOr(Schema.String), + collateral: Schema.Boolean, + consumed_by_tx: Schema.NullOr(Schema.String) }) -export type BlockfrostDatum = Schema.Schema.Type +export type BlockfrostTxUtxoOutput = Schema.Schema.Type /** - * Blockfrost transaction evaluation response schema + * Blockfrost transaction UTxOs response schema (from /txs/{hash}/utxos endpoint) */ -export const BlockfrostRedeemer = Schema.Struct({ - tx_index: Schema.Number, - purpose: Schema.Literal("spend", "mint", "cert", "reward"), - unit_mem: Schema.String, - unit_steps: Schema.String, - fee: Schema.String +export const BlockfrostTxUtxos = Schema.Struct({ + hash: Schema.String, + inputs: Schema.Array(Schema.Unknown), + outputs: Schema.Array(BlockfrostTxUtxoOutput) }) +export type BlockfrostTxUtxos = Schema.Schema.Type + /** - * Blockfrost evaluation response schema (array format) - * Used by /utils/txs/evaluate endpoint (CBOR body, no additional UTxOs) + * Blockfrost transaction submit response schema */ -export const BlockfrostEvaluationResponse = Schema.Struct({ - result: Schema.Struct({ - EvaluationResult: Schema.Array(BlockfrostRedeemer) - }) +export const BlockfrostSubmitResponse = Schema.String + +export type BlockfrostSubmitResponse = Schema.Schema.Type + +/** + * Blockfrost datum response schema + */ +export const BlockfrostDatum = Schema.Struct({ + json_value: Schema.optional(Schema.Unknown), + cbor: Schema.String }) -export type BlockfrostEvaluationResponse = Schema.Schema.Type +export type BlockfrostDatum = Schema.Schema.Type /** * Schema for JSONWSP-wrapped Ogmios evaluation response * Used by /utils/txs/evaluate/utxos endpoint - * Format: { type: "jsonwsp/response", result: { EvaluationResult: { "spend:0": { memory, steps } } } } + * Can contain either EvaluationResult (success) or EvaluationFailure (error) */ export const JsonwspOgmiosEvaluationResponse = Schema.Struct({ type: Schema.optional(Schema.String), @@ -135,13 +158,14 @@ export const JsonwspOgmiosEvaluationResponse = Schema.Struct({ servicename: Schema.optional(Schema.String), methodname: Schema.optional(Schema.String), result: Schema.Struct({ - EvaluationResult: Schema.Record({ + EvaluationResult: Schema.optional(Schema.Record({ key: Schema.String, // "spend:0", "mint:1", etc. value: Schema.Struct({ memory: Schema.Number, steps: Schema.Number }) - }) + })), + EvaluationFailure: Schema.optional(Schema.Unknown) }), reflection: Schema.optional(Schema.Unknown) }) @@ -173,8 +197,8 @@ export const transformProtocolParameters = ( collateralPercentage: blockfrostParams.collateral_percent || 0, maxCollateralInputs: blockfrostParams.max_collateral_inputs || 0, minFeeRefScriptCostPerByte: blockfrostParams.min_fee_ref_script_cost_per_byte || 0, - drepDeposit: 0n, // Not provided by this endpoint - govActionDeposit: 0n, // Not provided by this endpoint + drepDeposit: blockfrostParams.drep_deposit ? BigInt(blockfrostParams.drep_deposit) : 0n, + govActionDeposit: blockfrostParams.gov_action_deposit ? BigInt(blockfrostParams.gov_action_deposit) : 0n, costModels: { PlutusV1: (blockfrostParams.cost_models?.PlutusV1 as Record) || {}, PlutusV2: (blockfrostParams.cost_models?.PlutusV2 as Record) || {}, @@ -212,61 +236,16 @@ export const transformAmounts = (amounts: ReadonlyArray): Core return assets } -/** - * Transform Blockfrost UTxO to Core UTxO - */ -export const transformUTxO = (blockfrostUtxo: BlockfrostUTxO, addressStr: string): CoreUTxO.UTxO => { - const assets = transformAmounts(blockfrostUtxo.amount) - const address = CoreAddress.fromBech32(addressStr) - const transactionId = TransactionHash.fromHex(blockfrostUtxo.tx_hash) - - // TODO: Handle datum and script ref when Core types support them - // let datumOption: Datum.Datum | undefined = undefined - // if (blockfrostUtxo.inline_datum) { - // datumOption = { type: "inlineDatum", inline: blockfrostUtxo.inline_datum } - // } else if (blockfrostUtxo.data_hash) { - // datumOption = { type: "datumHash", hash: blockfrostUtxo.data_hash } - // } - - return new CoreUTxO.UTxO({ - transactionId, - index: BigInt(blockfrostUtxo.output_index), - address, - assets - }) -} - /** * Transform Blockfrost delegation to delegation info */ export const transformDelegation = (blockfrostDelegation: BlockfrostDelegation): Provider.Delegation => { - if (!blockfrostDelegation.active || !blockfrostDelegation.pool_id) { - return { poolId: null, rewards: 0n } + if (!blockfrostDelegation.pool_id) { + return { poolId: null, rewards: BigInt(blockfrostDelegation.withdrawable_amount) } } - const poolId = Schema.decodeSync(PoolKeyHash.FromHex)(blockfrostDelegation.pool_id) - return { poolId, rewards: BigInt(blockfrostDelegation.active_stake) } -} - -/** - * Transform Blockfrost evaluation response to Evolution SDK format - */ -export const transformEvaluationResult = ( - blockfrostResponse: BlockfrostEvaluationResponse -): Array => { - return blockfrostResponse.result.EvaluationResult.map((redeemer: Schema.Schema.Type) => { - // Blockfrost uses "cert" and "reward" which match Core terminology - const tag: Redeemer.RedeemerTag = redeemer.purpose as Redeemer.RedeemerTag - - return { - ex_units: new Redeemer.ExUnits({ - mem: BigInt(redeemer.unit_mem), - steps: BigInt(redeemer.unit_steps) - }), - redeemer_index: redeemer.tx_index, - redeemer_tag: tag - } - }) + const poolId = Schema.decodeSync(PoolKeyHash.FromBech32)(blockfrostDelegation.pool_id) + return { poolId, rewards: BigInt(blockfrostDelegation.withdrawable_amount) } } /** @@ -277,8 +256,19 @@ export const transformEvaluationResult = ( export const transformJsonwspOgmiosEvaluationResult = ( jsonwspResponse: JsonwspOgmiosEvaluationResponse ): Array => { - const result: Array = [] + // Check for evaluation failure + if (jsonwspResponse.result.EvaluationFailure) { + const failure = jsonwspResponse.result.EvaluationFailure + throw new Error(`Script evaluation failed: ${JSON.stringify(failure)}`) + } + + // Handle success case const evaluationResult = jsonwspResponse.result.EvaluationResult + if (!evaluationResult) { + throw new Error("No evaluation result returned from Blockfrost") + } + + const result: Array = [] for (const [key, budget] of Object.entries(evaluationResult)) { // Parse "spend:0", "mint:1", etc. diff --git a/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts b/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts index 82aa5aee..c0c8e047 100644 --- a/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts +++ b/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts @@ -3,18 +3,24 @@ * Internal module implementing all provider operations using Effect pattern */ +import { HttpClientError } from "@effect/platform" import { Effect, Schedule, Schema } from "effect" import * as CoreAddress from "../../../Address.js" import * as Bytes from "../../../Bytes.js" import type * as Credential from "../../../Credential.js" import * as PlutusData from "../../../Data.js" -import type * as DatumOption from "../../../DatumOption.js" +import * as DatumOption from "../../../DatumOption.js" +import * as PlutusV1 from "../../../PlutusV1.js" +import * as PlutusV2 from "../../../PlutusV2.js" +import * as PlutusV3 from "../../../PlutusV3.js" import type * as RewardAddress from "../../../RewardAddress.js" +import type * as Script from "../../../Script.js" import * as Transaction from "../../../Transaction.js" import * as TransactionHash from "../../../TransactionHash.js" import type * as TransactionInput from "../../../TransactionInput.js" -import type * as CoreUTxO from "../../../UTxO.js" +import * as CoreUTxO from "../../../UTxO.js" +import type * as Provider from "../Provider.js" import { ProviderError } from "../Provider.js" import * as Blockfrost from "./Blockfrost.js" import * as HttpUtils from "./HttpUtils.js" @@ -51,6 +57,16 @@ const wrapError = (operation: string) => (error: unknown) => cause: error }) +/** + * Check if an error is a 404 Not Found response + */ +const is404Error = (error: unknown): boolean => { + if (error instanceof HttpClientError.ResponseError) { + return error.response.status === 404 + } + return false +} + /** * Convert address or credential to appropriate Blockfrost endpoint path */ @@ -63,6 +79,99 @@ const getAddressPath = (addressOrCredential: CoreAddress.Address | Credential.Cr return addressOrCredential.toString() } +/** + * Blockfrost script info response schema + */ +const BlockfrostScriptInfo = Schema.Struct({ + script_hash: Schema.String, + type: Schema.String, + serialised_size: Schema.optional(Schema.Number) +}) + +/** + * Blockfrost script CBOR response schema + */ +const BlockfrostScriptCbor = Schema.Struct({ + cbor: Schema.String +}) + +/** + * Fetch script by hash and return as Script type + */ +const getScriptByHash = (baseUrl: string, projectId?: string) => + (scriptHash: string): Effect.Effect => { + // First get script info to determine type + return withRateLimit( + HttpUtils.get( + `${baseUrl}/scripts/${scriptHash}`, + BlockfrostScriptInfo, + createHeaders(projectId) + ) + ).pipe( + Effect.mapError(wrapError("getScriptByHash")), + Effect.flatMap((info) => { + // For native scripts, we could return NativeScript but for now focus on Plutus + if (info.type === "timelock" || info.type === "native") { + return Effect.fail(new ProviderError({ + message: `Native scripts not yet supported: ${scriptHash}`, + cause: "Native script" + })) + } + // Fetch CBOR for Plutus scripts + return withRateLimit( + HttpUtils.get( + `${baseUrl}/scripts/${scriptHash}/cbor`, + BlockfrostScriptCbor, + createHeaders(projectId) + ) + ).pipe( + Effect.mapError(wrapError("getScriptByHash")), + Effect.map((cbor) => { + const scriptBytes = Bytes.fromHex(cbor.cbor) + switch (info.type) { + case "plutusV1": + return new PlutusV1.PlutusV1({ bytes: scriptBytes }) + case "plutusV2": + return new PlutusV2.PlutusV2({ bytes: scriptBytes }) + case "plutusV3": + return new PlutusV3.PlutusV3({ bytes: scriptBytes }) + default: + throw new Error(`Unknown script type: ${info.type}`) + } + }) + ) + }) + ) + } + +/** + * Blockfrost datum CBOR response schema + */ +const BlockfrostDatumCbor = Schema.Struct({ + cbor: Schema.String +}) + +/** + * Fetch datum by hash and return as DatumOption. + * Since we've resolved the actual datum data, we return InlineDatum. + */ +const getDatumByHash = (baseUrl: string, projectId?: string) => + (datumHash: string): Effect.Effect => { + return withRateLimit( + HttpUtils.get( + `${baseUrl}/scripts/datum/${datumHash}/cbor`, + BlockfrostDatumCbor, + createHeaders(projectId) + ) + ).pipe( + Effect.map((datum) => { + const data = PlutusData.fromCBORHex(datum.cbor) + return new DatumOption.InlineDatum({ data }) + }), + Effect.mapError(wrapError("getDatumByHash")) + ) + } + // ============================================================================ // Blockfrost Effect Functions (Curry Pattern) // ============================================================================ @@ -84,75 +193,284 @@ export const getProtocolParameters = (baseUrl: string, projectId?: string) => ) /** - * Get UTxOs for an address or credential + * Get UTxOs for an address or credential with pagination support + * Fetches reference scripts and resolves datum hashes for complete UTxO data * Returns: (baseUrl, projectId?) => (addressOrCredential) => Effect */ export const getUtxos = (baseUrl: string, projectId?: string) => (addressOrCredential: CoreAddress.Address | Credential.Credential) => { const addressPath = getAddressPath(addressOrCredential) + const fetchScript = getScriptByHash(baseUrl, projectId) + const fetchDatum = getDatumByHash(baseUrl, projectId) - return withRateLimit( - HttpUtils.get( - `${baseUrl}/addresses/${addressPath}/utxos`, - Schema.Array(Blockfrost.BlockfrostUTxO), - createHeaders(projectId) - ).pipe( - Effect.map((utxos) => - utxos.map((utxo) => Blockfrost.transformUTxO(utxo, addressPath)) - ), - Effect.mapError(wrapError("getUtxos")) + // Fetch all pages of UTxOs + const fetchPage = (page: number): Effect.Effect, unknown> => + withRateLimit( + HttpUtils.get( + `${baseUrl}/addresses/${addressPath}/utxos?page=${page}&count=100`, + Schema.Array(Blockfrost.BlockfrostUTxO), + createHeaders(projectId) + ) + ) + + const fetchAllPages = Effect.gen(function* () { + const allUtxos: Array = [] + let page = 1 + let hasMore = true + + while (hasMore) { + const pageResult = yield* fetchPage(page).pipe( + // Handle 404 as empty array (no UTxOs at address) + Effect.catchIf(is404Error, () => Effect.succeed([] as ReadonlyArray)) + ) + + allUtxos.push(...pageResult) + + // If we got less than 100 results, we've reached the end + if (pageResult.length < 100) { + hasMore = false + } else { + page++ + } + } + + return allUtxos + }) + + // Transform UTxOs with full script and datum resolution + const transformWithResolution = (utxo: Blockfrost.BlockfrostUTxO) => { + const scriptEffect = utxo.reference_script_hash + ? fetchScript(utxo.reference_script_hash).pipe( + Effect.map((s) => s as Script.Script | undefined), + Effect.catchAll(() => Effect.succeed(undefined)) + ) + : Effect.succeed(undefined) + + const datumEffect = utxo.inline_datum + ? Effect.succeed( + new DatumOption.InlineDatum({ + data: PlutusData.fromCBORHex(utxo.inline_datum) + }) as DatumOption.DatumOption | undefined + ) + : utxo.data_hash + ? fetchDatum(utxo.data_hash).pipe( + Effect.map((d) => d as DatumOption.DatumOption | undefined), + Effect.catchAll(() => Effect.succeed( + new DatumOption.DatumHash({ hash: Bytes.fromHex(utxo.data_hash!) }) as DatumOption.DatumOption | undefined + )) + ) + : Effect.succeed(undefined) + + return Effect.all([scriptEffect, datumEffect]).pipe( + Effect.map(([scriptRef, datumOption]) => { + const assets = Blockfrost.transformAmounts(utxo.amount) + const address = CoreAddress.fromBech32(addressPath) + const transactionId = TransactionHash.fromHex(utxo.tx_hash) + + return new CoreUTxO.UTxO({ + transactionId, + index: BigInt(utxo.output_index), + address, + assets, + scriptRef, + datumOption + }) + }) ) + } + + return fetchAllPages.pipe( + Effect.flatMap((utxos) => + Effect.forEach(utxos, transformWithResolution, { concurrency: 10 }) + ), + Effect.mapError(wrapError("getUtxos")) ) } /** - * Get UTxOs with a specific unit (asset) + * Get UTxOs with a specific unit (asset) with pagination support + * Fetches reference scripts and resolves datum hashes for complete UTxO data * Returns: (baseUrl, projectId?) => (addressOrCredential, unit) => Effect */ export const getUtxosWithUnit = (baseUrl: string, projectId?: string) => (addressOrCredential: CoreAddress.Address | Credential.Credential, unit: string) => { const addressPath = getAddressPath(addressOrCredential) + const fetchScript = getScriptByHash(baseUrl, projectId) + const fetchDatum = getDatumByHash(baseUrl, projectId) - return withRateLimit( - HttpUtils.get( - `${baseUrl}/addresses/${addressPath}/utxos/${unit}`, - Schema.Array(Blockfrost.BlockfrostUTxO), - createHeaders(projectId) - ).pipe( - Effect.map((utxos) => - utxos.map((utxo) => Blockfrost.transformUTxO(utxo, addressPath)) - ), - Effect.mapError(wrapError("getUtxosWithUnit")) + // Fetch all pages of UTxOs + const fetchPage = (page: number): Effect.Effect, unknown> => + withRateLimit( + HttpUtils.get( + `${baseUrl}/addresses/${addressPath}/utxos/${unit}?page=${page}&count=100`, + Schema.Array(Blockfrost.BlockfrostUTxO), + createHeaders(projectId) + ) ) + + const fetchAllPages = Effect.gen(function* () { + const allUtxos: Array = [] + let page = 1 + let hasMore = true + + while (hasMore) { + const pageResult = yield* fetchPage(page).pipe( + // Handle 404 as empty array (no UTxOs with this unit at address) + Effect.catchIf(is404Error, () => Effect.succeed([] as ReadonlyArray)) + ) + + allUtxos.push(...pageResult) + + // If we got less than 100 results, we've reached the end + if (pageResult.length < 100) { + hasMore = false + } else { + page++ + } + } + + return allUtxos + }) + + // Transform UTxOs with full script and datum resolution + const transformWithResolution = (utxo: Blockfrost.BlockfrostUTxO) => { + const scriptEffect = utxo.reference_script_hash + ? fetchScript(utxo.reference_script_hash).pipe( + Effect.map((s) => s as Script.Script | undefined), + Effect.catchAll(() => Effect.succeed(undefined)) + ) + : Effect.succeed(undefined) + + const datumEffect = utxo.inline_datum + ? Effect.succeed( + new DatumOption.InlineDatum({ + data: PlutusData.fromCBORHex(utxo.inline_datum) + }) as DatumOption.DatumOption | undefined + ) + : utxo.data_hash + ? fetchDatum(utxo.data_hash).pipe( + Effect.map((d) => d as DatumOption.DatumOption | undefined), + Effect.catchAll(() => Effect.succeed( + new DatumOption.DatumHash({ hash: Bytes.fromHex(utxo.data_hash!) }) as DatumOption.DatumOption | undefined + )) + ) + : Effect.succeed(undefined) + + return Effect.all([scriptEffect, datumEffect]).pipe( + Effect.map(([scriptRef, datumOption]) => { + const assets = Blockfrost.transformAmounts(utxo.amount) + const address = CoreAddress.fromBech32(addressPath) + const transactionId = TransactionHash.fromHex(utxo.tx_hash) + + return new CoreUTxO.UTxO({ + transactionId, + index: BigInt(utxo.output_index), + address, + assets, + scriptRef, + datumOption + }) + }) + ) + } + + return fetchAllPages.pipe( + Effect.flatMap((utxos) => + Effect.forEach(utxos, transformWithResolution, { concurrency: 10 }) + ), + Effect.mapError(wrapError("getUtxosWithUnit")) ) } /** * Get UTxO by unit (first occurrence) + * Fetches reference script and resolves datum for complete UTxO data * Returns: (baseUrl, projectId?) => (unit) => Effect */ export const getUtxoByUnit = (baseUrl: string, projectId?: string) => - (unit: string) => - withRateLimit( + (unit: string) => { + const fetchScript = getScriptByHash(baseUrl, projectId) + const fetchDatum = getDatumByHash(baseUrl, projectId) + + // First, get addresses holding this unit + return withRateLimit( HttpUtils.get( `${baseUrl}/assets/${unit}/addresses`, - Schema.Array(Blockfrost.BlockfrostUTxO), + Schema.Array(Blockfrost.BlockfrostAssetAddress), createHeaders(projectId) - ).pipe( - Effect.flatMap((utxos) => { - if (utxos.length === 0) { - return Effect.fail(new ProviderError({ - message: `No UTxO found for unit ${unit}`, - cause: "No UTxO found" - })) - } - // Use the first address for the UTxO transformation - const firstUtxo = utxos[0] - return Effect.succeed(Blockfrost.transformUTxO(firstUtxo, "unknown")) - }), - Effect.mapError(wrapError("getUtxoByUnit")) ) + ).pipe( + Effect.flatMap((addresses) => { + if (addresses.length === 0) { + return Effect.fail(new ProviderError({ + message: `No address found holding unit ${unit}`, + cause: "No UTxO found" + })) + } + // Get UTxOs from the first address holding the unit + const address = addresses[0].address + return withRateLimit( + HttpUtils.get( + `${baseUrl}/addresses/${address}/utxos/${unit}`, + Schema.Array(Blockfrost.BlockfrostUTxO), + createHeaders(projectId) + ) + ).pipe( + Effect.flatMap((utxos) => { + if (utxos.length === 0) { + return Effect.fail(new ProviderError({ + message: `No UTxO found for unit ${unit}`, + cause: "No UTxO found" + })) + } + + const utxo = utxos[0] + + // Fetch script and datum if present + const scriptEffect = utxo.reference_script_hash + ? fetchScript(utxo.reference_script_hash).pipe( + Effect.map((s) => s as Script.Script | undefined), + Effect.catchAll(() => Effect.succeed(undefined)) + ) + : Effect.succeed(undefined) + + const datumEffect = utxo.inline_datum + ? Effect.succeed( + new DatumOption.InlineDatum({ + data: PlutusData.fromCBORHex(utxo.inline_datum) + }) as DatumOption.DatumOption | undefined + ) + : utxo.data_hash + ? fetchDatum(utxo.data_hash).pipe( + Effect.map((d) => d as DatumOption.DatumOption | undefined), + Effect.catchAll(() => Effect.succeed( + new DatumOption.DatumHash({ hash: Bytes.fromHex(utxo.data_hash!) }) as DatumOption.DatumOption | undefined + )) + ) + : Effect.succeed(undefined) + + return Effect.all([scriptEffect, datumEffect]).pipe( + Effect.map(([scriptRef, datumOption]) => { + const assets = Blockfrost.transformAmounts(utxo.amount) + const coreAddress = CoreAddress.fromBech32(address) + const transactionId = TransactionHash.fromHex(utxo.tx_hash) + + return new CoreUTxO.UTxO({ + transactionId, + index: BigInt(utxo.output_index), + address: coreAddress, + assets, + scriptRef, + datumOption + }) + }) + ) + }) + ) + }), + Effect.mapError(wrapError("getUtxoByUnit")) ) + } /** * Get UTxOs by transaction inputs (output references) @@ -160,21 +478,68 @@ export const getUtxoByUnit = (baseUrl: string, projectId?: string) => */ export const getUtxosByOutRef = (baseUrl: string, projectId?: string) => (inputs: ReadonlyArray) => { + const fetchScript = getScriptByHash(baseUrl, projectId) + const fetchDatum = getDatumByHash(baseUrl, projectId) + // Blockfrost doesn't have a bulk endpoint, so we need to make individual calls const effects = inputs.map((input) => withRateLimit( HttpUtils.get( `${baseUrl}/txs/${TransactionHash.toHex(input.transactionId)}/utxos`, - Schema.Array(Blockfrost.BlockfrostUTxO), + Blockfrost.BlockfrostTxUtxos, createHeaders(projectId) - ).pipe( - Effect.map((utxos) => - utxos - .filter((utxo) => utxo.output_index === Number(input.index)) - .map((utxo) => Blockfrost.transformUTxO(utxo, "unknown")) - ), - Effect.mapError(wrapError("getUtxosByOutRef")) ) + ).pipe( + Effect.flatMap((txUtxos) => { + const matchingOutputs = txUtxos.outputs.filter( + (output) => output.output_index === Number(input.index) + ) + + // For each output, fetch script and datum if needed + return Effect.forEach( + matchingOutputs, + (output) => { + const scriptEffect = output.reference_script_hash + ? fetchScript(output.reference_script_hash).pipe( + Effect.map((s) => s as Script.Script | undefined), + Effect.catchAll(() => Effect.succeed(undefined)) + ) + : Effect.succeed(undefined) + + const datumEffect = output.inline_datum + ? Effect.succeed( + new DatumOption.InlineDatum({ + data: PlutusData.fromCBORHex(output.inline_datum) + }) as DatumOption.DatumOption | undefined + ) + : output.data_hash + ? fetchDatum(output.data_hash).pipe( + Effect.map((d) => d as DatumOption.DatumOption | undefined), + Effect.catchAll(() => Effect.succeed(undefined)) + ) + : Effect.succeed(undefined) + + return Effect.all([scriptEffect, datumEffect]).pipe( + Effect.map(([scriptRef, datumOption]) => { + const assets = Blockfrost.transformAmounts(output.amount) + const address = CoreAddress.fromBech32(output.address) + const transactionId = TransactionHash.fromHex(txUtxos.hash) + + return new CoreUTxO.UTxO({ + transactionId, + index: BigInt(output.output_index), + address, + assets, + scriptRef, + datumOption + }) + }) + ) + }, + { concurrency: "unbounded" } + ) + }), + Effect.mapError(wrapError("getUtxosByOutRef")) ) ) @@ -189,18 +554,20 @@ export const getUtxosByOutRef = (baseUrl: string, projectId?: string) => */ export const getDelegation = (baseUrl: string, projectId?: string) => (rewardAddress: RewardAddress.RewardAddress) => { - // Assume RewardAddress has a string representation - const rewardAddressStr = String(rewardAddress) - + // RewardAddress is a branded string, use it directly return withRateLimit( HttpUtils.get( - `${baseUrl}/accounts/${rewardAddressStr}`, + `${baseUrl}/accounts/${rewardAddress}`, Blockfrost.BlockfrostDelegation, createHeaders(projectId) - ).pipe( - Effect.map(Blockfrost.transformDelegation), - Effect.mapError(wrapError("getDelegation")) ) + ).pipe( + Effect.map(Blockfrost.transformDelegation), + // Handle 404 - account not registered/never staked + Effect.catchIf(is404Error, () => + Effect.succeed({ poolId: null, rewards: 0n } as Provider.Delegation) + ), + Effect.mapError(wrapError("getDelegation")) ) } @@ -297,71 +664,50 @@ export const evaluateTx = (baseUrl: string, projectId?: string) => // Convert Transaction to CBOR hex for evaluation const txCborHex = Transaction.toCBORHex(tx) - // If additional UTxOs provided, use the /utils/txs/evaluate/utxos endpoint with JSON payload - if (additionalUTxOs && additionalUTxOs.length > 0) { - // Create headers with application/json content-type - const headers = { - ...(projectId ? { "project_id": projectId } : {}), - "Content-Type": "application/json" - } - - // Use Ogmios format for additional UTxOs - const additionalUtxoSet = Ogmios.toOgmiosUTxOs(additionalUTxOs).map(utxo => { - const txIn = { - txId: utxo.transaction.id, - index: utxo.index - } - - const txOut: Record = { - address: utxo.address, - value: utxo.value - } - - // Add datum if present - if (utxo.datum) { - txOut.datum = utxo.datum - } else if (utxo.datumHash) { - txOut.datumHash = utxo.datumHash - } - - return [txIn, txOut] - }) - - const payload = { - cbor: txCborHex, // Transaction CBOR (hex) - additionalUtxoSet - } - - return withRateLimit( - HttpUtils.postJson( - `${baseUrl}/utils/txs/evaluate/utxos`, - payload, - Blockfrost.JsonwspOgmiosEvaluationResponse, - headers - ).pipe( - Effect.map(Blockfrost.transformJsonwspOgmiosEvaluationResult), - Effect.mapError(wrapError("evaluateTx")) - ) - ) + // Always use the /utils/txs/evaluate/utxos JSON endpoint as it's more reliable + // The /utils/txs/evaluate CBOR endpoint has intermittent 500 errors + const headers = { + ...(projectId ? { "project_id": projectId } : {}), + "Content-Type": "application/json" } - // Otherwise use the simpler /utils/txs/evaluate endpoint with CBOR body - const txBytes = Transaction.toCBORBytes(tx) + // Build additional UTxO set if provided + const additionalUtxoSet = additionalUTxOs && additionalUTxOs.length > 0 + ? Ogmios.toOgmiosUTxOs(additionalUTxOs).map(utxo => { + const txIn = { + txId: utxo.transaction.id, + index: utxo.index + } + + const txOut: Record = { + address: utxo.address, + value: utxo.value + } + + // Add datum if present + if (utxo.datum) { + txOut.datum = utxo.datum + } else if (utxo.datumHash) { + txOut.datumHash = utxo.datumHash + } + + return [txIn, txOut] + }) + : [] - // Create headers with application/cbor content-type - const headers = { - ...(projectId ? { "project_id": projectId } : {}), - "Content-Type": "application/cbor" + const payload = { + cbor: txCborHex, + additionalUtxoSet } return withRateLimit( - HttpUtils.postUint8Array( - `${baseUrl}/utils/txs/evaluate`, - txBytes, - Blockfrost.BlockfrostEvaluationResponse, + HttpUtils.postJson( + `${baseUrl}/utils/txs/evaluate/utxos`, + payload, + Blockfrost.JsonwspOgmiosEvaluationResponse, headers ).pipe( - Effect.map(Blockfrost.transformEvaluationResult), + Effect.map(Blockfrost.transformJsonwspOgmiosEvaluationResult), Effect.mapError(wrapError("evaluateTx")) ) ) diff --git a/packages/evolution/src/sdk/provider/internal/HttpUtils.ts b/packages/evolution/src/sdk/provider/internal/HttpUtils.ts index f2a2a7c1..bdd1b9b3 100644 --- a/packages/evolution/src/sdk/provider/internal/HttpUtils.ts +++ b/packages/evolution/src/sdk/provider/internal/HttpUtils.ts @@ -49,10 +49,11 @@ export const postJson = ( Effect.gen(function* () { let request = HttpClientRequest.post(url) request = yield* HttpClientRequest.bodyJson(request, body) - request = HttpClientRequest.setHeaders(request, { + const finalHeaders = { "Content-Type": "application/json", ...(headers || {}) - }) + } + request = HttpClientRequest.setHeaders(request, finalHeaders) const response = yield* HttpClient.execute(request) const filteredResponse = yield* filterStatusOk(response) diff --git a/packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts b/packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts index de6b5169..2d6237c0 100644 --- a/packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts +++ b/packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts @@ -19,7 +19,6 @@ import type * as CoreScript from "../../../Script.js" import * as Transaction from "../../../Transaction.js" import * as TransactionHash from "../../../TransactionHash.js" import type * as TransactionInput from "../../../TransactionInput.js" -import * as UPLC from "../../../uplc/UPLC.js" import * as CoreUTxO from "../../../UTxO.js" import type { EvalRedeemer } from "../../EvalRedeemer.js" import * as Provider from "../Provider.js" @@ -115,26 +114,25 @@ const getScriptEffect = Effect.retry(Schedule.compose(Schedule.exponential(50), Schedule.recurs(5))), Effect.timeout(5_000), Effect.map(({ language, script }): CoreScript.Script => { - // Convert script hex to bytes - const rawScriptBytes = Bytes.fromHex(script) + // Convert script hex to bytes - Kupo returns scripts already properly encoded + const scriptBytes = Bytes.fromHex(script) // Create the proper Script type based on language switch (language) { case "native": { // Parse the native script from CBOR - return NativeScripts.fromCBORBytes(rawScriptBytes) + return NativeScripts.fromCBORBytes(scriptBytes) } case "plutus:v1": { - const doubleCborHex = UPLC.applyDoubleCborEncoding(script) - return new PlutusV1.PlutusV1({ bytes: Bytes.fromHex(doubleCborHex) }) + // Kupo returns scripts in single CBOR-encoded format (59xxxx...) + // which is the correct format for PlutusV1/V2/V3 bytes field + return new PlutusV1.PlutusV1({ bytes: scriptBytes }) } case "plutus:v2": { - const doubleCborHex = UPLC.applyDoubleCborEncoding(script) - return new PlutusV2.PlutusV2({ bytes: Bytes.fromHex(doubleCborHex) }) + return new PlutusV2.PlutusV2({ bytes: scriptBytes }) } case "plutus:v3": { - const doubleCborHex = UPLC.applyDoubleCborEncoding(script) - return new PlutusV3.PlutusV3({ bytes: Bytes.fromHex(doubleCborHex) }) + return new PlutusV3.PlutusV3({ bytes: scriptBytes }) } default: throw new Error(`Unknown script language: ${language}`) @@ -190,7 +188,9 @@ export const getProtocolParametersEffect = Effect.fn("getProtocolParameters")(fu const { result } = yield* pipe( HttpUtils.postJson(ogmiosUrl, data, schema, headers?.ogmiosHeader), Effect.timeout(TIMEOUT), - Effect.catchAll((cause) => new Provider.ProviderError({ cause, message: "Failed to get protocol parameters" })), + Effect.catchAll((cause) => + new Provider.ProviderError({ cause, message: "Failed to get protocol parameters" }) + ), Effect.provide(FetchHttpClient.layer) ) return toProtocolParameters(result) diff --git a/packages/evolution/src/sdk/provider/internal/Ogmios.ts b/packages/evolution/src/sdk/provider/internal/Ogmios.ts index fb4624a7..376891c2 100644 --- a/packages/evolution/src/sdk/provider/internal/Ogmios.ts +++ b/packages/evolution/src/sdk/provider/internal/Ogmios.ts @@ -9,7 +9,7 @@ import * as PlutusData from "../../../Data.js" import type * as DatumOption from "../../../DatumOption.js" import * as NativeScripts from "../../../NativeScripts.js" import * as PolicyId from "../../../PolicyId.js" -import * as CoreScript from "../../../Script.js" +import type * as CoreScript from "../../../Script.js" import * as TransactionHash from "../../../TransactionHash.js" import type * as CoreUTxO from "../../../UTxO.js" From d42ca5f250afadf473fb1eda2c373c9795061b42 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Mon, 12 Jan 2026 10:37:00 -0700 Subject: [PATCH 3/3] fix: improve type safety and consistency in provider datum handling --- packages/evolution-devnet/src/Config.ts | 4 +-- .../test/TxBuilder.Scripts.test.ts | 36 ++++++++++++------- packages/evolution/src/Address.ts | 2 ++ packages/evolution/src/AddressEras.ts | 2 ++ .../evolution/src/CommitteeColdCredential.ts | 2 ++ .../evolution/src/CommitteeHotCredential.ts | 2 ++ packages/evolution/src/DRep.ts | 2 ++ packages/evolution/src/PoolKeyHash.ts | 2 ++ packages/evolution/src/PrivateKey.ts | 2 ++ packages/evolution/src/RewardAccount.ts | 2 ++ .../sdk/provider/internal/BlockfrostEffect.ts | 32 ++++++++++------- .../sdk/provider/internal/KupmiosEffects.ts | 2 +- 12 files changed, 62 insertions(+), 28 deletions(-) diff --git a/packages/evolution-devnet/src/Config.ts b/packages/evolution-devnet/src/Config.ts index 3973c8e3..f1794d48 100644 --- a/packages/evolution-devnet/src/Config.ts +++ b/packages/evolution-devnet/src/Config.ts @@ -720,7 +720,7 @@ export const DEFAULT_KUPO_CONFIG: Required = { */ export const DEFAULT_OGMIOS_CONFIG: Required = { enabled: true, - image: "cardanosolutions/ogmios:v6.12.0", + image: "cardanosolutions/ogmios:v6.14.0", port: 1337, logLevel: "info" } as const @@ -734,7 +734,7 @@ export const DEFAULT_OGMIOS_CONFIG: Required = { */ export const DEFAULT_DEVNET_CONFIG: Required = { clusterName: "devnet", - image: "ghcr.io/intersectmbo/cardano-node:10.4.1", + image: "ghcr.io/intersectmbo/cardano-node:10.5.1", ports: { node: 4001, submit: 8090 diff --git a/packages/evolution-devnet/test/TxBuilder.Scripts.test.ts b/packages/evolution-devnet/test/TxBuilder.Scripts.test.ts index f7573e94..d73cd60e 100644 --- a/packages/evolution-devnet/test/TxBuilder.Scripts.test.ts +++ b/packages/evolution-devnet/test/TxBuilder.Scripts.test.ts @@ -179,7 +179,8 @@ describe("TxBuilder Script Handling", () => { const signBuilder = await builder.build({ changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), availableUtxos: [fundingUtxo], - protocolParameters: PROTOCOL_PARAMS + protocolParameters: PROTOCOL_PARAMS, + passAdditionalUtxos: true // Required for synthetic UTxOs not on chain }) const tx = await signBuilder.toTransaction() @@ -388,7 +389,8 @@ describe("TxBuilder Script Handling", () => { const signBuilder = await builder.build({ changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), availableUtxos: [multiAssetUtxo], - protocolParameters: PROTOCOL_PARAMS + protocolParameters: PROTOCOL_PARAMS, + passAdditionalUtxos: true // Required for synthetic UTxOs not on chain }) const tx = await signBuilder.toTransaction() @@ -600,7 +602,8 @@ describe("TxBuilder Script Handling", () => { const signBuilder = await builder.build({ changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), availableUtxos: [utxoWithRefScript, utxoWithoutRefScript], - protocolParameters: PROTOCOL_PARAMS + protocolParameters: PROTOCOL_PARAMS, + passAdditionalUtxos: true // Required for synthetic UTxOs not on chain }) const tx = await signBuilder.toTransaction() @@ -674,7 +677,8 @@ describe("TxBuilder Script Handling", () => { const signBuilder = await builder.build({ changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), availableUtxos: [tightFundingUtxo, collateralUtxo], - protocolParameters: PROTOCOL_PARAMS + protocolParameters: PROTOCOL_PARAMS, + passAdditionalUtxos: true // Required for synthetic UTxOs not on chain }) const tx = await signBuilder.toTransaction() @@ -755,7 +759,8 @@ describe("TxBuilder Script Handling", () => { const signBuilder = await builder.build({ changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), availableUtxos: [collateralUtxo], - protocolParameters: PROTOCOL_PARAMS + protocolParameters: PROTOCOL_PARAMS, + passAdditionalUtxos: true // Required for synthetic UTxOs not on chain }) const tx = await signBuilder.toTransaction() @@ -816,7 +821,8 @@ describe("TxBuilder Script Handling", () => { const signBuilder = await builder.build({ changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), availableUtxos: [], - protocolParameters: PROTOCOL_PARAMS + protocolParameters: PROTOCOL_PARAMS, + passAdditionalUtxos: true // Required for synthetic UTxOs not on chain }) const tx = await signBuilder.toTransaction() @@ -863,7 +869,8 @@ describe("TxBuilder Script Handling", () => { const signBuilder = await builder.build({ changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), availableUtxos: [], - protocolParameters: PROTOCOL_PARAMS + protocolParameters: PROTOCOL_PARAMS, + passAdditionalUtxos: true // Required for synthetic UTxOs not on chain }) const tx = await signBuilder.toTransaction() @@ -908,7 +915,8 @@ describe("TxBuilder Script Handling", () => { const signBuilder = await builder.build({ changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), availableUtxos: [], - protocolParameters: PROTOCOL_PARAMS + protocolParameters: PROTOCOL_PARAMS, + passAdditionalUtxos: true // Required for synthetic UTxOs not on chain }) const tx = await signBuilder.toTransaction() @@ -998,7 +1006,8 @@ describe("TxBuilder Script Handling", () => { const signBuilder = await builder.build({ changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), availableUtxos: [], - protocolParameters: PROTOCOL_PARAMS + protocolParameters: PROTOCOL_PARAMS, + passAdditionalUtxos: true // Required for synthetic UTxOs not on chain }) const tx = await signBuilder.toTransaction() @@ -1040,7 +1049,8 @@ describe("TxBuilder Script Handling", () => { const signBuilder = await builder.build({ changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), availableUtxos: [], - protocolParameters: PROTOCOL_PARAMS + protocolParameters: PROTOCOL_PARAMS, + passAdditionalUtxos: true // Required for synthetic UTxOs not on chain }) const tx = await signBuilder.toTransaction() @@ -1120,7 +1130,8 @@ describe("TxBuilder Script Handling", () => { const signBuilder = await builder.build({ changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), availableUtxos: [collateralUtxo1, collateralUtxo2, collateralUtxo3, collateralUtxo4], - protocolParameters: PROTOCOL_PARAMS + protocolParameters: PROTOCOL_PARAMS, + passAdditionalUtxos: true // Required for synthetic UTxOs not on chain }) const tx = await signBuilder.toTransaction() @@ -1195,7 +1206,8 @@ describe("TxBuilder Script Handling", () => { const signBuilder = await builder.build({ changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), availableUtxos: [collateralUtxo1, collateralUtxo2, collateralUtxo3], - protocolParameters: PROTOCOL_PARAMS + protocolParameters: PROTOCOL_PARAMS, + passAdditionalUtxos: true // Required for synthetic UTxOs not on chain }) const tx = await signBuilder.toTransaction() diff --git a/packages/evolution/src/Address.ts b/packages/evolution/src/Address.ts index 0628ae6a..a4aac964 100644 --- a/packages/evolution/src/Address.ts +++ b/packages/evolution/src/Address.ts @@ -169,6 +169,8 @@ export const FromBech32 = Schema.transformOrFail(Schema.String, Schema.typeSchem Eff.gen(function* () { const result = yield* Eff.try({ try: () => { + // Note: `as any` needed because bech32.decode expects template literal type `${Prefix}1${string}` + // but Schema provides plain string. Consider using decodeToBytes which accepts string. const decoded = bech32.decode(fromA as any, false) const bytes = bech32.fromWords(decoded.words) return new Uint8Array(bytes) diff --git a/packages/evolution/src/AddressEras.ts b/packages/evolution/src/AddressEras.ts index b3d956dd..5f60c2f4 100644 --- a/packages/evolution/src/AddressEras.ts +++ b/packages/evolution/src/AddressEras.ts @@ -176,6 +176,8 @@ export const FromBech32 = Schema.transformOrFail(Schema.String, Schema.typeSchem Eff.gen(function* () { const result = yield* Eff.try({ try: () => { + // Note: `as any` needed because bech32.decode expects template literal type `${Prefix}1${string}` + // but Schema provides plain string. Consider using decodeToBytes which accepts string. const decoded = bech32.decode(fromA as any, false) const bytes = bech32.fromWords(decoded.words) return new Uint8Array(bytes) diff --git a/packages/evolution/src/CommitteeColdCredential.ts b/packages/evolution/src/CommitteeColdCredential.ts index 9cf767a3..c4990c5c 100644 --- a/packages/evolution/src/CommitteeColdCredential.ts +++ b/packages/evolution/src/CommitteeColdCredential.ts @@ -122,6 +122,8 @@ export const FromBech32 = Schema.transformOrFail(Schema.String, Schema.typeSchem Effect.gen(function* () { const result = yield* Effect.try({ try: () => { + // Note: `as any` needed because bech32.decode expects template literal type `${Prefix}1${string}` + // but Schema provides plain string. Consider using decodeToBytes which accepts string. const decoded = bech32.decode(fromA as any, false) if (decoded.prefix !== "cc_cold") { throw new Error(`Invalid prefix: expected "cc_cold", got "${decoded.prefix}"`) diff --git a/packages/evolution/src/CommitteeHotCredential.ts b/packages/evolution/src/CommitteeHotCredential.ts index c7fb5585..731f2d99 100644 --- a/packages/evolution/src/CommitteeHotCredential.ts +++ b/packages/evolution/src/CommitteeHotCredential.ts @@ -122,6 +122,8 @@ export const FromBech32 = Schema.transformOrFail(Schema.String, Schema.typeSchem Effect.gen(function* () { const result = yield* Effect.try({ try: () => { + // Note: `as any` needed because bech32.decode expects template literal type `${Prefix}1${string}` + // but Schema provides plain string. Consider using decodeToBytes which accepts string. const decoded = bech32.decode(fromA as any, false) if (decoded.prefix !== "cc_hot") { throw new Error(`Invalid prefix: expected "cc_hot", got "${decoded.prefix}"`) diff --git a/packages/evolution/src/DRep.ts b/packages/evolution/src/DRep.ts index 2e47c439..b196a4bf 100644 --- a/packages/evolution/src/DRep.ts +++ b/packages/evolution/src/DRep.ts @@ -331,6 +331,8 @@ export const FromBech32 = Schema.transformOrFail(Schema.String, Schema.typeSchem Eff.gen(function* () { const result = yield* Eff.try({ try: () => { + // Note: `as any` needed because bech32.decode expects template literal type `${Prefix}1${string}` + // but Schema provides plain string. Consider using decodeToBytes which accepts string. const decoded = bech32.decode(fromA as any, false) if (decoded.prefix !== "drep") { throw new Error(`Invalid prefix: expected "drep", got "${decoded.prefix}"`) diff --git a/packages/evolution/src/PoolKeyHash.ts b/packages/evolution/src/PoolKeyHash.ts index 7ed55cb1..1f8d26e8 100644 --- a/packages/evolution/src/PoolKeyHash.ts +++ b/packages/evolution/src/PoolKeyHash.ts @@ -77,6 +77,8 @@ export const FromBech32 = Schema.transformOrFail(Schema.String, Schema.typeSchem Eff.gen(function* () { const result = yield* Eff.try({ try: () => { + // Note: `as any` needed because bech32.decode expects template literal type `${Prefix}1${string}` + // but Schema provides plain string. Consider using decodeToBytes which accepts string. const decoded = bech32.decode(fromA as any, false) const bytes = bech32.fromWords(decoded.words) return new Uint8Array(bytes) diff --git a/packages/evolution/src/PrivateKey.ts b/packages/evolution/src/PrivateKey.ts index 2a01f49f..970d1857 100644 --- a/packages/evolution/src/PrivateKey.ts +++ b/packages/evolution/src/PrivateKey.ts @@ -93,6 +93,8 @@ export const FromBech32 = Schema.transformOrFail(Schema.String, Schema.typeSchem decode: (fromA, _, ast) => E.gen(function* () { const { prefix, words } = yield* ParseResult.try({ + // Note: `as any` needed because bech32.decode expects template literal type `${Prefix}1${string}` + // but Schema provides plain string. Consider using decodeToBytes which accepts string. try: () => bech32.decode(fromA as any, 1023), catch: (error) => new ParseResult.Type(ast, fromA, `Failed to decode bech32 string: ${(error as Error).message}`) }) diff --git a/packages/evolution/src/RewardAccount.ts b/packages/evolution/src/RewardAccount.ts index c94a5078..ad31869b 100644 --- a/packages/evolution/src/RewardAccount.ts +++ b/packages/evolution/src/RewardAccount.ts @@ -121,6 +121,8 @@ export const FromBech32 = Schema.transformOrFail(Schema.String, Schema.typeSchem Eff.gen(function* () { const result = yield* Eff.try({ try: () => { + // Note: `as any` needed because bech32.decode expects template literal type `${Prefix}1${string}` + // but Schema provides plain string. Consider using decodeToBytes which accepts string. const decoded = bech32.decode(fromA as any, false) const bytes = bech32.fromWords(decoded.words) return new Uint8Array(bytes) diff --git a/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts b/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts index c0c8e047..8e2bef35 100644 --- a/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts +++ b/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts @@ -246,17 +246,18 @@ export const getUtxos = (baseUrl: string, projectId?: string) => ) : Effect.succeed(undefined) + const dataHash = utxo.data_hash const datumEffect = utxo.inline_datum ? Effect.succeed( new DatumOption.InlineDatum({ data: PlutusData.fromCBORHex(utxo.inline_datum) }) as DatumOption.DatumOption | undefined ) - : utxo.data_hash - ? fetchDatum(utxo.data_hash).pipe( + : dataHash + ? fetchDatum(dataHash).pipe( Effect.map((d) => d as DatumOption.DatumOption | undefined), Effect.catchAll(() => Effect.succeed( - new DatumOption.DatumHash({ hash: Bytes.fromHex(utxo.data_hash!) }) as DatumOption.DatumOption | undefined + new DatumOption.DatumHash({ hash: Bytes.fromHex(dataHash) }) as DatumOption.DatumOption | undefined )) ) : Effect.succeed(undefined) @@ -341,17 +342,18 @@ export const getUtxosWithUnit = (baseUrl: string, projectId?: string) => ) : Effect.succeed(undefined) + const dataHash = utxo.data_hash const datumEffect = utxo.inline_datum ? Effect.succeed( new DatumOption.InlineDatum({ data: PlutusData.fromCBORHex(utxo.inline_datum) }) as DatumOption.DatumOption | undefined ) - : utxo.data_hash - ? fetchDatum(utxo.data_hash).pipe( + : dataHash + ? fetchDatum(dataHash).pipe( Effect.map((d) => d as DatumOption.DatumOption | undefined), Effect.catchAll(() => Effect.succeed( - new DatumOption.DatumHash({ hash: Bytes.fromHex(utxo.data_hash!) }) as DatumOption.DatumOption | undefined + new DatumOption.DatumHash({ hash: Bytes.fromHex(dataHash) }) as DatumOption.DatumOption | undefined )) ) : Effect.succeed(undefined) @@ -434,17 +436,18 @@ export const getUtxoByUnit = (baseUrl: string, projectId?: string) => ) : Effect.succeed(undefined) + const dataHash = utxo.data_hash const datumEffect = utxo.inline_datum ? Effect.succeed( new DatumOption.InlineDatum({ data: PlutusData.fromCBORHex(utxo.inline_datum) }) as DatumOption.DatumOption | undefined ) - : utxo.data_hash - ? fetchDatum(utxo.data_hash).pipe( + : dataHash + ? fetchDatum(dataHash).pipe( Effect.map((d) => d as DatumOption.DatumOption | undefined), Effect.catchAll(() => Effect.succeed( - new DatumOption.DatumHash({ hash: Bytes.fromHex(utxo.data_hash!) }) as DatumOption.DatumOption | undefined + new DatumOption.DatumHash({ hash: Bytes.fromHex(dataHash) }) as DatumOption.DatumOption | undefined )) ) : Effect.succeed(undefined) @@ -506,16 +509,19 @@ export const getUtxosByOutRef = (baseUrl: string, projectId?: string) => ) : Effect.succeed(undefined) + const dataHash = output.data_hash const datumEffect = output.inline_datum ? Effect.succeed( new DatumOption.InlineDatum({ data: PlutusData.fromCBORHex(output.inline_datum) }) as DatumOption.DatumOption | undefined ) - : output.data_hash - ? fetchDatum(output.data_hash).pipe( + : dataHash + ? fetchDatum(dataHash).pipe( Effect.map((d) => d as DatumOption.DatumOption | undefined), - Effect.catchAll(() => Effect.succeed(undefined)) + Effect.catchAll(() => Effect.succeed( + new DatumOption.DatumHash({ hash: Bytes.fromHex(dataHash) }) as DatumOption.DatumOption | undefined + )) ) : Effect.succeed(undefined) @@ -536,7 +542,7 @@ export const getUtxosByOutRef = (baseUrl: string, projectId?: string) => }) ) }, - { concurrency: "unbounded" } + { concurrency: 10 } ) }), Effect.mapError(wrapError("getUtxosByOutRef")) diff --git a/packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts b/packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts index 2d6237c0..5ec000a7 100644 --- a/packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts +++ b/packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts @@ -168,7 +168,7 @@ const kupmiosUtxosToUtxos = ) ) }, - { concurrency: "unbounded" } + { concurrency: 10 } ) }