diff --git a/.changeset/blockchain-applied-provider.md b/.changeset/blockchain-applied-provider.md new file mode 100644 index 000000000..d5a2c3bbc --- /dev/null +++ b/.changeset/blockchain-applied-provider.md @@ -0,0 +1,5 @@ +--- +"@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/.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/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 4db41aabb..905a9b13a 100644 --- a/docs/content/docs/advanced/custom-providers.mdx +++ b/docs/content/docs/advanced/custom-providers.mdx @@ -6,12 +6,12 @@ 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 +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 | +| 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 @@ -57,6 +58,13 @@ const kupmiosClient = Client.make(preprod) 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/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/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..850538dd9 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..46cae6950 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 } } @@ -155,6 +157,7 @@ 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)), + 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)), 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..fb7f97c98 --- /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. + * Every endpoint requires a Bearer token. + * + * @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..500477c06 --- /dev/null +++ b/packages/evolution/src/sdk/provider/internal/BlockchainApplied.ts @@ -0,0 +1,257 @@ +/** + * @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. + * + * 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" + +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) 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.BigInt, + page_size: Schema.BigInt, + total_count: Schema.optional(Schema.BigInt), + total_pages: Schema.optional(Schema.BigInt), + 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.BigInt +}) +export interface UtxoAsset extends Schema.Schema.Type {} + +export const UtxoResponse = Schema.Struct({ + tx_hash: Schema.String, + output_index: Schema.BigInt, + address: 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)), + 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}) — awaitTx only needs to know the tx is indexed. +// ============================================================================ + +export const TxHashResponse = Schema.Struct({ + hash: Schema.String +}) + +// ============================================================================ +// 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 {} + +// ============================================================================ +// 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.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 {} + +// ============================================================================ +// Protocol parameters (GET /protocol_parameters/latest) +// ============================================================================ + +export const ProtocolParametersResponse = Schema.Struct({ + epoch_no: Schema.BigInt, + min_fee_a: Schema.BigInt, + min_fee_b: Schema.BigInt, + max_tx_size: Schema.BigInt, + 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, + 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 {} + +// ============================================================================ +// Tx submit / evaluate +// ============================================================================ + +export const SubmitResponse = Schema.Struct({ + tx_hash: Schema.String +}) + +export const ExUnits = Schema.Struct({ + mem: Schema.BigInt, + steps: Schema.BigInt +}) + +export const EvalRedeemerResponse = Schema.Struct({ + redeemer_tag: Redeemer.RedeemerTag, + 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 +// ============================================================================ + +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), + 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, + 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, + collateralPercentage: Number(response.collateral_percentage), + maxCollateralInputs: Number(response.max_collateral_inputs), + minFeeRefScriptCostPerByte: response.min_fee_ref_script_cost_per_byte, + costModels: { + PlutusV1: toCostModel(response.cost_models.PlutusV1), + PlutusV2: toCostModel(response.cost_models.PlutusV2), + PlutusV3: toCostModel(response.cost_models.PlutusV3) + } +}) + +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, 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: response.withdrawable_rewards +}) + +export const transformEvalRedeemer = (redeemer: Schema.Schema.Type): EvalRedeemer => ({ + redeemer_tag: redeemer.redeemer_tag, + redeemer_index: Number(redeemer.redeemer_index), + ex_units: new Redeemer.ExUnits({ + mem: redeemer.ex_units.mem, + steps: 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..341d3b6c5 --- /dev/null +++ b/packages/evolution/src/sdk/provider/internal/BlockchainAppliedEffect.ts @@ -0,0 +1,325 @@ +/** + * @fileoverview Effect-based Blockchain Applied (BCA) provider functions. + * Internal module implementing all provider operations using the Effect pattern. + */ + +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 * 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" +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 => ({ Authorization: `Bearer ${token}` }) + +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 HttpUtils.HttpResponseError && error.status === 404 + +// ============================================================================ +// UTxO resolution +// ============================================================================ +// 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 = (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: 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) => + 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)), + 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)), + 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(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), + 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 })), + 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: () => { + // 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 }) + }) + ), + 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. 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: Schedule.spaced(`${checkInterval} millis`), while: is404 }).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.AdditionalUtxo => ({ + 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/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/providers.test.ts b/packages/evolution/test/provider/providers.test.ts index d7f7c70ea..e67acb233 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 @@ -71,6 +74,11 @@ 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!), { supportsCredentialQueries: true }) +}) + // ── Kupmios (self-hosted or Demeter) ────────────────────────────────────────── describe.skipIf( !isConfigured(KUPO_URL, "https://your-kupo-endpoint") ||