diff --git a/.changeset/blockchain-applied-provider.md b/.changeset/blockchain-applied-provider.md new file mode 100644 index 00000000..e6a36ad5 --- /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 f9c35650..52289af9 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 c810abd8..d8ae95ba 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 4db41aab..25bf6763 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 371c111d..583ea297 100644 --- a/examples/with-vite-react/README.md +++ b/examples/with-vite-react/README.md @@ -126,11 +126,22 @@ const txHash = await sdk .then(tx => tx.submit()); ``` +Or with the Blockchain Applied (BCA) provider: + +```typescript +const sdk = client(preprod) + .withBlockchainApplied({ + baseUrl: "https://api.blockchain-applied.com/api_preprod/v1", + token: "your_bca_token" + }) + .withCip30(walletApi); +``` + ### Key Concepts - **Client Assembly**: Start with `client(chain)` and add capabilities with `.withX(...)` - **Wallet Capability**: Connect a CIP-30 wallet with `.withCip30(walletApi)` -- **Provider Capability**: Add Blockfrost, Maestro, Kupmios, or Koios with `.withBlockfrost(...)` and the related methods +- **Provider Capability**: Add Blockfrost, Maestro, Kupmios, Koios, or Blockchain Applied with `.withBlockfrost(...)` and the related methods - **Transaction Building**: Chain operations like `payToAddress()`, `collectFrom()`, etc. - **Signing & Submission**: Build → Sign → Submit pipeline diff --git a/packages/evolution/src/sdk/client/Client.ts b/packages/evolution/src/sdk/client/Client.ts index d986405c..a7fa9421 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 378a6c0e..7dbaf31a 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 2cabf697..8b9097c7 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 00000000..76f4cc76 --- /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 00000000..d1703913 --- /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 00000000..a94d6964 --- /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 c37ab214..1e1ed935 100644 --- a/packages/evolution/test/provider/conformance.ts +++ b/packages/evolution/test/provider/conformance.ts @@ -17,7 +17,7 @@ import { preprodScriptAddress, preprodStakeAddress, preprodTxHash, - previewTxHash, + previewTxHash } from "./fixtures/constants.js" import { evalSample1, evalSample2, evalSample3, evalSample4 } from "./fixtures/evaluateTx.js" @@ -126,9 +126,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 311a8a32..424dcfc7 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( () =>