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-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 e7d1c6a3..1f8d26e8 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,38 @@ 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: () => { + // 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) + }, + 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 +138,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/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/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..8e2bef35 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,287 @@ 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 dataHash = utxo.data_hash + const datumEffect = utxo.inline_datum + ? Effect.succeed( + new DatumOption.InlineDatum({ + data: PlutusData.fromCBORHex(utxo.inline_datum) + }) as DatumOption.DatumOption | undefined + ) + : dataHash + ? fetchDatum(dataHash).pipe( + Effect.map((d) => d as DatumOption.DatumOption | undefined), + Effect.catchAll(() => Effect.succeed( + new DatumOption.DatumHash({ hash: Bytes.fromHex(dataHash) }) 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 dataHash = utxo.data_hash + const datumEffect = utxo.inline_datum + ? Effect.succeed( + new DatumOption.InlineDatum({ + data: PlutusData.fromCBORHex(utxo.inline_datum) + }) as DatumOption.DatumOption | undefined + ) + : dataHash + ? fetchDatum(dataHash).pipe( + Effect.map((d) => d as DatumOption.DatumOption | undefined), + Effect.catchAll(() => Effect.succeed( + new DatumOption.DatumHash({ hash: Bytes.fromHex(dataHash) }) 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 dataHash = utxo.data_hash + const datumEffect = utxo.inline_datum + ? Effect.succeed( + new DatumOption.InlineDatum({ + data: PlutusData.fromCBORHex(utxo.inline_datum) + }) as DatumOption.DatumOption | undefined + ) + : dataHash + ? fetchDatum(dataHash).pipe( + Effect.map((d) => d as DatumOption.DatumOption | undefined), + Effect.catchAll(() => Effect.succeed( + new DatumOption.DatumHash({ hash: Bytes.fromHex(dataHash) }) 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 +481,71 @@ 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 dataHash = output.data_hash + const datumEffect = output.inline_datum + ? Effect.succeed( + new DatumOption.InlineDatum({ + data: PlutusData.fromCBORHex(output.inline_datum) + }) as DatumOption.DatumOption | undefined + ) + : dataHash + ? fetchDatum(dataHash).pipe( + Effect.map((d) => d as DatumOption.DatumOption | undefined), + Effect.catchAll(() => Effect.succeed( + new DatumOption.DatumHash({ hash: Bytes.fromHex(dataHash) }) as DatumOption.DatumOption | 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: 10 } + ) + }), + Effect.mapError(wrapError("getUtxosByOutRef")) ) ) @@ -189,18 +560,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 +670,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..5ec000a7 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}`) @@ -170,7 +168,7 @@ const kupmiosUtxosToUtxos = ) ) }, - { concurrency: "unbounded" } + { concurrency: 10 } ) } @@ -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 b7d98982..376891c2 100644 --- a/packages/evolution/src/sdk/provider/internal/Ogmios.ts +++ b/packages/evolution/src/sdk/provider/internal/Ogmios.ts @@ -7,8 +7,9 @@ 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 type * as CoreScript from "../../../Script.js" import * as TransactionHash from "../../../TransactionHash.js" import type * as CoreUTxO from "../../../UTxO.js" @@ -161,13 +162,16 @@ export const toOgmiosUTxOs = (utxos: Array | undefined): Array