From f27d4081dbfbef3eb5f9eb6cee0c19896d553aa6 Mon Sep 17 00:00:00 2001 From: Alexander Diemand Date: Tue, 14 Jul 2026 13:05:15 +0200 Subject: [PATCH 1/4] added Blockchain Applied (BCA) provider; tests successfully validated Signed-off-by: Alexander Diemand --- .changeset/blockchain-applied-provider.md | 5 + .env.test.local.example | 6 + .gitignore | 1 + .../docs/advanced/custom-providers.mdx | 42 ++- examples/with-vite-react/README.md | 10 + packages/evolution/src/sdk/client/Client.ts | 14 + .../src/sdk/client/internal/Client.ts | 11 +- .../src/sdk/client/internal/Providers.ts | 7 +- .../src/sdk/provider/BlockchainApplied.ts | 104 ++++++ .../provider/internal/BlockchainApplied.ts | 272 ++++++++++++++ .../internal/BlockchainAppliedEffect.ts | 332 ++++++++++++++++++ .../evolution/test/provider/conformance.ts | 6 +- .../evolution/test/provider/providers.test.ts | 15 +- 13 files changed, 796 insertions(+), 29 deletions(-) create mode 100644 .changeset/blockchain-applied-provider.md create mode 100644 packages/evolution/src/sdk/provider/BlockchainApplied.ts create mode 100644 packages/evolution/src/sdk/provider/internal/BlockchainApplied.ts create mode 100644 packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts diff --git a/.changeset/blockchain-applied-provider.md b/.changeset/blockchain-applied-provider.md new file mode 100644 index 000000000..e6a36ad52 --- /dev/null +++ b/.changeset/blockchain-applied-provider.md @@ -0,0 +1,5 @@ +--- +"@evolution-sdk/evolution": minor +--- + +Add Blockchain Applied (BCA) as a built-in provider (`Client.make(...).withBlockchainApplied({ baseUrl, token })`, or `BlockchainAppliedProvider` / `mainnet` / `preprod` / `preview` / `custom` directly). Covers the full `ProviderEffect` interface: `getProtocolParameters`, `getUtxos`, `getUtxosWithUnit`, `getUtxoByUnit`, `getUtxosByOutRef`, `getDelegation`, `getDatum`, `awaitTx`, `submitTx`, and `evaluateTx`. diff --git a/.env.test.local.example b/.env.test.local.example index f9c35650f..52289af96 100644 --- a/.env.test.local.example +++ b/.env.test.local.example @@ -21,6 +21,12 @@ MAESTRO_PREPROD_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # Optional: override the default preprod endpoint # MAESTRO_PREPROD_URL=https://preprod.gomaestro-api.org/v1 +# ── Blockchain Applied (BCA) ────────────────────────────────────────────────── +# Required to run BCA tests. Ask the BCA team for a preprod bearer token. +BCA_PREPROD_KEY=bca_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +# Optional: override the default preprod endpoint +# BCA_PREPROD_URL=https://staging.blockchain-applied.com/api_preprod/v1 + # ── Kupmios (self-hosted or Demeter.run) ────────────────────────────────────── # Both URLs are required to run Kupmios tests. # If you use Demeter, provide one Kupo key and one Ogmios key. diff --git a/.gitignore b/.gitignore index c810abd80..d8ae95ba5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,6 @@ CLAUDE.md AGENTS.md # Local env overrides — never commit API keys +**/*.local .env.local .env.test.local diff --git a/docs/content/docs/advanced/custom-providers.mdx b/docs/content/docs/advanced/custom-providers.mdx index 4db41aabb..25bf6763f 100644 --- a/docs/content/docs/advanced/custom-providers.mdx +++ b/docs/content/docs/advanced/custom-providers.mdx @@ -6,12 +6,13 @@ description: Implement your own blockchain provider # Custom Providers -The SDK currently provides four built-in providers. Custom provider implementation requires working with the internal `ProviderEffect` interface — this is an advanced topic and the API may change. + The SDK currently provides five built-in providers. Custom provider implementation requires working with the internal + `ProviderEffect` interface — this is an advanced topic and the API may change. ## Provider Interface -All providers implement the `ProviderEffect` interface internally. This is what each built-in provider (Blockfrost, Kupmios, Maestro, Koios) satisfies: +All providers implement the `ProviderEffect` interface internally. This is what each built-in provider (Blockfrost, Kupmios, Maestro, Koios, Blockchain Applied) satisfies: ```typescript interface ProviderEffect { @@ -32,12 +33,13 @@ Understanding this interface is useful for knowing what data each provider must ## Built-in Providers -| Provider | Connection | Best For | -| --------------- | ---------------- | --------------------------------- | -| **Blockfrost** | REST API | Production apps, simple setup | -| **Maestro** | REST API | Advanced queries, data analytics | -| **Koios** | REST API | Open source, community maintained | -| **Kupo/Ogmios** | WebSocket + REST | Self-hosted, devnet, full control | +| Provider | Connection | Best For | +| ---------------------- | ---------------- | -------------------------------------- | +| **Blockfrost** | REST API | Production apps, simple setup | +| **Maestro** | REST API | Advanced queries, data analytics | +| **Koios** | REST API | Open source, community maintained | +| **Kupo/Ogmios** | WebSocket + REST | Self-hosted, devnet, full control | +| **Blockchain Applied** | REST API | Full-featured Cardano indexer/node API | ## Using a Provider @@ -45,18 +47,22 @@ Understanding this interface is useful for knowing what data each provider must import { preprod, Client } from "@evolution-sdk/evolution" // Blockfrost -const blockfrostClient = Client.make(preprod) - .withBlockfrost({ - baseUrl: "https://cardano-preprod.blockfrost.io/api/v0", - projectId: process.env.BLOCKFROST_API_KEY! - }) +const blockfrostClient = Client.make(preprod).withBlockfrost({ + baseUrl: "https://cardano-preprod.blockfrost.io/api/v0", + projectId: process.env.BLOCKFROST_API_KEY! +}) // Kupo/Ogmios -const kupmiosClient = Client.make(preprod) - .withKupmios({ - kupoUrl: "http://localhost:1442", - ogmiosUrl: "http://localhost:1337" - }) +const kupmiosClient = Client.make(preprod).withKupmios({ + kupoUrl: "http://localhost:1442", + ogmiosUrl: "http://localhost:1337" +}) + +// Blockchain Applied +const bcaClient = Client.make(preprod).withBlockchainApplied({ + baseUrl: "https://api.blockchain-applied.com/api_preprod/v1", + token: process.env.BCA_API_KEY! +}) ``` ## Next Steps diff --git a/examples/with-vite-react/README.md b/examples/with-vite-react/README.md index 774e5b1e2..afd578668 100644 --- a/examples/with-vite-react/README.md +++ b/examples/with-vite-react/README.md @@ -130,6 +130,16 @@ const tx = await Client.make(chain) `pnpm dev` serves the API from the Vite dev server. In production, `server/index.ts` serves it with the built app. The API is public, so add rate limiting or an origin check before deploying it. +To use the Blockchain Applied (BCA) provider instead, swap the provider on the server: + +```typescript +// Server (server/payments.ts): the BCA provider, with the token +const tx = await Client.make(chain) + .withBlockchainApplied({ baseUrl: "https://api.blockchain-applied.com/api_preprod/v1", token: process.env.BCA_TOKEN }) + .withAddress(from) + // ... +``` + ## Development ### Building for Production diff --git a/packages/evolution/src/sdk/client/Client.ts b/packages/evolution/src/sdk/client/Client.ts index d986405cf..a7fa94214 100644 --- a/packages/evolution/src/sdk/client/Client.ts +++ b/packages/evolution/src/sdk/client/Client.ts @@ -90,6 +90,17 @@ export interface KoiosConfig { readonly token?: string } +/** + * Configuration for the Blockchain Applied (BCA) provider. + * + * @since 2.1.0 + * @category model + */ +export interface BlockchainAppliedConfig { + readonly baseUrl: string + readonly token?: string +} + /** * Configuration for the Kupmios provider (Kupo + Ogmios). * @@ -156,6 +167,7 @@ export interface ClientAssembly { readonly withKoios: (config: KoiosConfig) => ReadClient readonly withKupmios: (config: KupmiosConfig) => ReadClient readonly withMaestro: (config: MaestroConfig) => ReadClient + readonly withBlockchainApplied: (config: BlockchainAppliedConfig) => ReadClient readonly withAddress: (address: string, rewardAddress?: string) => AddressClient readonly withSeed: (config: SeedWalletConfig) => OfflineSignerClient readonly withPrivateKey: (config: PrivateKeyWalletConfig) => OfflineSignerClient @@ -190,6 +202,7 @@ export type AddressClient = EffectToPromiseAPI & { readonly withKoios: (config: KoiosConfig) => ReadOnlyClient readonly withKupmios: (config: KupmiosConfig) => ReadOnlyClient readonly withMaestro: (config: MaestroConfig) => ReadOnlyClient + readonly withBlockchainApplied: (config: BlockchainAppliedConfig) => ReadOnlyClient readonly effect: AddressClientEffect } @@ -205,6 +218,7 @@ export type OfflineSignerClient = EffectToPromiseAPI readonly withKoios: (config: KoiosConfig) => SigningClient readonly withKupmios: (config: KupmiosConfig) => SigningClient readonly withMaestro: (config: MaestroConfig) => SigningClient + readonly withBlockchainApplied: (config: BlockchainAppliedConfig) => SigningClient readonly effect: OfflineSignerClientEffect } diff --git a/packages/evolution/src/sdk/client/internal/Client.ts b/packages/evolution/src/sdk/client/internal/Client.ts index 378a6c0ec..7dbaf31a6 100644 --- a/packages/evolution/src/sdk/client/internal/Client.ts +++ b/packages/evolution/src/sdk/client/internal/Client.ts @@ -33,6 +33,7 @@ const createAddressClient = (chain: Chain, wallet: Wallet.ReadOnlyWallet): Addre withKoios: (config) => createReadOnlyClient(chain, Providers.koios(config), wallet), withKupmios: (config) => createReadOnlyClient(chain, Providers.kupmios(config), wallet), withMaestro: (config) => createReadOnlyClient(chain, Providers.maestro(config), wallet), + withBlockchainApplied: (config) => createReadOnlyClient(chain, Providers.blockchainApplied(config), wallet), effect: wallet.effect satisfies AddressClientEffect }) @@ -58,6 +59,7 @@ const createOfflineSignerClient = (chain: Chain, wallet: Signing.ResolvedSignerW withKoios: (config) => createSigningClient(chain, Providers.koios(config), wallet), withKupmios: (config) => createSigningClient(chain, Providers.kupmios(config), wallet), withMaestro: (config) => createSigningClient(chain, Providers.maestro(config), wallet), + withBlockchainApplied: (config) => createSigningClient(chain, Providers.blockchainApplied(config), wallet), effect: effectInterface } } @@ -141,7 +143,8 @@ const createSigningClient = ( const createReadClient = (chain: Chain, provider: Provider.Provider): ReadClient => ({ ...provider, chain, - withAddress: (address, rewardAddress) => createReadOnlyClient(chain, provider, Wallets.readOnlyWallet(address, rewardAddress)(chain)), + withAddress: (address, rewardAddress) => + createReadOnlyClient(chain, provider, Wallets.readOnlyWallet(address, rewardAddress)(chain)), withSeed: (config) => createSigningClient(chain, provider, Wallets.seedWallet(config)(chain)), withPrivateKey: (config) => createSigningClient(chain, provider, Wallets.privateKeyWallet(config)(chain)), withCip30: (api) => createSigningClient(chain, provider, Wallets.cip30Wallet(api)(chain)), @@ -155,8 +158,10 @@ export const client = (chain: Chain = mainnet): ClientAssembly => ({ withKoios: (config) => createReadClient(chain, Providers.koios(config)), withKupmios: (config) => createReadClient(chain, Providers.kupmios(config)), withMaestro: (config) => createReadClient(chain, Providers.maestro(config)), - withAddress: (address, rewardAddress) => createAddressClient(chain, Wallets.readOnlyWallet(address, rewardAddress)(chain)), + withBlockchainApplied: (config) => createReadClient(chain, Providers.blockchainApplied(config)), + withAddress: (address, rewardAddress) => + createAddressClient(chain, Wallets.readOnlyWallet(address, rewardAddress)(chain)), withSeed: (config) => createOfflineSignerClient(chain, Wallets.seedWallet(config)(chain)), withPrivateKey: (config) => createOfflineSignerClient(chain, Wallets.privateKeyWallet(config)(chain)), withCip30: (api) => createOfflineSignerClient(chain, Wallets.cip30Wallet(api)(chain)) -}) \ No newline at end of file +}) diff --git a/packages/evolution/src/sdk/client/internal/Providers.ts b/packages/evolution/src/sdk/client/internal/Providers.ts index 2cabf6977..8b9097c73 100644 --- a/packages/evolution/src/sdk/client/internal/Providers.ts +++ b/packages/evolution/src/sdk/client/internal/Providers.ts @@ -1,9 +1,10 @@ +import * as BlockchainApplied from "../../provider/BlockchainApplied.js" import * as Blockfrost from "../../provider/Blockfrost.js" import * as Koios from "../../provider/Koios.js" import * as Kupmios from "../../provider/Kupmios.js" import * as Maestro from "../../provider/Maestro.js" import type { Provider } from "../../provider/Provider.js" -import type { BlockfrostConfig, KoiosConfig, KupmiosConfig, MaestroConfig } from "../Client.js" +import type { BlockchainAppliedConfig, BlockfrostConfig, KoiosConfig, KupmiosConfig, MaestroConfig } from "../Client.js" // ── Blockfrost ──────────────────────────────────────────────────────────────── export const blockfrost = (config: BlockfrostConfig): Provider => Blockfrost.custom(config.baseUrl, config.projectId) @@ -18,3 +19,7 @@ export const kupmios = (config: KupmiosConfig): Provider => // ── Maestro ─────────────────────────────────────────────────────────────────── export const maestro = (config: MaestroConfig): Provider => new Maestro.MaestroProvider(config.baseUrl, config.apiKey, config.turboSubmit) + +// ── Blockchain Applied ─────────────────────────────────────────────────────── +export const blockchainApplied = (config: BlockchainAppliedConfig): Provider => + BlockchainApplied.custom(config.baseUrl, config.token) diff --git a/packages/evolution/src/sdk/provider/BlockchainApplied.ts b/packages/evolution/src/sdk/provider/BlockchainApplied.ts new file mode 100644 index 000000000..76f4cc766 --- /dev/null +++ b/packages/evolution/src/sdk/provider/BlockchainApplied.ts @@ -0,0 +1,104 @@ +import { Effect } from "effect" + +import * as BlockchainAppliedEffect from "./internal/BlockchainAppliedEffect.js" +import type { Provider, ProviderEffect } from "./Provider.js" + +const HOST = "https://api.blockchain-applied.com" + +/** + * Blockchain Applied (BCA) provider for Cardano blockchain data access. + * Supports Bearer token authentication. + * + * @since 2.0.0 + * @category constructors + */ +export class BlockchainAppliedProvider implements Provider { + readonly effect: ProviderEffect + readonly baseUrl: string + readonly token?: string + + constructor(baseUrl: string, token?: string) { + this.baseUrl = baseUrl + this.token = token + + this.effect = { + getProtocolParameters: () => BlockchainAppliedEffect.getProtocolParameters(baseUrl, token), + getUtxos: BlockchainAppliedEffect.getUtxos(baseUrl, token), + getUtxosWithUnit: BlockchainAppliedEffect.getUtxosWithUnit(baseUrl, token), + getUtxoByUnit: BlockchainAppliedEffect.getUtxoByUnit(baseUrl, token), + getUtxosByOutRef: BlockchainAppliedEffect.getUtxosByOutRef(baseUrl, token), + getDelegation: BlockchainAppliedEffect.getDelegation(baseUrl, token), + getDatum: BlockchainAppliedEffect.getDatum(baseUrl, token), + awaitTx: BlockchainAppliedEffect.awaitTx(baseUrl, token), + submitTx: BlockchainAppliedEffect.submitTx(baseUrl, token), + evaluateTx: BlockchainAppliedEffect.evaluateTx(baseUrl, token) + } + } + + getProtocolParameters = () => Effect.runPromise(this.effect.getProtocolParameters()) + + getUtxos = (addressOrCredential: Parameters[0]) => + Effect.runPromise(this.effect.getUtxos(addressOrCredential)) + + getUtxosWithUnit = ( + addressOrCredential: Parameters[0], + unit: Parameters[1] + ) => Effect.runPromise(this.effect.getUtxosWithUnit(addressOrCredential, unit)) + + getUtxoByUnit = (unit: Parameters[0]) => Effect.runPromise(this.effect.getUtxoByUnit(unit)) + + getUtxosByOutRef = (outRefs: Parameters[0]) => + Effect.runPromise(this.effect.getUtxosByOutRef(outRefs)) + + getDelegation = (rewardAddress: Parameters[0]) => + Effect.runPromise(this.effect.getDelegation(rewardAddress)) + + getDatum = (datumHash: Parameters[0]) => Effect.runPromise(this.effect.getDatum(datumHash)) + + awaitTx = ( + txHash: Parameters[0], + checkInterval?: Parameters[1], + timeout?: Parameters[2] + ) => Effect.runPromise(this.effect.awaitTx(txHash, checkInterval, timeout)) + + submitTx = (tx: Parameters[0]) => Effect.runPromise(this.effect.submitTx(tx)) + + evaluateTx = (tx: Parameters[0], additionalUTxOs?: Parameters[1]) => + Effect.runPromise(this.effect.evaluateTx(tx, additionalUTxOs)) +} + +/** + * Pre-configured Blockchain Applied provider for Cardano mainnet. + * + * @since 2.0.0 + * @category constructors + */ +export const mainnet = (token?: string): BlockchainAppliedProvider => + new BlockchainAppliedProvider(`${HOST}/api_ada/v1`, token) + +/** + * Pre-configured Blockchain Applied provider for Cardano preprod testnet. + * + * @since 2.0.0 + * @category constructors + */ +export const preprod = (token?: string): BlockchainAppliedProvider => + new BlockchainAppliedProvider(`${HOST}/api_preprod/v1`, token) + +/** + * Pre-configured Blockchain Applied provider for Cardano preview testnet. + * + * @since 2.0.0 + * @category constructors + */ +export const preview = (token?: string): BlockchainAppliedProvider => + new BlockchainAppliedProvider(`${HOST}/api_preview/v1`, token) + +/** + * Create a custom Blockchain Applied provider with a custom base URL. + * + * @since 2.0.0 + * @category constructors + */ +export const custom = (baseUrl: string, token?: string): BlockchainAppliedProvider => + new BlockchainAppliedProvider(baseUrl, token) diff --git a/packages/evolution/src/sdk/provider/internal/BlockchainApplied.ts b/packages/evolution/src/sdk/provider/internal/BlockchainApplied.ts new file mode 100644 index 000000000..d17039138 --- /dev/null +++ b/packages/evolution/src/sdk/provider/internal/BlockchainApplied.ts @@ -0,0 +1,272 @@ +/** + * @fileoverview Blockchain Applied (BCA) API schemas and transformation utilities. + * + * Schemas mirror https://api.blockchain-applied.com/api_ada/v1/openapi.json. + * Swagger UI on https://api.blockchain-applied.com/api_ada/v1/docs/ + * BCA is backed by cardano-db-sync, so field naming (block_index, out_sum, valid_contract, ...) + * follows db-sync conventions rather than Blockfrost/Koios/Maestro's own vocabularies. + */ + +import { Schema } from "effect" + +import * as CoreAssets from "../../../Assets.js" +import * as Bytes from "../../../Bytes.js" +import * as PlutusData from "../../../Data.js" +import * as DatumHash from "../../../DatumHash.js" +import type { DatumOption } from "../../../DatumOption.js" +import * as InlineDatum from "../../../InlineDatum.js" +import * as NativeScripts from "../../../NativeScripts.js" +import * as PlutusV1 from "../../../PlutusV1.js" +import * as PlutusV2 from "../../../PlutusV2.js" +import * as PlutusV3 from "../../../PlutusV3.js" +import * as PoolKeyHash from "../../../PoolKeyHash.js" +import * as Redeemer from "../../../Redeemer.js" +import type { Script } from "../../../Script.js" +import type { EvalRedeemer } from "../../EvalRedeemer.js" +import type * as Provider from "../Provider.js" + +// ============================================================================ +// Shared envelope +// ============================================================================ + +/** + * Some BCA resource endpoints (datum, script) wrap their payload as + * { summary, details }. `summary` is a resource-category label, not a + * union discriminator. + */ +export const Envelope = (details: Schema.Schema) => + Schema.Struct({ + summary: Schema.String, + details + }) + +export const PaginatedResponse = (item: Schema.Schema) => + Schema.Struct({ + data: Schema.Array(item), + page: Schema.Number, + page_size: Schema.Number, + total_count: Schema.Number, + total_pages: Schema.Number, + has_next: Schema.Boolean + }) + +// ============================================================================ +// UTxOs (GET /utxos/{address}, GET /assets/{unit}/utxos) +// ============================================================================ + +export const UtxoAsset = Schema.Struct({ + policy_id: Schema.String, + asset_name: Schema.String, + quantity: Schema.String +}) +export interface UtxoAsset extends Schema.Schema.Type {} + +export const UtxoResponse = Schema.Struct({ + tx_hash: Schema.String, + output_index: Schema.Number, + address: Schema.String, + lovelace: Schema.String, + assets: Schema.Array(UtxoAsset), + datum_hash: Schema.optional(Schema.NullOr(Schema.String)), + inline_datum: Schema.optional(Schema.NullOr(Schema.String)), + reference_script_hash: Schema.optional(Schema.NullOr(Schema.String)), + reference_script_cbor: Schema.optional(Schema.NullOr(Schema.String)), + reference_script_type: Schema.optional(Schema.NullOr(Schema.String)) +}) +export interface UtxoResponse extends Schema.Schema.Type {} + +export const PaginatedUtxoResponse = PaginatedResponse(UtxoResponse) + +// ============================================================================ +// Transaction (GET /tx/{hash}) — inputs/outputs are UtxoResponse-shaped, so +// getUtxosByOutRef can reuse the same UTxO resolution path as GET /utxos/{address}. +// ============================================================================ + +export const TransactionResponse = Schema.Struct({ + hash: Schema.String, + inputs: Schema.Array(UtxoResponse), + outputs: Schema.Array(UtxoResponse) +}) +export interface TransactionResponse extends Schema.Schema.Type {} + +// ============================================================================ +// Datum (GET /datum/{datumhash}) +// ============================================================================ + +export const DatumDetails = Schema.Struct({ + hash: Schema.String, + tx_hash: Schema.String, + json: Schema.Unknown, + bytes: Schema.String +}) +export interface DatumDetails extends Schema.Schema.Type {} + +// ============================================================================ +// Script (GET /script/{scripthash}) +// ============================================================================ + +export const ScriptDetails = Schema.Struct({ + hash: Schema.String, + size: Schema.Number, + type: Schema.String, + json: Schema.Unknown, + bytes: Schema.String, + tx_hash: Schema.String +}) +export interface ScriptDetails extends Schema.Schema.Type {} + +// ============================================================================ +// Staking (GET /staking/{stakeaddr}) — current pool delegation and reward +// balance in one flat object. +// ============================================================================ + +export const StakingResponse = Schema.Struct({ + address: Schema.String, + pool_id: Schema.optional(Schema.NullOr(Schema.String)), + total_rewards: Schema.String, + total_withdrawn: Schema.String, + withdrawable_rewards: Schema.String, + active_epoch: Schema.optional(Schema.NullOr(Schema.Number)), + delegation_tx: Schema.optional(Schema.NullOr(Schema.String)) +}) +export interface StakingResponse extends Schema.Schema.Type {} + +// ============================================================================ +// Protocol parameters (GET /protocol_parameters/latest) +// ============================================================================ + +export const ProtocolParametersResponse = Schema.Struct({ + epoch_no: Schema.Number, + min_fee_a: Schema.Number, + min_fee_b: Schema.Number, + max_tx_size: Schema.Number, + max_val_size: Schema.Number, + key_deposit: Schema.String, + pool_deposit: Schema.String, + price_mem: Schema.Number, + price_step: Schema.Number, + max_tx_ex_mem: Schema.String, + max_tx_ex_steps: Schema.String, + cost_models: Schema.Unknown, + coins_per_utxo_size: Schema.optional(Schema.NullOr(Schema.String)), + collateral_percentage: Schema.optional(Schema.NullOr(Schema.Number)), + drep_deposit: Schema.optional(Schema.NullOr(Schema.String)), + gov_action_deposit: Schema.optional(Schema.NullOr(Schema.String)), + max_collateral_inputs: Schema.optional(Schema.NullOr(Schema.Number)), + min_fee_ref_script_cost_per_byte: Schema.optional(Schema.NullOr(Schema.Number)) +}) +export interface ProtocolParametersResponse extends Schema.Schema.Type {} + +// ============================================================================ +// Tx submit / evaluate +// ============================================================================ + +export const SubmitResponse = Schema.Struct({ + tx_hash: Schema.String +}) + +export const ExUnits = Schema.Struct({ + mem: Schema.String, + steps: Schema.String +}) + +export const EvalRedeemerResponse = Schema.Struct({ + redeemer_tag: Schema.Literal("spend", "mint", "cert", "reward", "vote", "propose"), + redeemer_index: Schema.Number, + ex_units: ExUnits +}) + +// ============================================================================ +// Transformation utilities +// ============================================================================ + +/** BCA declares `cost_models` as an opaque object — normalize either array or object shapes. */ +const normalizeCostModel = (raw: unknown): Record => { + if (Array.isArray(raw)) { + return Object.fromEntries(raw.map((value, index) => [index.toString(), Number(value)])) + } + if (raw && typeof raw === "object") { + return Object.fromEntries( + Object.entries(raw as Record).map(([key, value]) => [key, Number(value)]) + ) + } + return {} +} + +const extractCostModel = (costModels: unknown, keys: ReadonlyArray): Record => { + if (costModels && typeof costModels === "object") { + for (const key of keys) { + const value = (costModels as Record)[key] + if (value !== undefined) return normalizeCostModel(value) + } + } + return {} +} + +export const transformProtocolParameters = (response: ProtocolParametersResponse): Provider.ProtocolParameters => ({ + minFeeA: response.min_fee_a, + minFeeB: response.min_fee_b, + maxTxSize: response.max_tx_size, + maxValSize: response.max_val_size, + keyDeposit: BigInt(response.key_deposit), + poolDeposit: BigInt(response.pool_deposit), + drepDeposit: BigInt(response.drep_deposit ?? "0"), + govActionDeposit: BigInt(response.gov_action_deposit ?? "0"), + priceMem: response.price_mem, + priceStep: response.price_step, + maxTxExMem: BigInt(response.max_tx_ex_mem), + maxTxExSteps: BigInt(response.max_tx_ex_steps), + coinsPerUtxoByte: BigInt(response.coins_per_utxo_size ?? "0"), + collateralPercentage: response.collateral_percentage ?? 0, + maxCollateralInputs: response.max_collateral_inputs ?? 0, + minFeeRefScriptCostPerByte: response.min_fee_ref_script_cost_per_byte ?? 0, + costModels: { + PlutusV1: extractCostModel(response.cost_models, ["PlutusV1", "plutus_v1"]), + PlutusV2: extractCostModel(response.cost_models, ["PlutusV2", "plutus_v2"]), + PlutusV3: extractCostModel(response.cost_models, ["PlutusV3", "plutus_v3"]) + } +}) + +export const transformAssets = (lovelace: string, assets: ReadonlyArray): CoreAssets.Assets => { + let result = CoreAssets.fromLovelace(BigInt(lovelace)) + for (const asset of assets) { + result = CoreAssets.addByHex(result, asset.policy_id, asset.asset_name, BigInt(asset.quantity)) + } + return result +} + +export const transformScript = (details: { type: string; bytes: string }): Script | undefined => { + const bytes = Bytes.fromHex(details.bytes) + switch (details.type) { + case "plutusV1": + return new PlutusV1.PlutusV1({ bytes }) + case "plutusV2": + return new PlutusV2.PlutusV2({ bytes }) + case "plutusV3": + return new PlutusV3.PlutusV3({ bytes }) + case "timelock": + case "multisig": + return NativeScripts.fromCBORHex(details.bytes) + default: + return undefined + } +} + +export const transformDelegation = (response: StakingResponse): Provider.Delegation => ({ + poolId: response.pool_id ? PoolKeyHash.fromBech32(response.pool_id) : null, + rewards: BigInt(response.withdrawable_rewards) +}) + +export const transformEvalRedeemer = (redeemer: Schema.Schema.Type): EvalRedeemer => ({ + redeemer_tag: redeemer.redeemer_tag as Redeemer.RedeemerTag, + redeemer_index: redeemer.redeemer_index, + ex_units: new Redeemer.ExUnits({ + mem: BigInt(redeemer.ex_units.mem), + steps: BigInt(redeemer.ex_units.steps) + }) +}) + +export const inlineDatumFromCBORHex = (cborHex: string): DatumOption => + new InlineDatum.InlineDatum({ data: PlutusData.fromCBORHex(cborHex) }) + +export const datumHashFromHex = (hex: string): DatumOption => DatumHash.fromHex(hex) diff --git a/packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts b/packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts new file mode 100644 index 000000000..a94d69647 --- /dev/null +++ b/packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts @@ -0,0 +1,332 @@ +/** + * @fileoverview Effect-based Blockchain Applied (BCA) provider functions. + * Internal module implementing all provider operations using the Effect pattern. + */ + +import { HttpClientError } from "@effect/platform" +import { Effect, Option, Schedule, Schema } from "effect" + +import * as CoreAddress from "../../../Address.js" +import * as AssetName from "../../../AssetName.js" +import * as CoreAssets from "../../../Assets.js" +import * as Bytes from "../../../Bytes.js" +import * as Credential from "../../../Credential.js" +import * as PlutusData from "../../../Data.js" +import type * as DatumHash from "../../../DatumHash.js" +import type * as DatumOption from "../../../DatumOption.js" +import * as NativeScripts from "../../../NativeScripts.js" +import * as PolicyId from "../../../PolicyId.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 * as CoreUTxO from "../../../UTxO.js" +import * as Provider from "../Provider.js" +import * as BCA from "./BlockchainApplied.js" +import * as HttpUtils from "./HttpUtils.js" + +const PAGE_SIZE = 100 +const TIMEOUT = 10_000 + +const bearerHeaders = (token?: string): Record | undefined => + token ? { Authorization: `Bearer ${token}` } : undefined + +const wrapError = (operation: string) => (cause: unknown) => + Effect.fail( + new Provider.ProviderError({ + message: `BlockchainApplied ${operation} failed`, + cause + }) + ) + +const getAddressPath = (addressOrCredential: CoreAddress.Address | Credential.Credential): string => + "hash" in addressOrCredential ? Credential.toBech32(addressOrCredential) : CoreAddress.toBech32(addressOrCredential) + +const is404 = (error: unknown): boolean => + error instanceof HttpClientError.ResponseError && error.response.status === 404 + +// ============================================================================ +// Script / datum resolution +// ============================================================================ +// BCA's UtxoResponse carries inline_datum / reference_script_cbor directly +// when available; only fall back to a separate lookup by hash for older +// rows that predate those fields. + +const fetchScript = + (baseUrl: string, headers: Record | undefined) => + (scriptHash: string): Effect.Effect => + HttpUtils.get(`${baseUrl}/script/${scriptHash}`, BCA.Envelope(BCA.ScriptDetails), headers).pipe( + Effect.timeout(TIMEOUT), + Effect.map((response) => BCA.transformScript(response.details)) + ) + +const fetchDatumOption = + (baseUrl: string, headers: Record | undefined) => + (datumHash: string): Effect.Effect => + HttpUtils.get(`${baseUrl}/datum/${datumHash}`, BCA.Envelope(BCA.DatumDetails), headers).pipe( + Effect.timeout(TIMEOUT), + Effect.map((response) => BCA.inlineDatumFromCBORHex(response.details.bytes)) + ) + +const resolveUtxo = + (baseUrl: string, headers: Record | undefined) => + (item: BCA.UtxoResponse): Effect.Effect => { + const scriptEffect = + item.reference_script_cbor && item.reference_script_type + ? Effect.succeed(BCA.transformScript({ type: item.reference_script_type, bytes: item.reference_script_cbor })) + : item.reference_script_hash + ? fetchScript( + baseUrl, + headers + )(item.reference_script_hash).pipe(Effect.catchAll(() => Effect.succeed(undefined))) + : Effect.succeed(undefined) + + const datumEffect = item.inline_datum + ? Effect.succeed(BCA.inlineDatumFromCBORHex(item.inline_datum)) + : item.datum_hash + ? fetchDatumOption( + baseUrl, + headers + )(item.datum_hash).pipe(Effect.catchAll(() => Effect.succeed(BCA.datumHashFromHex(item.datum_hash!)))) + : Effect.succeed(undefined) + + return Effect.all([scriptEffect, datumEffect]).pipe( + Effect.map( + ([scriptRef, datumOption]) => + new CoreUTxO.UTxO({ + transactionId: TransactionHash.fromHex(item.tx_hash), + index: BigInt(item.output_index), + address: CoreAddress.fromBech32(item.address), + assets: BCA.transformAssets(item.lovelace, item.assets), + datumOption, + scriptRef + }) + ) + ) + } + +// ============================================================================ +// Paginated UTxO fetch (GET /utxos/{address}, GET /assets/{unit}/utxos) +// ============================================================================ + +const fetchAllUtxoPages = (url: string, headers: Record | undefined) => + Effect.gen(function* () { + let all: Array = [] + // BCA pages are 0-indexed (CIP-30 Paginate convention). + let page = 0 + while (true) { + const response = yield* HttpUtils.get( + `${url}${url.includes("?") ? "&" : "?"}page=${page}&pagesize=${PAGE_SIZE}`, + BCA.PaginatedUtxoResponse, + headers + ).pipe(Effect.timeout(TIMEOUT)) + all = all.concat(response.data) + if (!response.has_next) break + page += 1 + } + return all + }) + +// ============================================================================ +// Protocol parameters +// ============================================================================ + +export const getProtocolParameters = (baseUrl: string, token?: string) => + HttpUtils.get(`${baseUrl}/protocol_parameters/latest`, BCA.ProtocolParametersResponse, bearerHeaders(token)).pipe( + Effect.map(BCA.transformProtocolParameters), + Effect.timeout(TIMEOUT), + Effect.catchAll(wrapError("getProtocolParameters")) + ) + +// ============================================================================ +// UTxOs +// ============================================================================ + +export const getUtxos = + (baseUrl: string, token?: string) => (addressOrCredential: CoreAddress.Address | Credential.Credential) => { + const headers = bearerHeaders(token) + const addressPath = getAddressPath(addressOrCredential) + return fetchAllUtxoPages(`${baseUrl}/utxos/${addressPath}`, headers).pipe( + Effect.flatMap((utxos) => Effect.forEach(utxos, resolveUtxo(baseUrl, headers), { concurrency: 10 })), + Effect.catchAll(wrapError("getUtxos")) + ) + } + +export const getUtxosWithUnit = + (baseUrl: string, token?: string) => + (addressOrCredential: CoreAddress.Address | Credential.Credential, unit: string) => { + const headers = bearerHeaders(token) + const addressPath = getAddressPath(addressOrCredential) + return fetchAllUtxoPages(`${baseUrl}/utxos/${addressPath}`, headers).pipe( + Effect.map((utxos) => utxos.filter((utxo) => utxo.assets.some((a) => `${a.policy_id}${a.asset_name}` === unit))), + Effect.flatMap((utxos) => Effect.forEach(utxos, resolveUtxo(baseUrl, headers), { concurrency: 10 })), + Effect.catchAll(wrapError("getUtxosWithUnit")) + ) + } + +export const getUtxoByUnit = (baseUrl: string, token?: string) => (unit: string) => { + const headers = bearerHeaders(token) + return fetchAllUtxoPages(`${baseUrl}/assets/${unit}/utxos`, headers).pipe( + Effect.flatMap((utxos) => { + if (utxos.length === 0) { + return Effect.fail(new Provider.ProviderError({ message: "No UTxO found for unit", cause: "Not found" })) + } + if (utxos.length > 1) { + return Effect.fail( + new Provider.ProviderError({ + message: "Unit needs to be an NFT or only held by one address.", + cause: "Multiple UTxOs found" + }) + ) + } + return resolveUtxo(baseUrl, headers)(utxos[0]) + }), + Effect.catchAll(wrapError("getUtxoByUnit")) + ) +} + +export const getUtxosByOutRef = + (baseUrl: string, token?: string) => (inputs: ReadonlyArray) => { + const headers = bearerHeaders(token) + + return Effect.forEach( + inputs, + (input) => { + const txHash = TransactionHash.toHex(input.transactionId) + return HttpUtils.get(`${baseUrl}/utxo/${txHash}/${Number(input.index)}`, BCA.UtxoResponse, headers).pipe( + Effect.timeout(TIMEOUT), + Effect.flatMap(resolveUtxo(baseUrl, headers)), + Effect.map((utxo) => Option.some(utxo)), + Effect.catchIf(is404, () => Effect.succeed(Option.none())), + Effect.catchAll(wrapError("getUtxosByOutRef")) + ) + }, + { concurrency: 10 } + ).pipe(Effect.map((results) => results.filter(Option.isSome).map((result) => result.value))) + } + +// ============================================================================ +// Delegation +// ============================================================================ + +export const getDelegation = (baseUrl: string, token?: string) => (rewardAddress: RewardAddress.RewardAddress) => + HttpUtils.get(`${baseUrl}/staking/${rewardAddress}`, BCA.StakingResponse, bearerHeaders(token)).pipe( + Effect.timeout(TIMEOUT), + Effect.map(BCA.transformDelegation), + // 404 — stake address not registered/never delegated. + Effect.catchIf(is404, () => Effect.succeed({ poolId: null, rewards: 0n } as Provider.Delegation)), + Effect.catchAll(wrapError("getDelegation")) + ) + +// ============================================================================ +// Datum +// ============================================================================ + +export const getDatum = (baseUrl: string, token?: string) => (datumHash: DatumHash.DatumHash) => { + const datumHashHex = Bytes.toHex(datumHash.hash) + return HttpUtils.get(`${baseUrl}/datum/${datumHashHex}`, BCA.Envelope(BCA.DatumDetails), bearerHeaders(token)).pipe( + Effect.timeout(TIMEOUT), + Effect.flatMap((response) => + Effect.try({ + try: () => Schema.decodeSync(PlutusData.FromCBORHex())(response.details.bytes), + catch: (error) => new Provider.ProviderError({ message: "Failed to parse datum CBOR", cause: error }) + }) + ), + Effect.catchAll(wrapError("getDatum")) + ) +} + +// ============================================================================ +// awaitTx +// ============================================================================ + +export const awaitTx = + (baseUrl: string, token?: string) => + (txHash: TransactionHash.TransactionHash, checkInterval = 20_000, timeout = 160_000) => { + const txHashHex = TransactionHash.toHex(txHash) + const headers = bearerHeaders(token) + + // GET /tx/{hash} 404s until the tx is indexed, 200s once confirmed. + const checkTx = HttpUtils.get(`${baseUrl}/tx/${txHashHex}`, BCA.TransactionResponse, headers).pipe( + Effect.timeout(TIMEOUT) + ) + + return Effect.retry(checkTx, Schedule.spaced(`${checkInterval} millis`)).pipe( + Effect.timeout(timeout), + Effect.as(true), + Effect.catchAllCause((cause) => + Effect.fail(new Provider.ProviderError({ cause, message: "BlockchainApplied awaitTx failed" })) + ) + ) + } + +// ============================================================================ +// submitTx / evaluateTx +// ============================================================================ + +export const submitTx = (baseUrl: string, token?: string) => (tx: Transaction.Transaction) => + HttpUtils.postUint8Array( + `${baseUrl}/tx/submit`, + Transaction.toCBORBytes(tx), + BCA.SubmitResponse, + bearerHeaders(token) + ).pipe( + Effect.timeout(TIMEOUT), + Effect.map((response) => TransactionHash.fromHex(response.tx_hash)), + Effect.catchAll(wrapError("submitTx")) + ) + +const toAdditionalUtxo = (utxo: CoreUTxO.UTxO): BCA.UtxoResponse => ({ + tx_hash: TransactionHash.toHex(utxo.transactionId), + output_index: Number(utxo.index), + address: CoreAddress.toBech32(utxo.address), + lovelace: utxo.assets.lovelace.toString(), + assets: CoreAssets.flatten(utxo.assets) + .filter(([, , quantity]) => quantity !== 0n) + .map(([policyId, assetName, quantity]) => ({ + policy_id: PolicyId.toHex(policyId), + asset_name: AssetName.toHex(assetName), + quantity: quantity.toString() + })), + datum_hash: utxo.datumOption?._tag === "DatumHash" ? Bytes.toHex(utxo.datumOption.hash) : undefined, + inline_datum: utxo.datumOption?._tag === "InlineDatum" ? PlutusData.toCBORHex(utxo.datumOption.data) : undefined, + ...toReferenceScriptFields(utxo.scriptRef) +}) + +const toReferenceScriptFields = ( + script: Script.Script | undefined +): { reference_script_cbor: string; reference_script_type: string } | Record => { + if (!script) return {} + switch (script._tag) { + case "PlutusV1": + return { reference_script_cbor: Bytes.toHex(script.bytes), reference_script_type: "plutusV1" } + case "PlutusV2": + return { reference_script_cbor: Bytes.toHex(script.bytes), reference_script_type: "plutusV2" } + case "PlutusV3": + return { reference_script_cbor: Bytes.toHex(script.bytes), reference_script_type: "plutusV3" } + case "NativeScript": + return { reference_script_cbor: NativeScripts.toCBORHex(script), reference_script_type: "timelock" } + } +} + +export const evaluateTx = + (baseUrl: string, token?: string) => (tx: Transaction.Transaction, additionalUTxOs?: Array) => { + const body = { + cbor: Transaction.toCBORHex(tx), + ...(additionalUTxOs && additionalUTxOs.length > 0 + ? { additional_utxos: additionalUTxOs.map(toAdditionalUtxo) } + : {}) + } + + return HttpUtils.postJson( + `${baseUrl}/tx/evaluate`, + body, + Schema.Array(BCA.EvalRedeemerResponse), + bearerHeaders(token) + ).pipe( + Effect.timeout(TIMEOUT), + Effect.map((response) => response.map(BCA.transformEvalRedeemer)), + Effect.catchAll(wrapError("evaluateTx")) + ) + } diff --git a/packages/evolution/test/provider/conformance.ts b/packages/evolution/test/provider/conformance.ts index 0dd4dd34c..f4a69f484 100644 --- a/packages/evolution/test/provider/conformance.ts +++ b/packages/evolution/test/provider/conformance.ts @@ -23,7 +23,7 @@ import { preprodScriptAddress, preprodStakeAddress, preprodTxHash, - previewTxHash, + previewTxHash } from "./fixtures/constants.js" import { evalSample1, evalSample2, evalSample3, evalSample4 } from "./fixtures/evaluateTx.js" @@ -148,9 +148,7 @@ export function registerConformanceTests(factory: () => Provider, options?: Conf }) it("awaitTx rejects for preview-only tx on preprod", { timeout: 10_000 }, async () => { - await expect(provider.awaitTx(previewTxHash(), 1000, 5000)).rejects.toThrow( - "awaitTx failed" - ) + await expect(provider.awaitTx(previewTxHash(), 1000, 5000)).rejects.toThrow("awaitTx failed") }) // submitTx and evaluateTx require a valid signed CBOR tx — skipped until we have tx fixtures diff --git a/packages/evolution/test/provider/providers.test.ts b/packages/evolution/test/provider/providers.test.ts index d7f7c70ea..3c64dd16e 100644 --- a/packages/evolution/test/provider/providers.test.ts +++ b/packages/evolution/test/provider/providers.test.ts @@ -10,6 +10,7 @@ */ import { describe, expect, it } from "vitest" +import { BlockchainAppliedProvider } from "../../src/sdk/provider/BlockchainApplied.js" import { BlockfrostProvider } from "../../src/sdk/provider/Blockfrost.js" import { Koios } from "../../src/sdk/provider/Koios.js" import { KupmiosProvider } from "../../src/sdk/provider/Kupmios.js" @@ -35,6 +36,8 @@ const BLOCKFROST_URL = process.env.BLOCKFROST_PREPROD_URL ?? "https://cardano-pr const BLOCKFROST_KEY = process.env.BLOCKFROST_PREPROD_KEY const MAESTRO_URL = process.env.MAESTRO_PREPROD_URL ?? "https://preprod.gomaestro-api.org/v1" const MAESTRO_KEY = process.env.MAESTRO_PREPROD_KEY +const BCA_URL = process.env.BCA_PREPROD_URL ?? "https://staging.blockchain-applied.com/api_preprod/v1" +const BCA_KEY = process.env.BCA_PREPROD_KEY const KUPO_URL = process.env.KUPMIOS_KUPO_URL const OGMIOS_URL = process.env.KUPMIOS_OGMIOS_URL const KUPMIOS_KUPO_KEY = process.env.KUPMIOS_KUPO_KEY @@ -63,7 +66,9 @@ describe.skipIf(!process.env.KOIOS_PREVIEW_ENABLED)("Koios (preview)", () => { // ── Blockfrost ──────────────────────────────────────────────────────────────── describe.skipIf(!isConfigured(BLOCKFROST_KEY, "preprodXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"))("Blockfrost", () => { - registerConformanceTests(() => new BlockfrostProvider(BLOCKFROST_URL, BLOCKFROST_KEY), { supportsCredentialQueries: true }) + registerConformanceTests(() => new BlockfrostProvider(BLOCKFROST_URL, BLOCKFROST_KEY), { + supportsCredentialQueries: true + }) }) // ── Maestro ─────────────────────────────────────────────────────────────────── @@ -71,10 +76,14 @@ describe.skipIf(!isConfigured(MAESTRO_KEY, "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"))( registerConformanceTests(() => new MaestroProvider(MAESTRO_URL, MAESTRO_KEY!)) }) +// ── Blockchain Applied (BCA) ────────────────────────────────────────────────── +describe.skipIf(!isConfigured(BCA_KEY, "bca_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"))("BlockchainApplied", () => { + registerConformanceTests(() => new BlockchainAppliedProvider(BCA_URL, BCA_KEY)) +}) + // ── Kupmios (self-hosted or Demeter) ────────────────────────────────────────── describe.skipIf( - !isConfigured(KUPO_URL, "https://your-kupo-endpoint") || - !isConfigured(OGMIOS_URL, "https://your-ogmios-endpoint") + !isConfigured(KUPO_URL, "https://your-kupo-endpoint") || !isConfigured(OGMIOS_URL, "https://your-ogmios-endpoint") )("Kupmios", () => { registerConformanceTests( () => From ec6bf619e8cd845d055115a05b717654f7c18dd1 Mon Sep 17 00:00:00 2001 From: Alexander Diemand Date: Tue, 13 Oct 2026 11:33:45 +0200 Subject: [PATCH 2/4] fix(provider): adapt BlockchainApplied to fetch-based HttpUtils and exact integers Upstream dropped @effect/platform (#548) and now hands every JSON number to the response schemas as its source text (#558). - Detect 404s via HttpUtils.HttpResponseError instead of HttpClientError - Decode integer fields with Schema.BigInt and decimals with Schema.NumberFromString; convert to number only where the public ProtocolParameters / EvalRedeemer types require it - Keep the evaluate request's additional_utxos wire format unchanged via an explicit AdditionalUtxo type - Make total_count / total_pages optional: the API no longer returns them, and pagination only relies on has_next Signed-off-by: Alexander Diemand --- .../provider/internal/BlockchainApplied.ts | 123 ++++++++++-------- .../internal/BlockchainAppliedEffect.ts | 7 +- 2 files changed, 74 insertions(+), 56 deletions(-) diff --git a/packages/evolution/src/sdk/provider/internal/BlockchainApplied.ts b/packages/evolution/src/sdk/provider/internal/BlockchainApplied.ts index d17039138..38a459cdd 100644 --- a/packages/evolution/src/sdk/provider/internal/BlockchainApplied.ts +++ b/packages/evolution/src/sdk/provider/internal/BlockchainApplied.ts @@ -5,6 +5,10 @@ * Swagger UI on https://api.blockchain-applied.com/api_ada/v1/docs/ * BCA is backed by cardano-db-sync, so field naming (block_index, out_sum, valid_contract, ...) * follows db-sync conventions rather than Blockfrost/Koios/Maestro's own vocabularies. + * + * HttpUtils hands every JSON number to the schemas as its source text, so integer fields + * decode with `Schema.BigInt` (from a bare number or a digit string) and decimal fields + * with `Schema.NumberFromString`. */ import { Schema } from "effect" @@ -43,10 +47,10 @@ export const Envelope = (details: Schema.Schema) => export const PaginatedResponse = (item: Schema.Schema) => Schema.Struct({ data: Schema.Array(item), - page: Schema.Number, - page_size: Schema.Number, - total_count: Schema.Number, - total_pages: Schema.Number, + page: Schema.BigInt, + page_size: Schema.BigInt, + total_count: Schema.optional(Schema.BigInt), + total_pages: Schema.optional(Schema.BigInt), has_next: Schema.Boolean }) @@ -57,15 +61,15 @@ export const PaginatedResponse = (item: Schema.Schema) => export const UtxoAsset = Schema.Struct({ policy_id: Schema.String, asset_name: Schema.String, - quantity: Schema.String + quantity: Schema.BigInt }) export interface UtxoAsset extends Schema.Schema.Type {} export const UtxoResponse = Schema.Struct({ tx_hash: Schema.String, - output_index: Schema.Number, + output_index: Schema.BigInt, address: Schema.String, - lovelace: Schema.String, + lovelace: Schema.BigInt, assets: Schema.Array(UtxoAsset), datum_hash: Schema.optional(Schema.NullOr(Schema.String)), inline_datum: Schema.optional(Schema.NullOr(Schema.String)), @@ -107,7 +111,7 @@ export interface DatumDetails extends Schema.Schema.Type {} export const ScriptDetails = Schema.Struct({ hash: Schema.String, - size: Schema.Number, + size: Schema.BigInt, type: Schema.String, json: Schema.Unknown, bytes: Schema.String, @@ -123,10 +127,10 @@ export interface ScriptDetails extends Schema.Schema.Type export const StakingResponse = Schema.Struct({ address: Schema.String, pool_id: Schema.optional(Schema.NullOr(Schema.String)), - total_rewards: Schema.String, - total_withdrawn: Schema.String, - withdrawable_rewards: Schema.String, - active_epoch: Schema.optional(Schema.NullOr(Schema.Number)), + total_rewards: Schema.BigInt, + total_withdrawn: Schema.BigInt, + withdrawable_rewards: Schema.BigInt, + active_epoch: Schema.optional(Schema.NullOr(Schema.BigInt)), delegation_tx: Schema.optional(Schema.NullOr(Schema.String)) }) export interface StakingResponse extends Schema.Schema.Type {} @@ -136,24 +140,24 @@ export interface StakingResponse extends Schema.Schema.Type {} @@ -166,16 +170,31 @@ export const SubmitResponse = Schema.Struct({ }) export const ExUnits = Schema.Struct({ - mem: Schema.String, - steps: Schema.String + mem: Schema.BigInt, + steps: Schema.BigInt }) export const EvalRedeemerResponse = Schema.Struct({ redeemer_tag: Schema.Literal("spend", "mint", "cert", "reward", "vote", "propose"), - redeemer_index: Schema.Number, + redeemer_index: Schema.BigInt, ex_units: ExUnits }) +/** + * A UTxO as sent in the `additional_utxos` of POST /tx/evaluate + */ +export interface AdditionalUtxo { + readonly tx_hash: string + readonly output_index: number + readonly address: string + readonly lovelace: string + readonly assets: ReadonlyArray<{ readonly policy_id: string; readonly asset_name: string; readonly quantity: string }> + readonly datum_hash?: string + readonly inline_datum?: string + readonly reference_script_cbor?: string + readonly reference_script_type?: string +} + // ============================================================================ // Transformation utilities // ============================================================================ @@ -204,21 +223,21 @@ const extractCostModel = (costModels: unknown, keys: ReadonlyArray): Rec } export const transformProtocolParameters = (response: ProtocolParametersResponse): Provider.ProtocolParameters => ({ - minFeeA: response.min_fee_a, - minFeeB: response.min_fee_b, - maxTxSize: response.max_tx_size, - maxValSize: response.max_val_size, - keyDeposit: BigInt(response.key_deposit), - poolDeposit: BigInt(response.pool_deposit), - drepDeposit: BigInt(response.drep_deposit ?? "0"), - govActionDeposit: BigInt(response.gov_action_deposit ?? "0"), + minFeeA: Number(response.min_fee_a), + minFeeB: Number(response.min_fee_b), + maxTxSize: Number(response.max_tx_size), + maxValSize: Number(response.max_val_size), + keyDeposit: response.key_deposit, + poolDeposit: response.pool_deposit, + drepDeposit: response.drep_deposit ?? 0n, + govActionDeposit: response.gov_action_deposit ?? 0n, priceMem: response.price_mem, priceStep: response.price_step, - maxTxExMem: BigInt(response.max_tx_ex_mem), - maxTxExSteps: BigInt(response.max_tx_ex_steps), - coinsPerUtxoByte: BigInt(response.coins_per_utxo_size ?? "0"), - collateralPercentage: response.collateral_percentage ?? 0, - maxCollateralInputs: response.max_collateral_inputs ?? 0, + maxTxExMem: response.max_tx_ex_mem, + maxTxExSteps: response.max_tx_ex_steps, + coinsPerUtxoByte: response.coins_per_utxo_size ?? 0n, + collateralPercentage: Number(response.collateral_percentage ?? 0n), + maxCollateralInputs: Number(response.max_collateral_inputs ?? 0n), minFeeRefScriptCostPerByte: response.min_fee_ref_script_cost_per_byte ?? 0, costModels: { PlutusV1: extractCostModel(response.cost_models, ["PlutusV1", "plutus_v1"]), @@ -227,10 +246,10 @@ export const transformProtocolParameters = (response: ProtocolParametersResponse } }) -export const transformAssets = (lovelace: string, assets: ReadonlyArray): CoreAssets.Assets => { - let result = CoreAssets.fromLovelace(BigInt(lovelace)) +export const transformAssets = (lovelace: bigint, assets: ReadonlyArray): CoreAssets.Assets => { + let result = CoreAssets.fromLovelace(lovelace) for (const asset of assets) { - result = CoreAssets.addByHex(result, asset.policy_id, asset.asset_name, BigInt(asset.quantity)) + result = CoreAssets.addByHex(result, asset.policy_id, asset.asset_name, asset.quantity) } return result } @@ -254,15 +273,15 @@ export const transformScript = (details: { type: string; bytes: string }): Scrip export const transformDelegation = (response: StakingResponse): Provider.Delegation => ({ poolId: response.pool_id ? PoolKeyHash.fromBech32(response.pool_id) : null, - rewards: BigInt(response.withdrawable_rewards) + rewards: response.withdrawable_rewards }) export const transformEvalRedeemer = (redeemer: Schema.Schema.Type): EvalRedeemer => ({ redeemer_tag: redeemer.redeemer_tag as Redeemer.RedeemerTag, - redeemer_index: redeemer.redeemer_index, + redeemer_index: Number(redeemer.redeemer_index), ex_units: new Redeemer.ExUnits({ - mem: BigInt(redeemer.ex_units.mem), - steps: BigInt(redeemer.ex_units.steps) + mem: redeemer.ex_units.mem, + steps: redeemer.ex_units.steps }) }) diff --git a/packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts b/packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts index a94d69647..b1c08c606 100644 --- a/packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts +++ b/packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts @@ -3,7 +3,6 @@ * Internal module implementing all provider operations using the Effect pattern. */ -import { HttpClientError } from "@effect/platform" import { Effect, Option, Schedule, Schema } from "effect" import * as CoreAddress from "../../../Address.js" @@ -44,7 +43,7 @@ const getAddressPath = (addressOrCredential: CoreAddress.Address | Credential.Cr "hash" in addressOrCredential ? Credential.toBech32(addressOrCredential) : CoreAddress.toBech32(addressOrCredential) const is404 = (error: unknown): boolean => - error instanceof HttpClientError.ResponseError && error.response.status === 404 + error instanceof HttpUtils.HttpResponseError && error.status === 404 // ============================================================================ // Script / datum resolution @@ -96,7 +95,7 @@ const resolveUtxo = ([scriptRef, datumOption]) => new CoreUTxO.UTxO({ transactionId: TransactionHash.fromHex(item.tx_hash), - index: BigInt(item.output_index), + index: item.output_index, address: CoreAddress.fromBech32(item.address), assets: BCA.transformAssets(item.lovelace, item.assets), datumOption, @@ -277,7 +276,7 @@ export const submitTx = (baseUrl: string, token?: string) => (tx: Transaction.Tr Effect.catchAll(wrapError("submitTx")) ) -const toAdditionalUtxo = (utxo: CoreUTxO.UTxO): BCA.UtxoResponse => ({ +const toAdditionalUtxo = (utxo: CoreUTxO.UTxO): BCA.AdditionalUtxo => ({ tx_hash: TransactionHash.toHex(utxo.transactionId), output_index: Number(utxo.index), address: CoreAddress.toBech32(utxo.address), From a8e23ced2d8cbe189d5ec3b5116906e1bd102691 Mon Sep 17 00:00:00 2001 From: Alexander Diemand Date: Thu, 12 Nov 2026 13:14:06 +0100 Subject: [PATCH 3/4] adapt to code review on Github Signed-off-by: Alexander Diemand --- .changeset/blockchain-applied-provider.md | 2 +- .gitignore | 1 - README.md | 3 +- .../docs/advanced/custom-providers.mdx | 44 +- docs/content/docs/providers/index.mdx | 15 +- .../content/docs/providers/provider-types.mdx | 72 ++- packages/evolution/src/sdk/client/Client.ts | 2 +- .../src/sdk/client/internal/Client.ts | 8 +- .../src/sdk/provider/BlockchainApplied.ts | 14 +- .../provider/internal/BlockchainApplied.ts | 90 ++-- .../internal/BlockchainAppliedEffect.ts | 128 +++--- .../test/provider/BlockchainApplied.test.ts | 420 ++++++++++++++++++ .../evolution/test/provider/conformance.ts | 6 +- .../evolution/test/provider/providers.test.ts | 9 +- 14 files changed, 618 insertions(+), 196 deletions(-) create mode 100644 packages/evolution/test/provider/BlockchainApplied.test.ts diff --git a/.changeset/blockchain-applied-provider.md b/.changeset/blockchain-applied-provider.md index e6a36ad52..d5a2c3bbc 100644 --- a/.changeset/blockchain-applied-provider.md +++ b/.changeset/blockchain-applied-provider.md @@ -1,5 +1,5 @@ --- -"@evolution-sdk/evolution": minor +"@evolution-sdk/evolution": patch --- Add Blockchain Applied (BCA) as a built-in provider (`Client.make(...).withBlockchainApplied({ baseUrl, token })`, or `BlockchainAppliedProvider` / `mainnet` / `preprod` / `preview` / `custom` directly). Covers the full `ProviderEffect` interface: `getProtocolParameters`, `getUtxos`, `getUtxosWithUnit`, `getUtxoByUnit`, `getUtxosByOutRef`, `getDelegation`, `getDatum`, `awaitTx`, `submitTx`, and `evaluateTx`. diff --git a/.gitignore b/.gitignore index d8ae95ba5..c810abd80 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,5 @@ CLAUDE.md AGENTS.md # Local env overrides — never commit API keys -**/*.local .env.local .env.test.local diff --git a/README.md b/README.md index cfec5d487..0ab8e09df 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,7 @@ Join our thriving community of Cardano developers: - [x] `Blockfrost` - Blockfrost API provider - [x] `Koios` - Koios API provider - [x] `KupoOgmios` - Kupo/Ogmios provider + - [x] `BlockchainApplied` - Blockchain Applied API provider - [ ] `UtxoRpc` - UTXO RPC provider - [x] Provider abstraction layer - [ ] Failover and load balancing @@ -333,7 +334,7 @@ Join our thriving community of Cardano developers: - [x] Schema types from Plutus blueprint types (codegen) ### Current Focus -The core SDK covers transaction building, smart contracts (Plutus V1/V2/V3), providers (Blockfrost, Maestro, Koios, Kupo/Ogmios), CIP-30 wallet integration, Conway governance, and staking. Current priorities include additional coin selection algorithms, UTXO RPC provider support, hardware wallet integration, and Hydra integration. +The core SDK covers transaction building, smart contracts (Plutus V1/V2/V3), providers (Blockfrost, Maestro, Koios, Kupo/Ogmios, Blockchain Applied), CIP-30 wallet integration, Conway governance, and staking. Current priorities include additional coin selection algorithms, UTXO RPC provider support, hardware wallet integration, and Hydra integration. ## Contributing diff --git a/docs/content/docs/advanced/custom-providers.mdx b/docs/content/docs/advanced/custom-providers.mdx index 25bf6763f..905a9b13a 100644 --- a/docs/content/docs/advanced/custom-providers.mdx +++ b/docs/content/docs/advanced/custom-providers.mdx @@ -6,8 +6,7 @@ description: Implement your own blockchain provider # Custom Providers - The SDK currently provides five built-in providers. Custom provider implementation requires working with the internal - `ProviderEffect` interface — this is an advanced topic and the API may change. +The SDK currently provides five built-in providers. Custom provider implementation requires working with the internal `ProviderEffect` interface — this is an advanced topic and the API may change. ## Provider Interface @@ -33,13 +32,13 @@ Understanding this interface is useful for knowing what data each provider must ## Built-in Providers -| Provider | Connection | Best For | -| ---------------------- | ---------------- | -------------------------------------- | -| **Blockfrost** | REST API | Production apps, simple setup | -| **Maestro** | REST API | Advanced queries, data analytics | -| **Koios** | REST API | Open source, community maintained | -| **Kupo/Ogmios** | WebSocket + REST | Self-hosted, devnet, full control | -| **Blockchain Applied** | REST API | Full-featured Cardano indexer/node API | +| Provider | Connection | Best For | +| ---------------------- | ---------------- | --------------------------------- | +| **Blockfrost** | REST API | Production apps, simple setup | +| **Maestro** | REST API | Advanced queries, data analytics | +| **Koios** | REST API | Open source, community maintained | +| **Kupo/Ogmios** | WebSocket + REST | Self-hosted, devnet, full control | +| **Blockchain Applied** | REST API | Hosted REST API, bearer token | ## Using a Provider @@ -47,22 +46,25 @@ Understanding this interface is useful for knowing what data each provider must import { preprod, Client } from "@evolution-sdk/evolution" // Blockfrost -const blockfrostClient = Client.make(preprod).withBlockfrost({ - baseUrl: "https://cardano-preprod.blockfrost.io/api/v0", - projectId: process.env.BLOCKFROST_API_KEY! -}) +const blockfrostClient = Client.make(preprod) + .withBlockfrost({ + baseUrl: "https://cardano-preprod.blockfrost.io/api/v0", + projectId: process.env.BLOCKFROST_API_KEY! + }) // Kupo/Ogmios -const kupmiosClient = Client.make(preprod).withKupmios({ - kupoUrl: "http://localhost:1442", - ogmiosUrl: "http://localhost:1337" -}) +const kupmiosClient = Client.make(preprod) + .withKupmios({ + kupoUrl: "http://localhost:1442", + ogmiosUrl: "http://localhost:1337" + }) // Blockchain Applied -const bcaClient = Client.make(preprod).withBlockchainApplied({ - baseUrl: "https://api.blockchain-applied.com/api_preprod/v1", - token: process.env.BCA_API_KEY! -}) +const bcaClient = Client.make(preprod) + .withBlockchainApplied({ + baseUrl: "https://api.blockchain-applied.com/api_preprod/v1", + token: process.env.BCA_API_KEY! + }) ``` ## Next Steps diff --git a/docs/content/docs/providers/index.mdx b/docs/content/docs/providers/index.mdx index 79ae75be5..ade84792d 100644 --- a/docs/content/docs/providers/index.mdx +++ b/docs/content/docs/providers/index.mdx @@ -41,12 +41,13 @@ const txHash = await client.submitTx(signedTx) ## Available Providers -| Provider | Infrastructure | Best For | -| -------------- | -------------- | ---------------------------------- | -| **Blockfrost** | Hosted API | Production apps, rapid development | -| **Kupmios** | Ogmios + Kupo | Self-hosted, full control | -| **Maestro** | Hosted API | Advanced features, analytics | -| **Koios** | Community API | Decentralized infrastructure | +| Provider | Infrastructure | Best For | +| ---------------------- | -------------- | ---------------------------------- | +| **Blockfrost** | Hosted API | Production apps, rapid development | +| **Kupmios** | Ogmios + Kupo | Self-hosted, full control | +| **Maestro** | Hosted API | Advanced features, analytics | +| **Koios** | Community API | Decentralized infrastructure | +| **Blockchain Applied** | Hosted API | Hosted REST API, bearer token | See [Provider Types](/docs/providers/provider-types) for detailed comparison and configuration. @@ -88,7 +89,7 @@ Understanding the difference helps choose the right architecture: - Compare Blockfrost, Kupmios, Maestro, and Koios configurations + Compare Blockfrost, Kupmios, Maestro, Koios, and Blockchain Applied configurations Learn to use provider without wallet for generic queries diff --git a/docs/content/docs/providers/provider-types.mdx b/docs/content/docs/providers/provider-types.mdx index 3abac0dbb..186f5b2f9 100644 --- a/docs/content/docs/providers/provider-types.mdx +++ b/docs/content/docs/providers/provider-types.mdx @@ -1,11 +1,11 @@ --- title: Provider Types -description: Compare and configure Blockfrost, Kupmios, Maestro, and Koios providers +description: Compare and configure Blockfrost, Kupmios, Maestro, Koios, and Blockchain Applied providers --- # Provider Types -The Evolution SDK supports four provider types, each connecting to different Cardano infrastructure. Choose based on your deployment model, feature requirements, and infrastructure preferences. +The Evolution SDK supports five provider types, each connecting to different Cardano infrastructure. Choose based on your deployment model, feature requirements, and infrastructure preferences. ## Blockfrost @@ -276,19 +276,65 @@ Applications prioritizing decentralization, community infrastructure, or wanting --- +## Blockchain Applied + +Hosted REST API by [Blockchain Applied](https://blockchain-applied.com), backed by cardano-db-sync and a Cardano node. + +### Configuration + +```typescript twoslash +import { mainnet, Client } from "@evolution-sdk/evolution" + +const client = Client.make(mainnet) + .withBlockchainApplied({ + baseUrl: "https://api.blockchain-applied.com/api_ada/v1", + token: process.env.BCA_API_KEY! + }) +``` + +### Network Endpoints + +```typescript +// Mainnet +baseUrl: "https://api.blockchain-applied.com/api_ada/v1" + +// Preprod testnet +baseUrl: "https://api.blockchain-applied.com/api_preprod/v1" + +// Preview testnet +baseUrl: "https://api.blockchain-applied.com/api_preview/v1" +``` + +### Best For + +Applications that want a hosted REST API with a bearer token. + +**Advantages:** + +- Simple setup +- UTxO queries by address or payment credential +- Transaction submission and script evaluation included + +**Considerations:** + +- API token required for every endpoint +- Rate limits per token + +--- + ## Provider Comparison Matrix -| Feature | Blockfrost | Kupmios | Maestro | Koios | -| --------------------- | ---------------- | -------------------- | ---------- | ---------------- | -| **Setup Complexity** | Low | High | Low | Low | -| **Infrastructure** | Hosted | Self-hosted | Hosted | Community-hosted | -| **API Key Required** | Yes | No | Yes | No | -| **Rate Limits** | Yes | No | Yes | Varies | -| **Cost** | Free tier + paid | Infrastructure costs | Paid tiers | Free | -| **Data Sovereignty** | No | Yes | No | No | -| **Decentralization** | Low | High | Low | Medium | -| **Advanced Features** | Standard | Full control | Extended | Standard | -| **Uptime SLA** | Commercial | Self-managed | Commercial | Best effort | +| Feature | Blockfrost | Kupmios | Maestro | Koios | Blockchain Applied | +| --------------------- | ---------------- | -------------------- | ---------- | ---------------- | ------------------ | +| **Setup Complexity** | Low | High | Low | Low | Low | +| **Infrastructure** | Hosted | Self-hosted | Hosted | Community-hosted | Hosted | +| **API Key Required** | Yes | No | Yes | No | Yes | +| **Rate Limits** | Yes | No | Yes | Varies | Yes | +| **Cost** | Free tier + paid | Infrastructure costs | Paid tiers | Free | Paid tiers | +| **Data Sovereignty** | No | Yes | No | No | No | +| **Decentralization** | Low | High | Low | Medium | Low | +| **Advanced Features** | Standard | Full control | Extended | Standard | Standard | +| **Uptime SLA** | Commercial | Self-managed | Commercial | Best effort | Commercial | ## Switching Providers diff --git a/packages/evolution/src/sdk/client/Client.ts b/packages/evolution/src/sdk/client/Client.ts index a7fa94214..850538dd9 100644 --- a/packages/evolution/src/sdk/client/Client.ts +++ b/packages/evolution/src/sdk/client/Client.ts @@ -98,7 +98,7 @@ export interface KoiosConfig { */ export interface BlockchainAppliedConfig { readonly baseUrl: string - readonly token?: string + readonly token: string } /** diff --git a/packages/evolution/src/sdk/client/internal/Client.ts b/packages/evolution/src/sdk/client/internal/Client.ts index 7dbaf31a6..46cae6950 100644 --- a/packages/evolution/src/sdk/client/internal/Client.ts +++ b/packages/evolution/src/sdk/client/internal/Client.ts @@ -143,8 +143,7 @@ const createSigningClient = ( const createReadClient = (chain: Chain, provider: Provider.Provider): ReadClient => ({ ...provider, chain, - withAddress: (address, rewardAddress) => - createReadOnlyClient(chain, provider, Wallets.readOnlyWallet(address, rewardAddress)(chain)), + withAddress: (address, rewardAddress) => createReadOnlyClient(chain, provider, Wallets.readOnlyWallet(address, rewardAddress)(chain)), withSeed: (config) => createSigningClient(chain, provider, Wallets.seedWallet(config)(chain)), withPrivateKey: (config) => createSigningClient(chain, provider, Wallets.privateKeyWallet(config)(chain)), withCip30: (api) => createSigningClient(chain, provider, Wallets.cip30Wallet(api)(chain)), @@ -159,9 +158,8 @@ export const client = (chain: Chain = mainnet): ClientAssembly => ({ withKupmios: (config) => createReadClient(chain, Providers.kupmios(config)), withMaestro: (config) => createReadClient(chain, Providers.maestro(config)), withBlockchainApplied: (config) => createReadClient(chain, Providers.blockchainApplied(config)), - withAddress: (address, rewardAddress) => - createAddressClient(chain, Wallets.readOnlyWallet(address, rewardAddress)(chain)), + withAddress: (address, rewardAddress) => createAddressClient(chain, Wallets.readOnlyWallet(address, rewardAddress)(chain)), withSeed: (config) => createOfflineSignerClient(chain, Wallets.seedWallet(config)(chain)), withPrivateKey: (config) => createOfflineSignerClient(chain, Wallets.privateKeyWallet(config)(chain)), withCip30: (api) => createOfflineSignerClient(chain, Wallets.cip30Wallet(api)(chain)) -}) +}) \ No newline at end of file diff --git a/packages/evolution/src/sdk/provider/BlockchainApplied.ts b/packages/evolution/src/sdk/provider/BlockchainApplied.ts index 76f4cc766..fb7f97c98 100644 --- a/packages/evolution/src/sdk/provider/BlockchainApplied.ts +++ b/packages/evolution/src/sdk/provider/BlockchainApplied.ts @@ -7,7 +7,7 @@ const HOST = "https://api.blockchain-applied.com" /** * Blockchain Applied (BCA) provider for Cardano blockchain data access. - * Supports Bearer token authentication. + * Every endpoint requires a Bearer token. * * @since 2.0.0 * @category constructors @@ -15,9 +15,9 @@ const HOST = "https://api.blockchain-applied.com" export class BlockchainAppliedProvider implements Provider { readonly effect: ProviderEffect readonly baseUrl: string - readonly token?: string + readonly token: string - constructor(baseUrl: string, token?: string) { + constructor(baseUrl: string, token: string) { this.baseUrl = baseUrl this.token = token @@ -73,7 +73,7 @@ export class BlockchainAppliedProvider implements Provider { * @since 2.0.0 * @category constructors */ -export const mainnet = (token?: string): BlockchainAppliedProvider => +export const mainnet = (token: string): BlockchainAppliedProvider => new BlockchainAppliedProvider(`${HOST}/api_ada/v1`, token) /** @@ -82,7 +82,7 @@ export const mainnet = (token?: string): BlockchainAppliedProvider => * @since 2.0.0 * @category constructors */ -export const preprod = (token?: string): BlockchainAppliedProvider => +export const preprod = (token: string): BlockchainAppliedProvider => new BlockchainAppliedProvider(`${HOST}/api_preprod/v1`, token) /** @@ -91,7 +91,7 @@ export const preprod = (token?: string): BlockchainAppliedProvider => * @since 2.0.0 * @category constructors */ -export const preview = (token?: string): BlockchainAppliedProvider => +export const preview = (token: string): BlockchainAppliedProvider => new BlockchainAppliedProvider(`${HOST}/api_preview/v1`, token) /** @@ -100,5 +100,5 @@ export const preview = (token?: string): BlockchainAppliedProvider => * @since 2.0.0 * @category constructors */ -export const custom = (baseUrl: string, token?: string): BlockchainAppliedProvider => +export const custom = (baseUrl: string, token: string): BlockchainAppliedProvider => new BlockchainAppliedProvider(baseUrl, token) diff --git a/packages/evolution/src/sdk/provider/internal/BlockchainApplied.ts b/packages/evolution/src/sdk/provider/internal/BlockchainApplied.ts index 38a459cdd..500477c06 100644 --- a/packages/evolution/src/sdk/provider/internal/BlockchainApplied.ts +++ b/packages/evolution/src/sdk/provider/internal/BlockchainApplied.ts @@ -34,7 +34,7 @@ import type * as Provider from "../Provider.js" // ============================================================================ /** - * Some BCA resource endpoints (datum, script) wrap their payload as + * Some BCA resource endpoints (datum) wrap their payload as * { summary, details }. `summary` is a resource-category label, not a * union discriminator. */ @@ -82,16 +82,12 @@ export interface UtxoResponse extends Schema.Schema.Type {} export const PaginatedUtxoResponse = PaginatedResponse(UtxoResponse) // ============================================================================ -// Transaction (GET /tx/{hash}) — inputs/outputs are UtxoResponse-shaped, so -// getUtxosByOutRef can reuse the same UTxO resolution path as GET /utxos/{address}. +// Transaction (GET /tx/{hash}) — awaitTx only needs to know the tx is indexed. // ============================================================================ -export const TransactionResponse = Schema.Struct({ - hash: Schema.String, - inputs: Schema.Array(UtxoResponse), - outputs: Schema.Array(UtxoResponse) +export const TxHashResponse = Schema.Struct({ + hash: Schema.String }) -export interface TransactionResponse extends Schema.Schema.Type {} // ============================================================================ // Datum (GET /datum/{datumhash}) @@ -105,20 +101,6 @@ export const DatumDetails = Schema.Struct({ }) export interface DatumDetails extends Schema.Schema.Type {} -// ============================================================================ -// Script (GET /script/{scripthash}) -// ============================================================================ - -export const ScriptDetails = Schema.Struct({ - hash: Schema.String, - size: Schema.BigInt, - type: Schema.String, - json: Schema.Unknown, - bytes: Schema.String, - tx_hash: Schema.String -}) -export interface ScriptDetails extends Schema.Schema.Type {} - // ============================================================================ // Staking (GET /staking/{stakeaddr}) — current pool delegation and reward // balance in one flat object. @@ -147,17 +129,21 @@ export const ProtocolParametersResponse = Schema.Struct({ max_val_size: Schema.BigInt, key_deposit: Schema.BigInt, pool_deposit: Schema.BigInt, + drep_deposit: Schema.BigInt, + gov_action_deposit: Schema.BigInt, price_mem: Schema.NumberFromString, price_step: Schema.NumberFromString, max_tx_ex_mem: Schema.BigInt, max_tx_ex_steps: Schema.BigInt, - cost_models: Schema.Unknown, - coins_per_utxo_size: Schema.optional(Schema.NullOr(Schema.BigInt)), - collateral_percentage: Schema.optional(Schema.NullOr(Schema.BigInt)), - drep_deposit: Schema.optional(Schema.NullOr(Schema.BigInt)), - gov_action_deposit: Schema.optional(Schema.NullOr(Schema.BigInt)), - max_collateral_inputs: Schema.optional(Schema.NullOr(Schema.BigInt)), - min_fee_ref_script_cost_per_byte: Schema.optional(Schema.NullOr(Schema.NumberFromString)) + coins_per_utxo_size: Schema.BigInt, + collateral_percentage: Schema.BigInt, + max_collateral_inputs: Schema.BigInt, + min_fee_ref_script_cost_per_byte: Schema.NumberFromString, + cost_models: Schema.Struct({ + PlutusV1: Schema.Array(Schema.BigInt), + PlutusV2: Schema.Array(Schema.BigInt), + PlutusV3: Schema.Array(Schema.BigInt) + }) }) export interface ProtocolParametersResponse extends Schema.Schema.Type {} @@ -175,7 +161,7 @@ export const ExUnits = Schema.Struct({ }) export const EvalRedeemerResponse = Schema.Struct({ - redeemer_tag: Schema.Literal("spend", "mint", "cert", "reward", "vote", "propose"), + redeemer_tag: Redeemer.RedeemerTag, redeemer_index: Schema.BigInt, ex_units: ExUnits }) @@ -199,28 +185,8 @@ export interface AdditionalUtxo { // Transformation utilities // ============================================================================ -/** BCA declares `cost_models` as an opaque object — normalize either array or object shapes. */ -const normalizeCostModel = (raw: unknown): Record => { - if (Array.isArray(raw)) { - return Object.fromEntries(raw.map((value, index) => [index.toString(), Number(value)])) - } - if (raw && typeof raw === "object") { - return Object.fromEntries( - Object.entries(raw as Record).map(([key, value]) => [key, Number(value)]) - ) - } - return {} -} - -const extractCostModel = (costModels: unknown, keys: ReadonlyArray): Record => { - if (costModels && typeof costModels === "object") { - for (const key of keys) { - const value = (costModels as Record)[key] - if (value !== undefined) return normalizeCostModel(value) - } - } - return {} -} +const toCostModel = (costs: ReadonlyArray): Record => + Object.fromEntries(costs.map((value, index) => [index.toString(), Number(value)])) export const transformProtocolParameters = (response: ProtocolParametersResponse): Provider.ProtocolParameters => ({ minFeeA: Number(response.min_fee_a), @@ -229,20 +195,20 @@ export const transformProtocolParameters = (response: ProtocolParametersResponse maxValSize: Number(response.max_val_size), keyDeposit: response.key_deposit, poolDeposit: response.pool_deposit, - drepDeposit: response.drep_deposit ?? 0n, - govActionDeposit: response.gov_action_deposit ?? 0n, + drepDeposit: response.drep_deposit, + govActionDeposit: response.gov_action_deposit, priceMem: response.price_mem, priceStep: response.price_step, maxTxExMem: response.max_tx_ex_mem, maxTxExSteps: response.max_tx_ex_steps, - coinsPerUtxoByte: response.coins_per_utxo_size ?? 0n, - collateralPercentage: Number(response.collateral_percentage ?? 0n), - maxCollateralInputs: Number(response.max_collateral_inputs ?? 0n), - minFeeRefScriptCostPerByte: response.min_fee_ref_script_cost_per_byte ?? 0, + coinsPerUtxoByte: response.coins_per_utxo_size, + collateralPercentage: Number(response.collateral_percentage), + maxCollateralInputs: Number(response.max_collateral_inputs), + minFeeRefScriptCostPerByte: response.min_fee_ref_script_cost_per_byte, costModels: { - PlutusV1: extractCostModel(response.cost_models, ["PlutusV1", "plutus_v1"]), - PlutusV2: extractCostModel(response.cost_models, ["PlutusV2", "plutus_v2"]), - PlutusV3: extractCostModel(response.cost_models, ["PlutusV3", "plutus_v3"]) + PlutusV1: toCostModel(response.cost_models.PlutusV1), + PlutusV2: toCostModel(response.cost_models.PlutusV2), + PlutusV3: toCostModel(response.cost_models.PlutusV3) } }) @@ -277,7 +243,7 @@ export const transformDelegation = (response: StakingResponse): Provider.Delegat }) export const transformEvalRedeemer = (redeemer: Schema.Schema.Type): EvalRedeemer => ({ - redeemer_tag: redeemer.redeemer_tag as Redeemer.RedeemerTag, + redeemer_tag: redeemer.redeemer_tag, redeemer_index: Number(redeemer.redeemer_index), ex_units: new Redeemer.ExUnits({ mem: redeemer.ex_units.mem, diff --git a/packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts b/packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts index b1c08c606..083910bed 100644 --- a/packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts +++ b/packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts @@ -12,7 +12,6 @@ import * as Bytes from "../../../Bytes.js" import * as Credential from "../../../Credential.js" import * as PlutusData from "../../../Data.js" import type * as DatumHash from "../../../DatumHash.js" -import type * as DatumOption from "../../../DatumOption.js" import * as NativeScripts from "../../../NativeScripts.js" import * as PolicyId from "../../../PolicyId.js" import type * as RewardAddress from "../../../RewardAddress.js" @@ -28,8 +27,7 @@ import * as HttpUtils from "./HttpUtils.js" const PAGE_SIZE = 100 const TIMEOUT = 10_000 -const bearerHeaders = (token?: string): Record | undefined => - token ? { Authorization: `Bearer ${token}` } : undefined +const bearerHeaders = (token: string): Record => ({ Authorization: `Bearer ${token}` }) const wrapError = (operation: string) => (cause: unknown) => Effect.fail( @@ -46,70 +44,59 @@ const is404 = (error: unknown): boolean => error instanceof HttpUtils.HttpResponseError && error.status === 404 // ============================================================================ -// Script / datum resolution +// UTxO resolution // ============================================================================ -// BCA's UtxoResponse carries inline_datum / reference_script_cbor directly -// when available; only fall back to a separate lookup by hash for older -// rows that predate those fields. - -const fetchScript = - (baseUrl: string, headers: Record | undefined) => - (scriptHash: string): Effect.Effect => - HttpUtils.get(`${baseUrl}/script/${scriptHash}`, BCA.Envelope(BCA.ScriptDetails), headers).pipe( - Effect.timeout(TIMEOUT), - Effect.map((response) => BCA.transformScript(response.details)) - ) - -const fetchDatumOption = - (baseUrl: string, headers: Record | undefined) => - (datumHash: string): Effect.Effect => - HttpUtils.get(`${baseUrl}/datum/${datumHash}`, BCA.Envelope(BCA.DatumDetails), headers).pipe( - Effect.timeout(TIMEOUT), - Effect.map((response) => BCA.inlineDatumFromCBORHex(response.details.bytes)) +// BCA's UtxoResponse carries the inline datum and the reference script CBOR +// (rebuilt server-side for native scripts, which db-sync stores no CBOR for), +// so no follow-up lookups are needed. + +const decodeScriptRef = (item: BCA.UtxoResponse): Effect.Effect => { + if (!item.reference_script_hash && !item.reference_script_cbor) return Effect.succeed(undefined) + const { reference_script_cbor: bytes, reference_script_type: type } = item + return Effect.try({ + try: () => (bytes && type ? BCA.transformScript({ type, bytes }) : undefined), + catch: (cause) => new Provider.ProviderError({ message: "Failed to decode reference script", cause }) + }).pipe( + // Without its reference script the UTxO would be left out of the reference + // script fee, and the node would reject the transaction. + Effect.filterOrFail( + (script): script is Script.Script => script !== undefined, + () => + new Provider.ProviderError({ + message: `No usable reference script for ${item.tx_hash}#${item.output_index}`, + cause: { reference_script_hash: item.reference_script_hash, reference_script_type: type } + }) ) + ) +} -const resolveUtxo = - (baseUrl: string, headers: Record | undefined) => - (item: BCA.UtxoResponse): Effect.Effect => { - const scriptEffect = - item.reference_script_cbor && item.reference_script_type - ? Effect.succeed(BCA.transformScript({ type: item.reference_script_type, bytes: item.reference_script_cbor })) - : item.reference_script_hash - ? fetchScript( - baseUrl, - headers - )(item.reference_script_hash).pipe(Effect.catchAll(() => Effect.succeed(undefined))) - : Effect.succeed(undefined) - - const datumEffect = item.inline_datum - ? Effect.succeed(BCA.inlineDatumFromCBORHex(item.inline_datum)) - : item.datum_hash - ? fetchDatumOption( - baseUrl, - headers - )(item.datum_hash).pipe(Effect.catchAll(() => Effect.succeed(BCA.datumHashFromHex(item.datum_hash!)))) - : Effect.succeed(undefined) - - return Effect.all([scriptEffect, datumEffect]).pipe( - Effect.map( - ([scriptRef, datumOption]) => +const resolveUtxo = (item: BCA.UtxoResponse): Effect.Effect => + decodeScriptRef(item).pipe( + Effect.flatMap((scriptRef) => + Effect.try({ + try: () => new CoreUTxO.UTxO({ transactionId: TransactionHash.fromHex(item.tx_hash), index: item.output_index, address: CoreAddress.fromBech32(item.address), assets: BCA.transformAssets(item.lovelace, item.assets), - datumOption, + datumOption: item.inline_datum + ? BCA.inlineDatumFromCBORHex(item.inline_datum) + : item.datum_hash + ? BCA.datumHashFromHex(item.datum_hash) + : undefined, scriptRef - }) - ) + }), + catch: (cause) => new Provider.ProviderError({ message: "Failed to decode UTxO", cause }) + }) ) - } + ) // ============================================================================ // Paginated UTxO fetch (GET /utxos/{address}, GET /assets/{unit}/utxos) // ============================================================================ -const fetchAllUtxoPages = (url: string, headers: Record | undefined) => +const fetchAllUtxoPages = (url: string, headers: Record) => Effect.gen(function* () { let all: Array = [] // BCA pages are 0-indexed (CIP-30 Paginate convention). @@ -131,7 +118,7 @@ const fetchAllUtxoPages = (url: string, headers: Record | undefi // Protocol parameters // ============================================================================ -export const getProtocolParameters = (baseUrl: string, token?: string) => +export const getProtocolParameters = (baseUrl: string, token: string) => HttpUtils.get(`${baseUrl}/protocol_parameters/latest`, BCA.ProtocolParametersResponse, bearerHeaders(token)).pipe( Effect.map(BCA.transformProtocolParameters), Effect.timeout(TIMEOUT), @@ -143,28 +130,28 @@ export const getProtocolParameters = (baseUrl: string, token?: string) => // ============================================================================ export const getUtxos = - (baseUrl: string, token?: string) => (addressOrCredential: CoreAddress.Address | Credential.Credential) => { + (baseUrl: string, token: string) => (addressOrCredential: CoreAddress.Address | Credential.Credential) => { const headers = bearerHeaders(token) const addressPath = getAddressPath(addressOrCredential) return fetchAllUtxoPages(`${baseUrl}/utxos/${addressPath}`, headers).pipe( - Effect.flatMap((utxos) => Effect.forEach(utxos, resolveUtxo(baseUrl, headers), { concurrency: 10 })), + Effect.flatMap((utxos) => Effect.forEach(utxos, resolveUtxo)), Effect.catchAll(wrapError("getUtxos")) ) } export const getUtxosWithUnit = - (baseUrl: string, token?: string) => + (baseUrl: string, token: string) => (addressOrCredential: CoreAddress.Address | Credential.Credential, unit: string) => { const headers = bearerHeaders(token) const addressPath = getAddressPath(addressOrCredential) return fetchAllUtxoPages(`${baseUrl}/utxos/${addressPath}`, headers).pipe( Effect.map((utxos) => utxos.filter((utxo) => utxo.assets.some((a) => `${a.policy_id}${a.asset_name}` === unit))), - Effect.flatMap((utxos) => Effect.forEach(utxos, resolveUtxo(baseUrl, headers), { concurrency: 10 })), + Effect.flatMap((utxos) => Effect.forEach(utxos, resolveUtxo)), Effect.catchAll(wrapError("getUtxosWithUnit")) ) } -export const getUtxoByUnit = (baseUrl: string, token?: string) => (unit: string) => { +export const getUtxoByUnit = (baseUrl: string, token: string) => (unit: string) => { const headers = bearerHeaders(token) return fetchAllUtxoPages(`${baseUrl}/assets/${unit}/utxos`, headers).pipe( Effect.flatMap((utxos) => { @@ -179,14 +166,14 @@ export const getUtxoByUnit = (baseUrl: string, token?: string) => (unit: string) }) ) } - return resolveUtxo(baseUrl, headers)(utxos[0]) + return resolveUtxo(utxos[0]) }), Effect.catchAll(wrapError("getUtxoByUnit")) ) } export const getUtxosByOutRef = - (baseUrl: string, token?: string) => (inputs: ReadonlyArray) => { + (baseUrl: string, token: string) => (inputs: ReadonlyArray) => { const headers = bearerHeaders(token) return Effect.forEach( @@ -195,7 +182,7 @@ export const getUtxosByOutRef = const txHash = TransactionHash.toHex(input.transactionId) return HttpUtils.get(`${baseUrl}/utxo/${txHash}/${Number(input.index)}`, BCA.UtxoResponse, headers).pipe( Effect.timeout(TIMEOUT), - Effect.flatMap(resolveUtxo(baseUrl, headers)), + Effect.flatMap(resolveUtxo), Effect.map((utxo) => Option.some(utxo)), Effect.catchIf(is404, () => Effect.succeed(Option.none())), Effect.catchAll(wrapError("getUtxosByOutRef")) @@ -209,12 +196,12 @@ export const getUtxosByOutRef = // Delegation // ============================================================================ -export const getDelegation = (baseUrl: string, token?: string) => (rewardAddress: RewardAddress.RewardAddress) => +export const getDelegation = (baseUrl: string, token: string) => (rewardAddress: RewardAddress.RewardAddress) => HttpUtils.get(`${baseUrl}/staking/${rewardAddress}`, BCA.StakingResponse, bearerHeaders(token)).pipe( Effect.timeout(TIMEOUT), Effect.map(BCA.transformDelegation), // 404 — stake address not registered/never delegated. - Effect.catchIf(is404, () => Effect.succeed({ poolId: null, rewards: 0n } as Provider.Delegation)), + Effect.catchIf(is404, () => Effect.succeed({ poolId: null, rewards: 0n })), Effect.catchAll(wrapError("getDelegation")) ) @@ -222,7 +209,7 @@ export const getDelegation = (baseUrl: string, token?: string) => (rewardAddress // Datum // ============================================================================ -export const getDatum = (baseUrl: string, token?: string) => (datumHash: DatumHash.DatumHash) => { +export const getDatum = (baseUrl: string, token: string) => (datumHash: DatumHash.DatumHash) => { const datumHashHex = Bytes.toHex(datumHash.hash) return HttpUtils.get(`${baseUrl}/datum/${datumHashHex}`, BCA.Envelope(BCA.DatumDetails), bearerHeaders(token)).pipe( Effect.timeout(TIMEOUT), @@ -241,17 +228,18 @@ export const getDatum = (baseUrl: string, token?: string) => (datumHash: DatumHa // ============================================================================ export const awaitTx = - (baseUrl: string, token?: string) => + (baseUrl: string, token: string) => (txHash: TransactionHash.TransactionHash, checkInterval = 20_000, timeout = 160_000) => { const txHashHex = TransactionHash.toHex(txHash) const headers = bearerHeaders(token) - // GET /tx/{hash} 404s until the tx is indexed, 200s once confirmed. - const checkTx = HttpUtils.get(`${baseUrl}/tx/${txHashHex}`, BCA.TransactionResponse, headers).pipe( + // GET /tx/{hash} 404s until the tx is indexed, 200s once confirmed. Any + // other failure (e.g. a 401) ends the wait at once. + const checkTx = HttpUtils.get(`${baseUrl}/tx/${txHashHex}`, BCA.TxHashResponse, headers).pipe( Effect.timeout(TIMEOUT) ) - return Effect.retry(checkTx, Schedule.spaced(`${checkInterval} millis`)).pipe( + return Effect.retry(checkTx, { schedule: Schedule.spaced(`${checkInterval} millis`), while: is404 }).pipe( Effect.timeout(timeout), Effect.as(true), Effect.catchAllCause((cause) => @@ -264,7 +252,7 @@ export const awaitTx = // submitTx / evaluateTx // ============================================================================ -export const submitTx = (baseUrl: string, token?: string) => (tx: Transaction.Transaction) => +export const submitTx = (baseUrl: string, token: string) => (tx: Transaction.Transaction) => HttpUtils.postUint8Array( `${baseUrl}/tx/submit`, Transaction.toCBORBytes(tx), @@ -310,7 +298,7 @@ const toReferenceScriptFields = ( } export const evaluateTx = - (baseUrl: string, token?: string) => (tx: Transaction.Transaction, additionalUTxOs?: Array) => { + (baseUrl: string, token: string) => (tx: Transaction.Transaction, additionalUTxOs?: Array) => { const body = { cbor: Transaction.toCBORHex(tx), ...(additionalUTxOs && additionalUTxOs.length > 0 diff --git a/packages/evolution/test/provider/BlockchainApplied.test.ts b/packages/evolution/test/provider/BlockchainApplied.test.ts new file mode 100644 index 000000000..c5be272d2 --- /dev/null +++ b/packages/evolution/test/provider/BlockchainApplied.test.ts @@ -0,0 +1,420 @@ +/** + * Offline tests for the Blockchain Applied (BCA) provider. `fetch` is stubbed with response + * bodies recorded from the BCA preprod API (cost models shortened, `/tx` trimmed to its header + * fields), so no test needs a BCA token. + */ +import { Effect } from "effect" +import { afterEach, describe, expect, it, vi } from "vitest" + +import * as Address from "../../src/Address.js" +import * as Assets from "../../src/Assets.js" +import * as Bytes from "../../src/Bytes.js" +import * as Credential from "../../src/Credential.js" +import * as Data from "../../src/Data.js" +import * as DatumHash from "../../src/DatumHash.js" +import * as InlineDatum from "../../src/InlineDatum.js" +import * as KeyHash from "../../src/KeyHash.js" +import * as NativeScripts from "../../src/NativeScripts.js" +import * as PoolKeyHash from "../../src/PoolKeyHash.js" +import * as BlockchainAppliedEffect from "../../src/sdk/provider/internal/BlockchainAppliedEffect.js" +import * as Transaction from "../../src/Transaction.js" +import * as TransactionHash from "../../src/TransactionHash.js" +import * as TransactionInput from "../../src/TransactionInput.js" +import { + PREPROD_ADDRESS_BECH32, + PREPROD_DATUM_HASH_HEX, + PREPROD_STAKE_ADDRESS_BECH32, + PREPROD_TX_HASH_HEX, + PREPROD_UNIT +} from "./fixtures/constants.js" +import { evalSample1 } from "./fixtures/evaluateTx.js" + +const BASE_URL = "https://api.blockchain-applied.com/api_preprod/v1" +const TOKEN = "test-token" + +type Route = string | { readonly status: number; readonly body: string } + +/** + * Route each request URL to a raw JSON body, so numbers reach the parser exactly as written. + * An array answers successive requests in order (the last entry repeats). + */ +const stubFetch = (routes: Record>) => { + const served: Record = {} + const fetchMock = vi.fn((url: string, _init?: RequestInit) => { + const entry = routes[url] + const route = Array.isArray(entry) ? entry[Math.min((served[url] = (served[url] ?? -1) + 1), entry.length - 1)] : entry + if (route === undefined) return Promise.resolve(new Response('{"error":"not found"}', { status: 404 })) + return Promise.resolve( + typeof route === "string" ? new Response(route) : new Response(route.body, { status: route.status }) + ) + }) + vi.stubGlobal("fetch", fetchMock) + return fetchMock +} + +const page = (url: string, n: number) => `${url}?page=${n}&pagesize=100` + +const fail = (effect: Effect.Effect) => Effect.runPromise(Effect.flip(effect)) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +// Recorded from GET /utxos/{PREPROD_ADDRESS_BECH32} +const UTXO_ADA_ONLY = `{"tx_hash":"2c8f45e2aa58c7bb25739da28786cce97a97cd346107bb372807fabde323ab96","output_index":1,"address":"${PREPROD_ADDRESS_BECH32}","lovelace":"2186901580","assets":[],"datum_hash":null,"reference_script_hash":null,"inline_datum":null,"reference_script_cbor":null,"reference_script_type":null}` +const UTXO_TOKENS = `{"tx_hash":"e0568183b00d1a7625a97ba1792a7506aafc96719c0643fbb021ae75c9d815a4","output_index":1,"address":"${PREPROD_ADDRESS_BECH32}","lovelace":"2425818","assets":[{"policy_id":"2b172b4b0141058c3a5ee855cfa0f52f85808e3d3aba47f5a44cbd5e","asset_name":"69455448","quantity":"1"},{"policy_id":"d088875616229b83868ac5596e77288d8b5c9dffc2f748d8df6332b6","asset_name":"74494e4459","quantity":"9997000000"}],"datum_hash":null,"reference_script_hash":null,"inline_datum":null,"reference_script_cbor":null,"reference_script_type":null}` +const TOKEN_UNIT = "d088875616229b83868ac5596e77288d8b5c9dffc2f748d8df6332b674494e4459" + +// Recorded from GET /utxo/{PREPROD_TX_HASH_HEX}/0: an inline datum, whose hash is set too +const INLINE_DATUM_CBOR = "d8799fd8799f1a001a8ea5ff1b0000018ea6888e58ff" +const UTXO_INLINE_DATUM = `{"tx_hash":"${PREPROD_TX_HASH_HEX}","output_index":0,"address":"addr_test1wz7uytdxstxe4nhdtl2gj9rcnlyce99tc707mz6qewxyx9qac0urr","lovelace":"2000000","assets":[{"policy_id":"ec0f50624331d0df45f40ab0e6081e5f1ff577c5c4dc767079d4fe59","asset_name":"695553443230323430343033313833343534","quantity":"1"}],"datum_hash":"${PREPROD_DATUM_HASH_HEX}","reference_script_hash":null,"inline_datum":"${INLINE_DATUM_CBOR}","reference_script_cbor":null,"reference_script_type":null}` +// The same output with only a datum hash +const UTXO_DATUM_HASH = UTXO_INLINE_DATUM.replace(`"inline_datum":"${INLINE_DATUM_CBOR}"`, '"inline_datum":null') + +// A native reference script, its CBOR rebuilt by the API from db-sync's script JSON +const NATIVE_SCRIPT = NativeScripts.makeScriptPubKey(Bytes.fromHex("e09d36c79dec9bd1b3d9e152247701cd0bb860b5ebfd1de8abb6735a")) +const NATIVE_SCRIPT_HASH = "208bdcaf2d83ae026964e23659c703a377473168a39cbdc2b0241115" +const UTXO_NATIVE_SCRIPT = UTXO_ADA_ONLY.replace( + '"reference_script_hash":null', + `"reference_script_hash":"${NATIVE_SCRIPT_HASH}"` +) + .replace('"reference_script_cbor":null', `"reference_script_cbor":"${NativeScripts.toCBORHex(NATIVE_SCRIPT)}"`) + .replace('"reference_script_type":null', '"reference_script_type":"timelock"') +// A reference script the API returned no CBOR for +const UTXO_SCRIPT_NO_CBOR = UTXO_NATIVE_SCRIPT.replace(/"reference_script_cbor":"[0-9a-f]+"/, '"reference_script_cbor":null') + +const paged = (items: ReadonlyArray, n: number, hasNext: boolean) => + `{"data":[${items.join(",")}],"page":${n},"page_size":100,"has_next":${hasNext}}` + +describe("BlockchainApplied getProtocolParameters", () => { + // Recorded from preprod epoch 321, cost models shortened + const body = `{"epoch_no":321,"min_fee_a":44,"min_fee_b":155381,"max_tx_size":16384,"max_val_size":5000,"key_deposit":"2000000","pool_deposit":"500000000","drep_deposit":"500000000","gov_action_deposit":"1000000000","price_mem":0.0577,"price_step":0.0000721,"max_tx_ex_mem":"17500000","max_tx_ex_steps":"18446744073709551615","coins_per_utxo_size":"4310","collateral_percentage":150,"max_collateral_inputs":3,"min_fee_ref_script_cost_per_byte":15,"cost_models":{"PlutusV1":[100788,420,1,1],"PlutusV2":[100788,420,1,1,1000],"PlutusV3":[100788,420,1,1,1000,173]}}` + const url = `${BASE_URL}/protocol_parameters/latest` + + it("decodes the parameters and keeps the cost model order", async () => { + stubFetch({ [url]: body }) + + const params = await Effect.runPromise(BlockchainAppliedEffect.getProtocolParameters(BASE_URL, TOKEN)) + + expect(params).toEqual({ + minFeeA: 44, + minFeeB: 155381, + maxTxSize: 16384, + maxValSize: 5000, + keyDeposit: 2000000n, + poolDeposit: 500000000n, + drepDeposit: 500000000n, + govActionDeposit: 1000000000n, + priceMem: 0.0577, + priceStep: 0.0000721, + maxTxExMem: 17500000n, + maxTxExSteps: 18446744073709551615n, + coinsPerUtxoByte: 4310n, + collateralPercentage: 150, + maxCollateralInputs: 3, + minFeeRefScriptCostPerByte: 15, + costModels: { + PlutusV1: { 0: 100788, 1: 420, 2: 1, 3: 1 }, + PlutusV2: { 0: 100788, 1: 420, 2: 1, 3: 1, 4: 1000 }, + PlutusV3: { 0: 100788, 1: 420, 2: 1, 3: 1, 4: 1000, 5: 173 } + } + }) + }) + + it("fails when coins_per_utxo_size is missing", async () => { + stubFetch({ [url]: body.replace('"coins_per_utxo_size":"4310",', "") }) + + const error = await fail(BlockchainAppliedEffect.getProtocolParameters(BASE_URL, TOKEN)) + + expect(error._tag).toBe("ProviderError") + }) + + it("fails when min_fee_ref_script_cost_per_byte is null", async () => { + stubFetch({ [url]: body.replace('"min_fee_ref_script_cost_per_byte":15', '"min_fee_ref_script_cost_per_byte":null') }) + + const error = await fail(BlockchainAppliedEffect.getProtocolParameters(BASE_URL, TOKEN)) + + expect(error._tag).toBe("ProviderError") + }) + + it("fails on cost models keyed by parameter name", async () => { + stubFetch({ [url]: body.replace('"PlutusV1":[100788,420,1,1]', '"PlutusV1":{"addInteger-cpu-arguments-intercept":100788}') }) + + const error = await fail(BlockchainAppliedEffect.getProtocolParameters(BASE_URL, TOKEN)) + + expect(error._tag).toBe("ProviderError") + }) +}) + +describe("BlockchainApplied UTxO queries", () => { + const utxosUrl = `${BASE_URL}/utxos/${PREPROD_ADDRESS_BECH32}` + const pages = { + [page(utxosUrl, 0)]: paged([UTXO_ADA_ONLY], 0, true), + [page(utxosUrl, 1)]: paged([UTXO_TOKENS], 1, false) + } + + it("getUtxos follows has_next across pages", async () => { + const fetchMock = stubFetch(pages) + + const utxos = await Effect.runPromise( + BlockchainAppliedEffect.getUtxos(BASE_URL, TOKEN)(Address.fromBech32(PREPROD_ADDRESS_BECH32)) + ) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(utxos.map((u) => TransactionHash.toHex(u.transactionId))).toEqual([ + "2c8f45e2aa58c7bb25739da28786cce97a97cd346107bb372807fabde323ab96", + "e0568183b00d1a7625a97ba1792a7506aafc96719c0643fbb021ae75c9d815a4" + ]) + expect(utxos[0].assets).toEqual(Assets.fromLovelace(2186901580n)) + expect(utxos[1].assets).toEqual( + Assets.addByHex( + Assets.addByHex(Assets.fromLovelace(2425818n), "2b172b4b0141058c3a5ee855cfa0f52f85808e3d3aba47f5a44cbd5e", "69455448", 1n), + "d088875616229b83868ac5596e77288d8b5c9dffc2f748d8df6332b6", + "74494e4459", + 9997000000n + ) + ) + }) + + it("getUtxos queries a payment credential by its bech32 form", async () => { + const credential = Credential.makeKeyHash(KeyHash.fromHex("c035d6e2a3c89bf7aa0f22b1bf5fe2d8a51f7e2f3e8b1a8db9d7e844").hash) + const credentialUrl = `${BASE_URL}/utxos/${Credential.toBech32(credential)}` + stubFetch({ [page(credentialUrl, 0)]: paged([UTXO_ADA_ONLY], 0, false) }) + + const utxos = await Effect.runPromise(BlockchainAppliedEffect.getUtxos(BASE_URL, TOKEN)(credential)) + + expect(credentialUrl).toContain("/utxos/addr_vkh1") + expect(utxos).toHaveLength(1) + }) + + it("getUtxosWithUnit keeps only UTxOs holding the unit", async () => { + stubFetch(pages) + + const utxos = await Effect.runPromise( + BlockchainAppliedEffect.getUtxosWithUnit(BASE_URL, TOKEN)(Address.fromBech32(PREPROD_ADDRESS_BECH32), TOKEN_UNIT) + ) + + expect(utxos.map((u) => u.index)).toEqual([1n]) + expect(TransactionHash.toHex(utxos[0].transactionId)).toBe( + "e0568183b00d1a7625a97ba1792a7506aafc96719c0643fbb021ae75c9d815a4" + ) + }) + + it("getUtxoByUnit returns the single holder", async () => { + stubFetch({ [page(`${BASE_URL}/assets/${PREPROD_UNIT}/utxos`, 0)]: paged([UTXO_INLINE_DATUM], 0, false) }) + + const utxo = await Effect.runPromise(BlockchainAppliedEffect.getUtxoByUnit(BASE_URL, TOKEN)(PREPROD_UNIT)) + + expect(utxo.index).toBe(0n) + }) + + it("getUtxoByUnit fails when several UTxOs hold the unit", async () => { + stubFetch({ [page(`${BASE_URL}/assets/${PREPROD_UNIT}/utxos`, 0)]: paged([UTXO_ADA_ONLY, UTXO_TOKENS], 0, false) }) + + const error = await fail(BlockchainAppliedEffect.getUtxoByUnit(BASE_URL, TOKEN)(PREPROD_UNIT)) + + expect(error._tag).toBe("ProviderError") + }) + + it("getUtxosByOutRef skips out-refs that 404", async () => { + stubFetch({ [`${BASE_URL}/utxo/${PREPROD_TX_HASH_HEX}/0`]: UTXO_INLINE_DATUM }) + const outRef = (index: bigint) => + new TransactionInput.TransactionInput({ transactionId: TransactionHash.fromHex(PREPROD_TX_HASH_HEX), index }) + + const utxos = await Effect.runPromise( + BlockchainAppliedEffect.getUtxosByOutRef(BASE_URL, TOKEN)([outRef(0n), outRef(7n)]) + ) + + expect(utxos.map((u) => u.index)).toEqual([0n]) + }) +}) + +describe("BlockchainApplied datums and reference scripts on UTxOs", () => { + const outRefUrl = `${BASE_URL}/utxo/${PREPROD_TX_HASH_HEX}/0` + const resolve = () => + BlockchainAppliedEffect.getUtxosByOutRef( + BASE_URL, + TOKEN + )([new TransactionInput.TransactionInput({ transactionId: TransactionHash.fromHex(PREPROD_TX_HASH_HEX), index: 0n })]) + + it("an inline datum becomes an InlineDatum", async () => { + stubFetch({ [outRefUrl]: UTXO_INLINE_DATUM }) + + const [utxo] = await Effect.runPromise(resolve()) + + expect(utxo.datumOption).toEqual(new InlineDatum.InlineDatum({ data: Data.fromCBORHex(INLINE_DATUM_CBOR) })) + }) + + it("a hash-only datum stays a DatumHash, without fetching the datum", async () => { + const fetchMock = stubFetch({ [outRefUrl]: UTXO_DATUM_HASH }) + + const [utxo] = await Effect.runPromise(resolve()) + + expect(utxo.datumOption).toEqual(DatumHash.fromHex(PREPROD_DATUM_HASH_HEX)) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it("decodes a native reference script", async () => { + stubFetch({ [outRefUrl]: UTXO_NATIVE_SCRIPT }) + + const [utxo] = await Effect.runPromise(resolve()) + + expect(utxo.scriptRef).toEqual(NATIVE_SCRIPT) + }) + + it("fails when a reference script comes without its CBOR", async () => { + stubFetch({ [outRefUrl]: UTXO_SCRIPT_NO_CBOR }) + + const error = await fail(resolve()) + + expect(error._tag).toBe("ProviderError") + }) +}) + +describe("BlockchainApplied getDelegation", () => { + const url = `${BASE_URL}/staking/${PREPROD_STAKE_ADDRESS_BECH32}` + + it("returns the pool and the withdrawable rewards", async () => { + // Recorded + stubFetch({ + [url]: `{"address":"${PREPROD_STAKE_ADDRESS_BECH32}","pool_id":"pool1mp96jpc2dtaruz0cazmljh03dev0969c4rq3wr6hnc4rjdxn8aw","active_epoch":30,"delegation_tx":"a7412e82b9d556162230faa0090535b499160c45766ea429e53e829ed8fe0c04","total_rewards":"147911340884","total_withdrawn":"94929392428","withdrawable_rewards":"52981948456"}` + }) + + const delegation = await Effect.runPromise( + BlockchainAppliedEffect.getDelegation(BASE_URL, TOKEN)(PREPROD_STAKE_ADDRESS_BECH32) + ) + + expect(delegation).toEqual({ + poolId: PoolKeyHash.fromBech32("pool1mp96jpc2dtaruz0cazmljh03dev0969c4rq3wr6hnc4rjdxn8aw"), + rewards: 52981948456n + }) + }) + + it("returns no pool and no rewards for an unknown stake address", async () => { + stubFetch({}) + + const delegation = await Effect.runPromise( + BlockchainAppliedEffect.getDelegation(BASE_URL, TOKEN)(PREPROD_STAKE_ADDRESS_BECH32) + ) + + expect(delegation).toEqual({ poolId: null, rewards: 0n }) + }) +}) + +describe("BlockchainApplied getDatum", () => { + it("decodes the datum bytes", async () => { + // Recorded + stubFetch({ + [`${BASE_URL}/datum/${PREPROD_DATUM_HASH_HEX}`]: `{"summary":"datum","details":{"hash":"${PREPROD_DATUM_HASH_HEX}","tx_hash":"${PREPROD_TX_HASH_HEX}","json":{"constructor":0,"fields":[{"constructor":0,"fields":[{"int":1740453}]},{"int":1712190951000}]},"bytes":"${INLINE_DATUM_CBOR}"}}` + }) + + const datum = await Effect.runPromise( + BlockchainAppliedEffect.getDatum(BASE_URL, TOKEN)(DatumHash.fromHex(PREPROD_DATUM_HASH_HEX)) + ) + + expect(datum).toEqual(Data.fromCBORHex(INLINE_DATUM_CBOR)) + }) +}) + +describe("BlockchainApplied awaitTx", () => { + const url = `${BASE_URL}/tx/${PREPROD_TX_HASH_HEX}` + // Recorded, trimmed to the header fields + const confirmed = `{"hash":"${PREPROD_TX_HASH_HEX}","block_index":1,"epoch_no":129,"fee":"190713","inputs":[],"outputs":[]}` + const notFound = { status: 404, body: '{"error":"Transaction not found"}' } + + it("polls while the transaction 404s, then returns", async () => { + const fetchMock = stubFetch({ [url]: [notFound, notFound, confirmed] }) + + const result = await Effect.runPromise( + BlockchainAppliedEffect.awaitTx(BASE_URL, TOKEN)(TransactionHash.fromHex(PREPROD_TX_HASH_HEX), 10, 5_000) + ) + + expect(result).toBe(true) + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + + it("fails at once on a 401 instead of polling until the timeout", async () => { + const fetchMock = stubFetch({ [url]: { status: 401, body: '{"error":"Unauthorized"}' } }) + + const error = await fail( + BlockchainAppliedEffect.awaitTx(BASE_URL, TOKEN)(TransactionHash.fromHex(PREPROD_TX_HASH_HEX), 10, 5_000) + ) + + expect(error._tag).toBe("ProviderError") + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) + +describe("BlockchainApplied submitTx and evaluateTx", () => { + const tx = Transaction.fromCBORHex(evalSample1.transaction) + + it("submitTx posts the CBOR bytes and returns the hash", async () => { + const txHash = "7e6378266748b08a44c24682af0e46384e7a58b1a7b71b44e84a24f35444ffba" + const fetchMock = stubFetch({ [`${BASE_URL}/tx/submit`]: `{"tx_hash":"${txHash}"}` }) + + const result = await Effect.runPromise(BlockchainAppliedEffect.submitTx(BASE_URL, TOKEN)(tx)) + + expect(TransactionHash.toHex(result)).toBe(txHash) + const init = fetchMock.mock.calls[0][1]! + expect(new Headers(init.headers).get("Content-Type")).toBe("application/cbor") + expect(init.body).toEqual(Transaction.toCBORBytes(tx)) + }) + + it("evaluateTx sends additional_utxos and decodes the redeemers", async () => { + const fetchMock = stubFetch({ + [`${BASE_URL}/tx/evaluate`]: + '[{"redeemer_tag":"spend","redeemer_index":0,"ex_units":{"mem":"1700","steps":"9007199254740993"}},{"redeemer_tag":"reward","redeemer_index":1,"ex_units":{"mem":"476468","steps":"133296322"}},{"redeemer_tag":"cert","redeemer_index":2,"ex_units":{"mem":"1","steps":"2"}}]' + }) + + const result = await Effect.runPromise(BlockchainAppliedEffect.evaluateTx(BASE_URL, TOKEN)(tx, evalSample1.utxos)) + + expect(result.map((r) => [r.redeemer_tag, r.redeemer_index, r.ex_units.mem, r.ex_units.steps])).toEqual([ + ["spend", 0, 1700n, 9007199254740993n], + ["reward", 1, 476468n, 133296322n], + ["cert", 2, 1n, 2n] + ]) + const body = JSON.parse(fetchMock.mock.calls[0][1]!.body as string) + expect(body.cbor).toBe(Transaction.toCBORHex(tx)) + expect(body.additional_utxos).toHaveLength(evalSample1.utxos.length) + expect(body.additional_utxos[0]).toMatchObject({ + tx_hash: TransactionHash.toHex(evalSample1.utxos[0].transactionId), + output_index: Number(evalSample1.utxos[0].index), + address: Address.toBech32(evalSample1.utxos[0].address), + lovelace: evalSample1.utxos[0].assets.lovelace.toString() + }) + }) + + it("evaluateTx fails on an unknown redeemer tag", async () => { + stubFetch({ + [`${BASE_URL}/tx/evaluate`]: '[{"redeemer_tag":"withdraw","redeemer_index":0,"ex_units":{"mem":"1","steps":"2"}}]' + }) + + const error = await fail(BlockchainAppliedEffect.evaluateTx(BASE_URL, TOKEN)(tx)) + + expect(error._tag).toBe("ProviderError") + }) +}) + +describe("BlockchainApplied authentication", () => { + it("sends the bearer token on every request", async () => { + const fetchMock = stubFetch({ + [`${BASE_URL}/utxo/${PREPROD_TX_HASH_HEX}/0`]: UTXO_INLINE_DATUM, + [`${BASE_URL}/tx/evaluate`]: "[]" + }) + + await Effect.runPromise( + BlockchainAppliedEffect.getUtxosByOutRef( + BASE_URL, + TOKEN + )([new TransactionInput.TransactionInput({ transactionId: TransactionHash.fromHex(PREPROD_TX_HASH_HEX), index: 0n })]) + ) + await Effect.runPromise( + BlockchainAppliedEffect.evaluateTx(BASE_URL, TOKEN)(Transaction.fromCBORHex(evalSample1.transaction)) + ) + + expect(fetchMock).toHaveBeenCalledTimes(2) + for (const [, init] of fetchMock.mock.calls) { + expect(new Headers(init!.headers).get("Authorization")).toBe(`Bearer ${TOKEN}`) + } + }) +}) diff --git a/packages/evolution/test/provider/conformance.ts b/packages/evolution/test/provider/conformance.ts index f4a69f484..0dd4dd34c 100644 --- a/packages/evolution/test/provider/conformance.ts +++ b/packages/evolution/test/provider/conformance.ts @@ -23,7 +23,7 @@ import { preprodScriptAddress, preprodStakeAddress, preprodTxHash, - previewTxHash + previewTxHash, } from "./fixtures/constants.js" import { evalSample1, evalSample2, evalSample3, evalSample4 } from "./fixtures/evaluateTx.js" @@ -148,7 +148,9 @@ export function registerConformanceTests(factory: () => Provider, options?: Conf }) it("awaitTx rejects for preview-only tx on preprod", { timeout: 10_000 }, async () => { - await expect(provider.awaitTx(previewTxHash(), 1000, 5000)).rejects.toThrow("awaitTx failed") + await expect(provider.awaitTx(previewTxHash(), 1000, 5000)).rejects.toThrow( + "awaitTx failed" + ) }) // submitTx and evaluateTx require a valid signed CBOR tx — skipped until we have tx fixtures diff --git a/packages/evolution/test/provider/providers.test.ts b/packages/evolution/test/provider/providers.test.ts index 3c64dd16e..e67acb233 100644 --- a/packages/evolution/test/provider/providers.test.ts +++ b/packages/evolution/test/provider/providers.test.ts @@ -66,9 +66,7 @@ describe.skipIf(!process.env.KOIOS_PREVIEW_ENABLED)("Koios (preview)", () => { // ── Blockfrost ──────────────────────────────────────────────────────────────── describe.skipIf(!isConfigured(BLOCKFROST_KEY, "preprodXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"))("Blockfrost", () => { - registerConformanceTests(() => new BlockfrostProvider(BLOCKFROST_URL, BLOCKFROST_KEY), { - supportsCredentialQueries: true - }) + registerConformanceTests(() => new BlockfrostProvider(BLOCKFROST_URL, BLOCKFROST_KEY), { supportsCredentialQueries: true }) }) // ── Maestro ─────────────────────────────────────────────────────────────────── @@ -78,12 +76,13 @@ describe.skipIf(!isConfigured(MAESTRO_KEY, "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"))( // ── Blockchain Applied (BCA) ────────────────────────────────────────────────── describe.skipIf(!isConfigured(BCA_KEY, "bca_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"))("BlockchainApplied", () => { - registerConformanceTests(() => new BlockchainAppliedProvider(BCA_URL, BCA_KEY)) + registerConformanceTests(() => new BlockchainAppliedProvider(BCA_URL, BCA_KEY!), { supportsCredentialQueries: true }) }) // ── Kupmios (self-hosted or Demeter) ────────────────────────────────────────── describe.skipIf( - !isConfigured(KUPO_URL, "https://your-kupo-endpoint") || !isConfigured(OGMIOS_URL, "https://your-ogmios-endpoint") + !isConfigured(KUPO_URL, "https://your-kupo-endpoint") || + !isConfigured(OGMIOS_URL, "https://your-ogmios-endpoint") )("Kupmios", () => { registerConformanceTests( () => From 448275b6a5a5b67ecb109d3d0ee948fae473eb85 Mon Sep 17 00:00:00 2001 From: Alexander Diemand Date: Thu, 12 Nov 2026 14:30:11 +0100 Subject: [PATCH 4/4] fix(provider): keep fetched datum bytes in BlockchainApplied getDatum Signed-off-by: Alexander Diemand --- .../src/sdk/provider/internal/BlockchainAppliedEffect.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts b/packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts index 083910bed..341d3b6c5 100644 --- a/packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts +++ b/packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts @@ -13,6 +13,7 @@ import * as Credential from "../../../Credential.js" import * as PlutusData from "../../../Data.js" import type * as DatumHash from "../../../DatumHash.js" import * as NativeScripts from "../../../NativeScripts.js" +import * as OriginalBytes from "../../../OriginalBytes.js" import * as PolicyId from "../../../PolicyId.js" import type * as RewardAddress from "../../../RewardAddress.js" import type * as Script from "../../../Script.js" @@ -215,7 +216,12 @@ export const getDatum = (baseUrl: string, token: string) => (datumHash: DatumHas Effect.timeout(TIMEOUT), Effect.flatMap((response) => Effect.try({ - try: () => Schema.decodeSync(PlutusData.FromCBORHex())(response.details.bytes), + try: () => { + // Keep the fetched bytes: a witness set writes the datum with them + const bytes = Bytes.fromHex(response.details.bytes) + const data = PlutusData.fromCBORBytes(bytes) + return typeof data === "bigint" ? data : OriginalBytes.record(data, bytes) + }, catch: (error) => new Provider.ProviderError({ message: "Failed to parse datum CBOR", cause: error }) }) ),