From 16bac66a28a75caa70c98c3afcfcf8ebce778d6f Mon Sep 17 00:00:00 2001 From: Jingles Date: Mon, 6 Jul 2026 19:36:30 +0800 Subject: [PATCH] feat(bitcoin): Maestro data provider and first-class coin selection Milestone: standard headless wallet on Bitcoin. - MaestroProvider implements IBitcoinProvider against Maestro's Esplora-compatible Bitcoin API using platform fetch (no new deps): address info/UTXOs/txs, per-tx unspent outputs via outspends, tx status, sat/vB fee estimates with closest-target selection, and raw-tx broadcast. - Extract largest-first coin selection from BitcoinHeadlessWallet into src/bitcoin/utils/coin-selection.ts and export it; behavior unchanged (BIP-141 vbyte model, 546-sat dust handling). - Unit tests for provider (mocked HTTP) and coin selection. - README Bitcoin section and bitcoin-milestone.md evidence doc. Co-Authored-By: Claude Fable 5 --- README.md | 134 ++++++-- bitcoin-milestone.md | 41 +++ src/bitcoin/providers/common.ts | 62 ++++ src/bitcoin/providers/maestro.ts | 262 ++++++++++++++++ src/bitcoin/utils/coin-selection.ts | 90 ++++++ .../wallet/mesh/bitcoin-headless-wallet.ts | 61 +--- src/index.ts | 4 + test/bitcoin/coin-selection.test.ts | 137 +++++++++ test/bitcoin/providers/maestro.test.ts | 290 ++++++++++++++++++ 9 files changed, 992 insertions(+), 89 deletions(-) create mode 100644 bitcoin-milestone.md create mode 100644 src/bitcoin/providers/common.ts create mode 100644 src/bitcoin/providers/maestro.ts create mode 100644 src/bitcoin/utils/coin-selection.ts create mode 100644 test/bitcoin/coin-selection.test.ts create mode 100644 test/bitcoin/providers/maestro.test.ts diff --git a/README.md b/README.md index a290f9e..4929abd 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ npm install @meshsdk/wallet ``` > **Migrating from v1 (`MeshWallet` or `BrowserWallet`)?** This version has breaking changes. See: +> > - [`mesh-wallet-migration.md`](./mesh-wallet-migration.md) — for `MeshWallet` to `MeshCardanoHeadlessWallet` > - [`browser-wallet-migration.md`](./browser-wallet-migration.md) — for `BrowserWallet` to `MeshCardanoBrowserWallet` @@ -18,6 +19,7 @@ npm install @meshsdk/wallet - [Exported Classes](#exported-classes) - [Headless Wallet (Server-Side)](#headless-wallet-server-side) - [Browser Wallet (Client-Side)](#browser-wallet-client-side) +- [Bitcoin Headless Wallet](#bitcoin-headless-wallet) - [Low-Level Components](#low-level-components) - [CIP-30 Compatibility](#cip-30-compatibility) - [CardanoHeadlessWallet vs MeshCardanoHeadlessWallet](#cardanoheadlesswallet-vs-meshcardanoheadlesswallet) @@ -38,16 +40,16 @@ This package uses a two-tier class hierarchy for both headless and browser walle ## Exported Classes -| Class | Purpose | Use When | -|-------|---------|----------| -| `MeshCardanoHeadlessWallet` | Full-featured headless wallet with convenience methods | Server-side signing, backend transaction building, testing | -| `CardanoHeadlessWallet` | CIP-30 strict headless wallet (raw hex/CBOR returns) | You need raw CIP-30 output without conversion | -| `MeshCardanoBrowserWallet` | Full-featured browser wallet wrapper with convenience methods | dApp frontend integration with browser wallets (Eternl, Nami, etc.) | -| `CardanoBrowserWallet` | CIP-30 strict browser wallet wrapper (raw hex/CBOR returns) | You need raw CIP-30 passthrough from browser wallets | -| `InMemoryBip32` | BIP32 key derivation from mnemonic (keys stored in memory) | Deriving payment/stake/DRep keys from a mnemonic | -| `BaseSigner` | Ed25519 signer from raw private keys | Signing with raw private keys (normal or extended) | -| `CardanoAddress` | Cardano address construction and utilities | Building addresses from credentials | -| `ICardanoWallet` | Interface definition for Cardano wallets | Type-checking and implementing custom wallets | +| Class | Purpose | Use When | +| --------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------- | +| `MeshCardanoHeadlessWallet` | Full-featured headless wallet with convenience methods | Server-side signing, backend transaction building, testing | +| `CardanoHeadlessWallet` | CIP-30 strict headless wallet (raw hex/CBOR returns) | You need raw CIP-30 output without conversion | +| `MeshCardanoBrowserWallet` | Full-featured browser wallet wrapper with convenience methods | dApp frontend integration with browser wallets (Eternl, Nami, etc.) | +| `CardanoBrowserWallet` | CIP-30 strict browser wallet wrapper (raw hex/CBOR returns) | You need raw CIP-30 passthrough from browser wallets | +| `InMemoryBip32` | BIP32 key derivation from mnemonic (keys stored in memory) | Deriving payment/stake/DRep keys from a mnemonic | +| `BaseSigner` | Ed25519 signer from raw private keys | Signing with raw private keys (normal or extended) | +| `CardanoAddress` | Cardano address construction and utilities | Building addresses from credentials | +| `ICardanoWallet` | Interface definition for Cardano wallets | Type-checking and implementing custom wallets | --- @@ -56,7 +58,7 @@ This package uses a two-tier class hierarchy for both headless and browser walle ### Create from Mnemonic ```typescript -import { MeshCardanoHeadlessWallet, AddressType } from "@meshsdk/wallet"; +import { AddressType, MeshCardanoHeadlessWallet } from "@meshsdk/wallet"; const wallet = await MeshCardanoHeadlessWallet.fromMnemonic({ mnemonic: "globe cupboard camera ...".split(" "), @@ -71,10 +73,14 @@ The `fetcher` is needed for signing transactions — the wallet uses it to look ### Create from Raw Private Key ```typescript -import { MeshCardanoHeadlessWallet, AddressType, BaseSigner } from "@meshsdk/wallet"; +import { + AddressType, + BaseSigner, + MeshCardanoHeadlessWallet, +} from "@meshsdk/wallet"; const paymentSigner = BaseSigner.fromNormalKeyHex( - "d4ffb1e83d44b66849b4f16183cbf2ba1358c491cfeb39f0b66b5f811a88f182" + "d4ffb1e83d44b66849b4f16183cbf2ba1358c491cfeb39f0b66b5f811a88f182", ); const wallet = await MeshCardanoHeadlessWallet.fromCredentialSources({ @@ -106,7 +112,7 @@ import { InMemoryBip32 } from "@meshsdk/wallet"; const HARDENED_OFFSET = 0x80000000; const bip32 = await InMemoryBip32.fromMnemonic( - "globe cupboard camera ...".split(" ") + "globe cupboard camera ...".split(" "), ); const paymentSigner = await bip32.getSigner([ @@ -154,11 +160,11 @@ const wallets = MeshCardanoBrowserWallet.getInstalledWallets(); ### Common Operations ```typescript -const balance = await wallet.getBalanceMesh(); // Asset[] -const address = await wallet.getChangeAddressBech32(); // bech32 string -const utxos = await wallet.getUtxosMesh(); // UTxO[] -const collateral = await wallet.getCollateralMesh(); // UTxO[] -const networkId = await wallet.getNetworkId(); // number +const balance = await wallet.getBalanceMesh(); // Asset[] +const address = await wallet.getChangeAddressBech32(); // bech32 string +const utxos = await wallet.getUtxosMesh(); // UTxO[] +const collateral = await wallet.getCollateralMesh(); // UTxO[] +const networkId = await wallet.getNetworkId(); // number const rewards = await wallet.getRewardAddressesBech32(); // string[] // Sign and get the full transaction back (ready to submit) @@ -170,6 +176,76 @@ const signature = await wallet.signData(addressBech32, hexPayload); --- +## Bitcoin Headless Wallet + +`BitcoinHeadlessWallet` is the Bitcoin counterpart of the Cardano headless wallet: a server-side / Node.js wallet driven by a pluggable `IBitcoinProvider` data provider — the same fetcher/submitter pattern used on the Cardano side. + +### Create from Mnemonic with the Maestro provider + +```typescript +import { BitcoinHeadlessWallet, MaestroProvider } from "@meshsdk/wallet"; + +const provider = new MaestroProvider({ + network: "testnet", // or "mainnet" + apiKey: "", +}); + +const wallet = await BitcoinHeadlessWallet.fromMnemonic({ + network: "Testnet4", // or "Mainnet" + mnemonic: "muscle urban donkey ...".split(" "), + provider, +}); +``` + +`MaestroProvider` implements `IBitcoinProvider` against the [Maestro Bitcoin API](https://docs.gomaestro.org/bitcoin)'s Esplora-compatible endpoints. Any other `IBitcoinProvider` implementation can be swapped in — the wallet code doesn't change. + +### Query UTXOs + +```typescript +// All UTXOs across the payment (P2WPKH) and ordinals (P2TR) addresses, +// each annotated with the address and purpose it belongs to. +const utxos = await wallet.fetchUTXOs(); + +// Or query the provider directly for any address. +const addressUtxos = await provider.fetchAddressUTxOs("tb1q..."); +``` + +### Coin selection + +`signTransfer` selects inputs automatically, but the algorithm is also exported for standalone use — largest-first selection with BIP-141 vbyte-accurate fee estimation and automatic sub-dust change handling: + +```typescript +import { selectUtxosLargestFirst } from "@meshsdk/wallet"; + +const { selectedUtxos, change } = selectUtxosLargestFirst( + utxos, // from fetchAddressUTxOs + 50_000, // target amount (sats) + 2, // fee rate (sats/vB) + 1, // number of recipients +); +``` + +### Send a transfer + +```typescript +// Queries UTXOs, selects coins, builds + signs a PSBT (RBF-enabled), +// broadcasts through the provider, and returns the txid. +const txid = await wallet.signTransfer([ + { address: "tb1q...", amount: 10_000 }, +]); +``` + +### Other operations + +```typescript +const balance = await wallet.getBalance(); // { confirmed, unconfirmed, total } in sats +const history = await wallet.getTransactionHistory(); // newest first +const signature = await wallet.signMessage("tb1q...", "hello"); // BIP-137 ECDSA +const psbtBase64 = await wallet.signPsbt({ psbt, signInputs }); // P2WPKH + P2TR inputs +``` + +--- + ## Low-Level Components ### InMemoryBip32 @@ -203,12 +279,12 @@ Both `MeshCardanoHeadlessWallet` and `MeshCardanoBrowserWallet` provide CIP-30 c `MeshCardanoHeadlessWallet` extends it with convenience methods: -| Need | Base method (hex/CBOR) | Mesh method (parsed) | -|------|----------------------|---------------------| -| Balance | `getBalance()` → CBOR hex | `getBalanceMesh()` → `Asset[]` | -| Address | `getChangeAddress()` → hex | `getChangeAddressBech32()` → bech32 | -| UTxOs | `getUtxos()` → CBOR hex[] | `getUtxosMesh()` → `UTxO[]` | -| Sign tx | `signTx()` → witness set | `signTxReturnFullTx()` → full signed tx | +| Need | Base method (hex/CBOR) | Mesh method (parsed) | +| ------- | -------------------------- | --------------------------------------- | +| Balance | `getBalance()` → CBOR hex | `getBalanceMesh()` → `Asset[]` | +| Address | `getChangeAddress()` → hex | `getChangeAddressBech32()` → bech32 | +| UTxOs | `getUtxos()` → CBOR hex[] | `getUtxosMesh()` → `UTxO[]` | +| Sign tx | `signTx()` → witness set | `signTxReturnFullTx()` → full signed tx | The same pattern applies to `CardanoBrowserWallet` vs `MeshCardanoBrowserWallet`. @@ -220,9 +296,9 @@ This package (`@meshsdk/wallet` v2) has breaking changes from the previous `Mesh **Do not attempt to upgrade without reading the migration guides.** Key breaking changes include renamed classes, swapped method parameters, changed return types, and removed methods. Many changes compile without errors but fail silently at runtime. -| Migrating from | Migrating to | Guide | -|----------------|-------------|-------| -| `MeshWallet` (from `@meshsdk/wallet` or `@meshsdk/core`) | `MeshCardanoHeadlessWallet` | [`mesh-wallet-migration.md`](./mesh-wallet-migration.md) | -| `BrowserWallet` (from `@meshsdk/wallet` or `@meshsdk/core`) | `MeshCardanoBrowserWallet` | [`browser-wallet-migration.md`](./browser-wallet-migration.md) | +| Migrating from | Migrating to | Guide | +| ----------------------------------------------------------- | --------------------------- | -------------------------------------------------------------- | +| `MeshWallet` (from `@meshsdk/wallet` or `@meshsdk/core`) | `MeshCardanoHeadlessWallet` | [`mesh-wallet-migration.md`](./mesh-wallet-migration.md) | +| `BrowserWallet` (from `@meshsdk/wallet` or `@meshsdk/core`) | `MeshCardanoBrowserWallet` | [`browser-wallet-migration.md`](./browser-wallet-migration.md) | The migration guides are written for both human developers and LLM agents — they contain deterministic SEARCH/REPLACE patterns that can be applied file-by-file. diff --git a/bitcoin-milestone.md b/bitcoin-milestone.md new file mode 100644 index 0000000..75283f5 --- /dev/null +++ b/bitcoin-milestone.md @@ -0,0 +1,41 @@ +# Milestone Evidence — Standard Headless Wallet on Bitcoin + +This document maps the milestone acceptance criteria to the code in this repository. + +**Milestone output A:** Develop and deploy a standard headless wallet on Bitcoin. + +The headless wallet is `BitcoinHeadlessWallet` ([`src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts`](./src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts)), a server-side / Node.js wallet implementing the [`IBitcoinWallet`](./src/bitcoin/interfaces/bitcoin-wallet.ts) interface, with BIP-39/BIP-32 key management, BIP-84 (P2WPKH) and BIP-86 (P2TR) address derivation, and a pluggable [`IBitcoinProvider`](./src/bitcoin/interfaces/bitcoin-provider.ts) data-provider interface mirroring the Mesh Cardano fetcher/submitter pattern. + +## A1 — UTXO querying based on Maestro as the data provider + +| What | Where | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| Maestro provider (implements `IBitcoinProvider`) | [`src/bitcoin/providers/maestro.ts`](./src/bitcoin/providers/maestro.ts) | +| Shared fetch/error plumbing | [`src/bitcoin/providers/common.ts`](./src/bitcoin/providers/common.ts) | +| Wallet-level UTXO querying (`fetchUTXOs`) | [`src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts`](./src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts) | +| Tests (mocked HTTP, 20 cases) | [`test/bitcoin/providers/maestro.test.ts`](./test/bitcoin/providers/maestro.test.ts) | + +`MaestroProvider` covers address info, address UTXOs, per-transaction unspent outputs, paginated transaction history, transaction status, fee estimates (sat/vB with closest-target selection and a testnet fallback), and raw-transaction broadcast — all against Maestro's Esplora-compatible Bitcoin API using the platform `fetch` (no HTTP client dependency). + +## A2 — Coin selection algorithm on queried Bitcoin UTXOs + +| What | Where | +| ------------------------------------------------- | ------------------------------------------------------------------------------ | +| Coin selection module (`selectUtxosLargestFirst`) | [`src/bitcoin/utils/coin-selection.ts`](./src/bitcoin/utils/coin-selection.ts) | +| Tests (fee model, dust handling, 12 cases) | [`test/bitcoin/coin-selection.test.ts`](./test/bitcoin/coin-selection.test.ts) | + +Largest-first selection with BIP-141/BIP-144 vbyte-accurate fee estimation (11 vB overhead, 68 vB per P2WPKH input, 31 vB per output). Change below the 546-sat P2WPKH dust threshold is dropped and absorbed as miner fee; insufficient funds throw a descriptive error. + +## A3 — Send transfer transaction + +| What | Where | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `signTransfer` (query → select → build → sign → broadcast) | [`src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts`](./src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts) | +| PSBT signing (P2WPKH ECDSA + P2TR Schnorr) | same file — `signPsbt` | +| Tests (broadcast, fees, dust, RBF) | [`test/bitcoin/bitcoin-headless-wallet.test.ts`](./test/bitcoin/bitcoin-headless-wallet.test.ts) | + +`signTransfer` fetches UTXOs from the provider, fetches a fee estimate (falling back to 2 sat/vB), runs coin selection, builds a BIP-125 RBF-enabled PSBT, signs, finalizes, and broadcasts through the provider, returning the txid. + +## Usage + +See the [Bitcoin Headless Wallet](./README.md#bitcoin-headless-wallet) section of the README for end-to-end examples. diff --git a/src/bitcoin/providers/common.ts b/src/bitcoin/providers/common.ts new file mode 100644 index 0000000..e46303c --- /dev/null +++ b/src/bitcoin/providers/common.ts @@ -0,0 +1,62 @@ +/** + * Shared HTTP plumbing for Bitcoin data providers. + * + * Providers use the platform `fetch` (Node 18+ / browsers) so the package does + * not carry an HTTP client dependency. All non-2xx responses are normalized + * into a `ProviderHttpError` carrying the response details. + */ + +export type HttpErrorDetails = { + status?: number; + statusText?: string; + body?: unknown; + url?: string; +}; + +/** + * Error thrown by provider HTTP helpers. Carries the response details so + * callers can branch on `status` when needed. + */ +export class ProviderHttpError extends Error { + readonly details: HttpErrorDetails; + + constructor(details: HttpErrorDetails) { + super(JSON.stringify(details)); + this.name = "ProviderHttpError"; + this.details = details; + } +} + +/** + * Perform a request against `baseUrl + path` and parse the response. + * Returns parsed JSON when the response body is JSON, otherwise the raw text + * (e.g. the plain-text txid returned by Esplora's POST /tx). + * Throws `ProviderHttpError` on any non-2xx status. + */ +export async function requestJson( + baseUrl: string, + path: string, + init: RequestInit & { headers: Record }, +): Promise { + const url = `${baseUrl}${path}`; + const response = await fetch(url, init); + + const text = await response.text(); + let body: unknown = text; + try { + body = text.length ? JSON.parse(text) : undefined; + } catch { + // Non-JSON body — keep the raw text. + } + + if (!response.ok) { + throw new ProviderHttpError({ + status: response.status, + statusText: response.statusText, + body, + url, + }); + } + + return body as T; +} diff --git a/src/bitcoin/providers/maestro.ts b/src/bitcoin/providers/maestro.ts new file mode 100644 index 0000000..a410770 --- /dev/null +++ b/src/bitcoin/providers/maestro.ts @@ -0,0 +1,262 @@ +import { IBitcoinProvider } from "../interfaces/bitcoin-provider"; +import { AddressInfo } from "../types/address-info"; +import { ScriptInfo } from "../types/script-info"; +import { TransactionsInfo } from "../types/transactions-info"; +import { TransactionsStatus } from "../types/transactions-status"; +import { UTxO } from "../types/utxo"; +import { requestJson } from "./common"; + +export type MaestroSupportedNetworks = "mainnet" | "testnet"; + +export interface MaestroConfig { + network: MaestroSupportedNetworks; + apiKey: string; +} + +/** + * Esplora `/tx/:txid/outspends` entry — spending status of one output. + */ +type OutspendStatus = { + spent: boolean; + txid?: string; + vin?: number; + status?: TransactionsStatus; +}; + +/** + * Maestro data provider for Bitcoin. + * + * Implements `IBitcoinProvider` (fetcher + submitter) against the Maestro + * Bitcoin API's Esplora-compatible endpoints, mirroring the shape of the Mesh + * Cardano providers. Uses the platform `fetch` — no HTTP client dependency. + * + * @see https://docs.gomaestro.org/bitcoin + */ +export class MaestroProvider implements IBitcoinProvider { + private readonly _baseUrl: string; + private readonly _headers: Record; + private readonly _network: MaestroSupportedNetworks; + + /** + * Create provider with custom base URL (for proxy endpoints). + * @param baseUrl - The base URL of the proxy endpoint. + * @param apiKey - The API key for the proxy. + */ + constructor(baseUrl: string, apiKey: string); + + /** + * Create provider with Maestro configuration. + * @param config - The Maestro configuration object. + */ + constructor(config: MaestroConfig); + + constructor(...args: unknown[]) { + if ( + typeof args[0] === "string" && + (args[0].startsWith("http") || args[0].startsWith("/")) + ) { + this._baseUrl = args[0]; + this._headers = { "api-key": args[1] as string }; + this._network = args[0].includes("testnet") ? "testnet" : "mainnet"; + } else { + const { network, apiKey } = args[0] as MaestroConfig; + this._baseUrl = `https://xbt-${network}.gomaestro-api.org/v0`; + this._headers = { "api-key": apiKey }; + this._network = network; + } + } + + /** + * Get information about an address: chain and mempool funded/spent sums. + * @param address - The address. + * @returns AddressInfo + */ + async fetchAddressInfo(address: string): Promise { + return this.get(`/esplora/address/${address}`); + } + + /** + * Get the list of unspent transaction outputs associated with the address. + * @param address - The address. + * @returns UTxO[] + */ + async fetchAddressUTxOs(address: string): Promise { + return this.get(`/esplora/address/${address}/utxo`); + } + + /** + * Get the unspent outputs of a transaction, optionally restricted to a + * single output index. Composes the Esplora transaction and outspends + * endpoints: an output is returned only if it has not been spent. + * @param txid - The transaction ID. + * @param vout - Restrict the result to this output index (optional). + * @returns UTxO[] — the transaction's unspent outputs. + */ + async fetchUTxO(txid: string, vout?: number): Promise { + const [tx, outspends] = await Promise.all([ + this.get(`/esplora/tx/${txid}`), + this.get(`/esplora/tx/${txid}/outspends`), + ]); + + return tx.vout + .map((output, index) => ({ + txid, + vout: index, + value: output.value, + status: tx.status, + })) + .filter( + (utxo) => + (vout === undefined || utxo.vout === vout) && + !outspends[utxo.vout]?.spent, + ); + } + + /** + * Get transaction history for the specified address, sorted with newest + * first. Returns mempool transactions plus the first 25 confirmed + * transactions; request more confirmed transactions using `lastSeenTxid`. + * @param address - The address. + * @param lastSeenTxid - The last seen transaction ID (optional). + * @returns TransactionsInfo[] + */ + async fetchAddressTxs( + address: string, + lastSeenTxid?: string, + ): Promise { + const path = lastSeenTxid + ? `/esplora/address/${address}/txs/chain/${lastSeenTxid}` + : `/esplora/address/${address}/txs`; + return this.get(path); + } + + /** + * Get the confirmation status of a transaction. + * @param txid - The transaction ID. + * @returns TransactionsStatus + */ + async fetchTxInfo(txid: string): Promise { + return this.get(`/esplora/tx/${txid}/status`); + } + + /** + * Get the estimated fee rate for confirmation within `blocks` blocks. + * Reads the Esplora fee-estimates map (sat/vB keyed by confirmation + * target) and picks the closest available target at or above `blocks`. + * @param blocks - The confirmation target in blocks (default: 6). + * @returns The estimated fee rate in satoshis per vByte. + */ + async fetchFeeEstimates(blocks: number = 6): Promise { + const estimates = await this.get>( + "/esplora/fee-estimates", + ); + + const targets = Object.keys(estimates) + .map(Number) + .sort((a, b) => a - b); + const closest = + targets.find((target) => target >= blocks) ?? targets[targets.length - 1]; + const feeRate = closest !== undefined ? estimates[closest.toString()] : 0; + + if (!feeRate || feeRate <= 0) { + if (this._network === "testnet") { + return 1; // 1 sat/vByte fallback for testnet (low activity expected) + } + throw new Error("[MaestroProvider] Fee estimation unavailable"); + } + + return feeRate; + } + + /** + * Get information about a script hash. + * @param hash - The script hash. + * @returns ScriptInfo + * @note Maestro does not have any endpoint available for this yet + */ + async fetchScriptInfo(hash: string): Promise { + return this.notImplemented("fetchScriptInfo"); + } + + /** + * Get the list of unspent transaction outputs associated with the script hash. + * @param hash - The script hash. + * @returns UTxO[] + * @note Maestro does not have any endpoint available for this yet + */ + async fetchScriptUTxOs(hash: string): Promise { + return this.notImplemented("fetchScriptUTxOs"); + } + + /** + * Get transaction history for the specified script hash. + * @param hash - The script hash. + * @param lastSeenTxid - The last seen transaction ID (optional). + * @returns TransactionsInfo[] + * @note Maestro does not have any endpoint available for this yet + */ + async fetchScriptTxs( + hash: string, + lastSeenTxid?: string, + ): Promise { + return this.notImplemented("fetchScriptTxs"); + } + + private notImplemented(method: string): never { + throw new Error( + `[MaestroProvider] ${method} is not implemented - Maestro does not have any endpoint available for this yet`, + ); + } + + /** + * Broadcast a raw transaction to the network. + * @param tx - The raw transaction in hexadecimal format. + * @returns The transaction ID. + */ + async submitTx(tx: string): Promise { + const data = await this.post<{ txid?: string } | string>("/esplora/tx", tx); + if (typeof data === "string") return data; + if (data?.txid) return data.txid; + throw new Error( + `[MaestroProvider] submitTx: unexpected response ${JSON.stringify(data)}`, + ); + } + + /** + * Get the network this provider is configured for. + * @returns The network configuration (mainnet or testnet). + */ + getNetwork(): MaestroSupportedNetworks { + return this._network; + } + + /** + * Generic GET request against the Maestro API. + * @param path - The API endpoint path. + * @returns The parsed response data. + */ + async get(path: string): Promise { + return requestJson(this._baseUrl, path, { + method: "GET", + headers: this._headers, + }); + } + + /** + * Generic POST request against the Maestro API. + * @param path - The API endpoint path. + * @param body - The request body (objects are JSON-encoded, strings sent raw). + * @returns The parsed response data. + */ + async post(path: string, body: unknown): Promise { + const isRaw = typeof body === "string"; + return requestJson(this._baseUrl, path, { + method: "POST", + headers: { + ...this._headers, + "Content-Type": isRaw ? "text/plain" : "application/json", + }, + body: isRaw ? body : JSON.stringify(body), + }); + } +} diff --git a/src/bitcoin/utils/coin-selection.ts b/src/bitcoin/utils/coin-selection.ts new file mode 100644 index 0000000..00b0d7a --- /dev/null +++ b/src/bitcoin/utils/coin-selection.ts @@ -0,0 +1,90 @@ +/** + * Coin selection for Bitcoin transactions. + * + * Operates on the minimal outpoint shape (`txid`/`vout`/`value`) so it accepts + * both full `UTxO` objects returned by an `IBitcoinProvider` and any + * caller-constructed input set. + */ + +/** + * Minimal UTxO shape required for coin selection. + */ +export type SelectableUTxO = { + txid: string; + vout: number; + value: number; +}; + +export type CoinSelectionResult = { + /** UTxOs chosen to fund the transaction, in selection order. */ + selectedUtxos: T[]; + /** Change value in satoshis; 0 when change would be sub-dust (absorbed as fee). */ + change: number; +}; + +/** + * P2WPKH dust threshold under default relay policy (Bitcoin Core ~0.21+): + * outputs below this value are non-standard and the tx will fail to relay. + * 546 sats matches `GetDustThreshold` for a P2WPKH output at the default + * 3000 sat/kvB dust feerate. Absorb anything below this into the miner fee. + */ +export const DUST_THRESHOLD_P2WPKH = 546; + +/** + * Largest-first UTXO selection with P2WPKH-realistic vbyte estimates. + * + * vbyte math (BIP-141 / BIP-144): + * - overhead: 10.5 vB (version 4 + locktime 4 + segwit marker/flag 0.5 + 2× varint <253) + * - per P2WPKH input: 68 vB (outpoint 41 + script_len 1 + sequence 0 + witness ~27) + * - per output: 31 vB (value 8 + script_len 1 + scriptPubKey 22) + * + * Tries with-change first; if change would be sub-dust, retries without a + * change output (one fewer 31 vB output) so the dust is consumed as fee. + * + * @param utxos - Candidate UTxOs (e.g. from `IBitcoinProvider.fetchAddressUTxOs`). + * @param targetAmount - Total amount to send across all recipients, in satoshis. + * @param feeRate - Fee rate in satoshis per vByte. + * @param numRecipients - Number of recipient outputs in the transaction. + * @returns The selected UTxOs and the change value (0 when no change output). + * @throws When the UTxO set cannot cover `targetAmount` plus fees. + */ +export function selectUtxosLargestFirst( + utxos: T[], + targetAmount: number, + feeRate: number, + numRecipients: number, +): CoinSelectionResult { + const VB_OVERHEAD = 11; + const VB_INPUT_P2WPKH = 68; + const VB_OUTPUT = 31; + const recipientsVb = numRecipients * VB_OUTPUT; + + const sorted = [...utxos].sort((a, b) => b.value - a.value); + let selectedValue = 0; + const selected: T[] = []; + + for (const utxo of sorted) { + selected.push(utxo); + selectedValue += utxo.value; + const inputsVb = selected.length * VB_INPUT_P2WPKH; + const vbytesWithChange = VB_OVERHEAD + inputsVb + recipientsVb + VB_OUTPUT; + const vbytesNoChange = VB_OVERHEAD + inputsVb + recipientsVb; + const feeWithChange = Math.ceil(vbytesWithChange * feeRate); + const feeNoChange = Math.ceil(vbytesNoChange * feeRate); + + if (selectedValue >= targetAmount + feeWithChange) { + const change = selectedValue - targetAmount - feeWithChange; + if (change >= DUST_THRESHOLD_P2WPKH) { + return { selectedUtxos: selected, change }; + } + // Change would be dust — drop the change output, absorb dust as fee. + if (selectedValue >= targetAmount + feeNoChange) { + return { selectedUtxos: selected, change: 0 }; + } + } else if (selectedValue >= targetAmount + feeNoChange) { + // Just enough for a no-change tx. + return { selectedUtxos: selected, change: 0 }; + } + } + throw new Error("[CoinSelection] Insufficient funds for transaction"); +} diff --git a/src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts b/src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts index a5fe419..7cc2ba3 100644 --- a/src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts +++ b/src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts @@ -22,6 +22,7 @@ import { import { RecoveryId } from "../../types/recoveryId"; import { TransactionsInfo } from "../../types/transactions-info"; import { UTxO } from "../../types/utxo"; +import { selectUtxosLargestFirst } from "../../utils/coin-selection"; import { bip32, bip39, bitcoin, ecc, ECPair } from "../core/bitcoin-core"; export interface BitcoinHeadlessWalletConfig { @@ -655,14 +656,6 @@ export class BitcoinHeadlessWallet implements IBitcoinWallet { // Helpers (module-scope, not exported as public API) // ----------------------------------------------------------------------------- -/** - * P2WPKH dust threshold under default relay policy (Bitcoin Core ~0.21+): - * outputs below this value are non-standard and the tx will fail to relay. - * 546 sats matches `GetDustThreshold` for a P2WPKH output at the default - * 3000 sat/kvB dust feerate. Absorb anything below this into the miner fee. - */ -const DUST_THRESHOLD_P2WPKH = 546; - /** * Build per-address sign-target index lists by inspecting each PSBT input's * witnessUtxo script type. Each input is assigned to the single signer that @@ -710,58 +703,6 @@ function buildDefaultSignTargets( return out; } -/** - * Largest-first UTXO selection with P2WPKH-realistic vbyte estimates. - * - * vbyte math (BIP-141 / BIP-144): - * - overhead: 10.5 vB (version 4 + locktime 4 + segwit marker/flag 0.5 + 2× varint <253) - * - per P2WPKH input: 68 vB (outpoint 41 + script_len 1 + sequence 0 + witness ~27) - * - per output: 31 vB (value 8 + script_len 1 + scriptPubKey 22) - * - * Tries with-change first; if change would be sub-dust, retries without a - * change output (one fewer 31 vB output) so the dust is consumed as fee. - */ -function selectUtxosLargestFirst( - utxos: { txid: string; vout: number; value: number }[], - targetAmount: number, - feeRate: number, - numRecipients: number, -): { selectedUtxos: typeof utxos; change: number } { - const VB_OVERHEAD = 11; - const VB_INPUT_P2WPKH = 68; - const VB_OUTPUT = 31; - const recipientsVb = numRecipients * VB_OUTPUT; - - const sorted = [...utxos].sort((a, b) => b.value - a.value); - let selectedValue = 0; - const selected: typeof utxos = []; - - for (const utxo of sorted) { - selected.push(utxo); - selectedValue += utxo.value; - const inputsVb = selected.length * VB_INPUT_P2WPKH; - const vbytesWithChange = VB_OVERHEAD + inputsVb + recipientsVb + VB_OUTPUT; - const vbytesNoChange = VB_OVERHEAD + inputsVb + recipientsVb; - const feeWithChange = Math.ceil(vbytesWithChange * feeRate); - const feeNoChange = Math.ceil(vbytesNoChange * feeRate); - - if (selectedValue >= targetAmount + feeWithChange) { - const change = selectedValue - targetAmount - feeWithChange; - if (change >= DUST_THRESHOLD_P2WPKH) { - return { selectedUtxos: selected, change }; - } - // Change would be dust — drop the change output, absorb dust as fee. - if (selectedValue >= targetAmount + feeNoChange) { - return { selectedUtxos: selected, change: 0 }; - } - } else if (selectedValue >= targetAmount + feeNoChange) { - // Just enough for a no-change tx. - return { selectedUtxos: selected, change: 0 }; - } - } - throw new Error("[BitcoinHeadlessWallet] Insufficient funds for transaction"); -} - /** * Recover the recoveryId (0 or 1) for an ECDSA signature given the message hash and * the expected compressed public key. Returns the id whose point recovery matches diff --git a/src/index.ts b/src/index.ts index 76c651a..9392a20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,11 @@ export type { VerifyMessageResult, } from "./bitcoin/interfaces/bitcoin-wallet"; export * from "./bitcoin/interfaces/bitcoin-provider"; +export { ProviderHttpError } from "./bitcoin/providers/common"; +export type { HttpErrorDetails } from "./bitcoin/providers/common"; +export * from "./bitcoin/providers/maestro"; export * from "./bitcoin/types"; +export * from "./bitcoin/utils/coin-selection"; export * from "./bitcoin/address/bitcoin-address"; export * from "./bitcoin/address/bitcoin-address-manager"; export * from "./bitcoin/wallet/mesh/bitcoin-headless-wallet"; diff --git a/test/bitcoin/coin-selection.test.ts b/test/bitcoin/coin-selection.test.ts new file mode 100644 index 0000000..b76ae37 --- /dev/null +++ b/test/bitcoin/coin-selection.test.ts @@ -0,0 +1,137 @@ +import { + DUST_THRESHOLD_P2WPKH, + selectUtxosLargestFirst, +} from "../../src/bitcoin/utils/coin-selection"; + +const makeUtxo = (value: number, i = 0) => ({ + txid: `${i}`.padStart(64, "0"), + vout: i, + value, +}); + +/** + * vbyte model used by the selector (see coin-selection.ts): + * overhead 11 + 68/input + 31/output. + * 1 input, 1 recipient, with change: 11 + 68 + 31 + 31 = 141 vB. + * 1 input, 1 recipient, no change: 11 + 68 + 31 = 110 vB. + */ +describe("selectUtxosLargestFirst", () => { + it("selects the largest UTxO first", () => { + const utxos = [ + makeUtxo(10_000, 0), + makeUtxo(90_000, 1), + makeUtxo(50_000, 2), + ]; + const { selectedUtxos } = selectUtxosLargestFirst(utxos, 20_000, 1, 1); + expect(selectedUtxos).toHaveLength(1); + expect(selectedUtxos[0]!.value).toBe(90_000); + }); + + it("accumulates multiple UTxOs until target plus fee is covered", () => { + const utxos = [ + makeUtxo(30_000, 0), + makeUtxo(30_000, 1), + makeUtxo(30_000, 2), + ]; + const { selectedUtxos, change } = selectUtxosLargestFirst( + utxos, + 55_000, + 1, + 1, + ); + expect(selectedUtxos).toHaveLength(2); + // 2 inputs, with change: 11 + 2*68 + 31 + 31 = 209 vB → fee 209 sats. + expect(change).toBe(60_000 - 55_000 - 209); + }); + + it("returns exact change matching the vbyte fee model", () => { + const { change } = selectUtxosLargestFirst( + [makeUtxo(100_000)], + 10_000, + 1, + 1, + ); + // 1 input, with change: 141 vB → fee 141 sats. + expect(change).toBe(100_000 - 10_000 - 141); + }); + + it("scales fee with the fee rate", () => { + const { change } = selectUtxosLargestFirst( + [makeUtxo(100_000)], + 10_000, + 10, + 1, + ); + expect(change).toBe(100_000 - 10_000 - 1_410); + }); + + it("accounts for extra recipient outputs in the fee", () => { + const { change } = selectUtxosLargestFirst( + [makeUtxo(100_000)], + 10_000, + 1, + 3, + ); + // 1 input, 3 recipients, with change: 11 + 68 + 3*31 + 31 = 203 vB. + expect(change).toBe(100_000 - 10_000 - 203); + }); + + it("drops sub-dust change and absorbs it as fee", () => { + // 10_700 - 10_500 - 141 = 59 → below dust; no-change fee 110 still covered. + const { selectedUtxos, change } = selectUtxosLargestFirst( + [makeUtxo(10_700)], + 10_500, + 1, + 1, + ); + expect(selectedUtxos).toHaveLength(1); + expect(change).toBe(0); + }); + + it("keeps change exactly at the dust threshold", () => { + // Choose value so change is exactly 546: 10_000 + 141 + 546 = 10_687. + const { change } = selectUtxosLargestFirst( + [makeUtxo(10_687)], + 10_000, + 1, + 1, + ); + expect(change).toBe(DUST_THRESHOLD_P2WPKH); + }); + + it("selects a no-change tx when value only covers the smaller fee", () => { + // Covers target + no-change fee (110) but not with-change fee (141). + const { change } = selectUtxosLargestFirst( + [makeUtxo(10_120)], + 10_000, + 1, + 1, + ); + expect(change).toBe(0); + }); + + it("throws when the UTxO set cannot cover target plus fee", () => { + expect(() => + selectUtxosLargestFirst([makeUtxo(10_000)], 10_000, 1, 1), + ).toThrow(/Insufficient funds/); + }); + + it("throws on an empty UTxO set", () => { + expect(() => selectUtxosLargestFirst([], 1_000, 1, 1)).toThrow( + /Insufficient funds/, + ); + }); + + it("does not mutate the input array", () => { + const utxos = [makeUtxo(1_000, 0), makeUtxo(90_000, 1)]; + const snapshot = [...utxos]; + selectUtxosLargestFirst(utxos, 10_000, 1, 1); + expect(utxos).toEqual(snapshot); + }); + + it("preserves extra properties on selected UTxOs (generic passthrough)", () => { + const utxos = [{ ...makeUtxo(90_000), address: "tb1q..." }]; + const { selectedUtxos } = selectUtxosLargestFirst(utxos, 10_000, 1, 1); + expect(selectedUtxos[0]!.address).toBe("tb1q..."); + }); +}); diff --git a/test/bitcoin/providers/maestro.test.ts b/test/bitcoin/providers/maestro.test.ts new file mode 100644 index 0000000..1d0dd0c --- /dev/null +++ b/test/bitcoin/providers/maestro.test.ts @@ -0,0 +1,290 @@ +import { MaestroProvider, ProviderHttpError } from "../../../src"; + +/** + * Minimal Response-like object for mocking the platform `fetch`. + * `requestJson` only touches `ok`, `status`, `statusText` and `text()`. + */ +function mockResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? "OK" : "Error", + text: async () => (typeof body === "string" ? body : JSON.stringify(body)), + }; +} + +describe("MaestroProvider", () => { + const fetchMock = jest.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + function makeProvider(network: "mainnet" | "testnet" = "mainnet") { + return new MaestroProvider({ network, apiKey: "test-key" }); + } + + /** URL of the nth fetch call. */ + function calledUrl(n = 0): string { + return fetchMock.mock.calls[n]![0] as string; + } + + // --------------------------------------------------------------------------- + // constructor + // --------------------------------------------------------------------------- + describe("constructor", () => { + it("builds the Maestro base URL from a network config", async () => { + fetchMock.mockResolvedValue(mockResponse([])); + await makeProvider("mainnet").fetchAddressUTxOs("addr"); + expect(calledUrl()).toBe( + "https://xbt-mainnet.gomaestro-api.org/v0/esplora/address/addr/utxo", + ); + }); + + it("resolves network from the config", () => { + expect(makeProvider("testnet").getNetwork()).toBe("testnet"); + expect(makeProvider("mainnet").getNetwork()).toBe("mainnet"); + }); + + it("accepts a custom baseUrl + apiKey overload", async () => { + const provider = new MaestroProvider( + "https://proxy.example.com/btc", + "k", + ); + fetchMock.mockResolvedValue(mockResponse([])); + await provider.fetchAddressUTxOs("addr"); + expect(calledUrl()).toBe( + "https://proxy.example.com/btc/esplora/address/addr/utxo", + ); + }); + + it("detects testnet from a custom baseUrl", () => { + const provider = new MaestroProvider( + "https://xbt-testnet.example.com", + "k", + ); + expect(provider.getNetwork()).toBe("testnet"); + }); + + it("sends the api-key header on every request", async () => { + fetchMock.mockResolvedValue(mockResponse([])); + await makeProvider().fetchAddressUTxOs("addr"); + const init = fetchMock.mock.calls[0]![1] as RequestInit; + expect((init.headers as Record)["api-key"]).toBe( + "test-key", + ); + }); + }); + + // --------------------------------------------------------------------------- + // fetchAddressUTxOs / fetchAddressInfo + // --------------------------------------------------------------------------- + describe("address queries", () => { + it("fetchAddressUTxOs returns the UTxO array as-is", async () => { + const utxos = [ + { + txid: "a".repeat(64), + vout: 1, + value: 50_000, + status: { + confirmed: true, + block_height: 100, + block_hash: "b".repeat(64), + block_time: 1, + }, + }, + ]; + fetchMock.mockResolvedValue(mockResponse(utxos)); + const result = await makeProvider().fetchAddressUTxOs("addr"); + expect(result).toEqual(utxos); + }); + + it("fetchAddressInfo hits the esplora address endpoint", async () => { + const info = { + address: "addr", + chain_stats: { funded_txo_sum: 1, spent_txo_sum: 0 }, + mempool_stats: { funded_txo_sum: 0, spent_txo_sum: 0 }, + }; + fetchMock.mockResolvedValue(mockResponse(info)); + const result = await makeProvider().fetchAddressInfo("addr"); + expect(result).toEqual(info); + expect(calledUrl()).toContain("/esplora/address/addr"); + }); + + it("fetchAddressTxs paginates with lastSeenTxid", async () => { + fetchMock.mockResolvedValue(mockResponse([])); + const provider = makeProvider(); + await provider.fetchAddressTxs("addr"); + expect(calledUrl(0)).toContain("/esplora/address/addr/txs"); + await provider.fetchAddressTxs("addr", "cursor-txid"); + expect(calledUrl(1)).toContain( + "/esplora/address/addr/txs/chain/cursor-txid", + ); + }); + }); + + // --------------------------------------------------------------------------- + // fetchUTxO + // --------------------------------------------------------------------------- + describe("fetchUTxO", () => { + const txid = "c".repeat(64); + const status = { + confirmed: true, + block_height: 200, + block_hash: "d".repeat(64), + block_time: 2, + }; + const tx = { + txid, + status, + vout: [{ value: 1_000 }, { value: 2_000 }, { value: 3_000 }], + }; + + function mockTxAndOutspends(outspends: { spent: boolean }[]) { + fetchMock.mockImplementation((url: string) => + Promise.resolve( + url.endsWith("/outspends") + ? mockResponse(outspends) + : mockResponse(tx), + ), + ); + } + + it("returns only unspent outputs", async () => { + mockTxAndOutspends([{ spent: false }, { spent: true }, { spent: false }]); + const result = await makeProvider().fetchUTxO(txid); + expect(result).toEqual([ + { txid, vout: 0, value: 1_000, status }, + { txid, vout: 2, value: 3_000, status }, + ]); + }); + + it("restricts to a single vout when requested", async () => { + mockTxAndOutspends([ + { spent: false }, + { spent: false }, + { spent: false }, + ]); + const result = await makeProvider().fetchUTxO(txid, 1); + expect(result).toEqual([{ txid, vout: 1, value: 2_000, status }]); + }); + + it("returns empty when the requested vout is spent", async () => { + mockTxAndOutspends([{ spent: true }, { spent: true }, { spent: true }]); + const result = await makeProvider().fetchUTxO(txid, 0); + expect(result).toEqual([]); + }); + }); + + // --------------------------------------------------------------------------- + // fetchTxInfo / fee estimates + // --------------------------------------------------------------------------- + describe("tx status and fees", () => { + it("fetchTxInfo returns the transaction status", async () => { + const status = { + confirmed: true, + block_height: 1, + block_hash: "e".repeat(64), + block_time: 3, + }; + fetchMock.mockResolvedValue(mockResponse(status)); + const result = await makeProvider().fetchTxInfo("txid"); + expect(result).toEqual(status); + expect(calledUrl()).toContain("/esplora/tx/txid/status"); + }); + + it("picks the closest confirmation target at or above `blocks`", async () => { + fetchMock.mockResolvedValue( + mockResponse({ "1": 30.5, "6": 12.2, "144": 1.5 }), + ); + expect(await makeProvider().fetchFeeEstimates(6)).toBe(12.2); + expect(await makeProvider().fetchFeeEstimates(3)).toBe(12.2); + expect(await makeProvider().fetchFeeEstimates(1)).toBe(30.5); + }); + + it("falls back to the slowest target when `blocks` exceeds all targets", async () => { + fetchMock.mockResolvedValue(mockResponse({ "1": 30.5, "6": 12.2 })); + expect(await makeProvider().fetchFeeEstimates(500)).toBe(12.2); + }); + + it("returns 1 sat/vB on testnet when estimates are unavailable", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + expect(await makeProvider("testnet").fetchFeeEstimates(6)).toBe(1); + }); + + it("throws on mainnet when estimates are unavailable", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + await expect( + makeProvider("mainnet").fetchFeeEstimates(6), + ).rejects.toThrow(/Fee estimation unavailable/); + }); + }); + + // --------------------------------------------------------------------------- + // submitTx + // --------------------------------------------------------------------------- + describe("submitTx", () => { + it("posts the tx hex and returns the plain-text txid", async () => { + fetchMock.mockResolvedValue(mockResponse("f".repeat(64))); + const txid = await makeProvider().submitTx("020000000001..."); + expect(txid).toBe("f".repeat(64)); + const init = fetchMock.mock.calls[0]![1] as RequestInit; + expect(init.method).toBe("POST"); + expect(init.body).toBe("020000000001..."); + expect(calledUrl()).toContain("/esplora/tx"); + }); + + it("accepts a JSON { txid } response shape", async () => { + fetchMock.mockResolvedValue(mockResponse({ txid: "abc123" })); + expect(await makeProvider().submitTx("00")).toBe("abc123"); + }); + + it("surfaces broadcast rejection as ProviderHttpError with details", async () => { + fetchMock.mockResolvedValue( + mockResponse( + "sendrawtransaction RPC error: min relay fee not met", + 400, + ), + ); + try { + await makeProvider().submitTx("00"); + fail("expected submitTx to throw"); + } catch (error) { + expect(error).toBeInstanceOf(ProviderHttpError); + expect((error as Error).message).toMatch(/min relay fee not met/); + } + }); + }); + + // --------------------------------------------------------------------------- + // errors + unsupported endpoints + // --------------------------------------------------------------------------- + describe("errors", () => { + it("throws ProviderHttpError carrying status for non-2xx responses", async () => { + fetchMock.mockResolvedValue( + mockResponse({ message: "Address not found" }, 404), + ); + try { + await makeProvider().fetchAddressUTxOs("bad-addr"); + fail("expected fetchAddressUTxOs to throw"); + } catch (error) { + expect(error).toBeInstanceOf(ProviderHttpError); + expect((error as ProviderHttpError).details.status).toBe(404); + } + }); + + it("script endpoints throw a not-implemented error", async () => { + const provider = makeProvider(); + await expect(provider.fetchScriptInfo("hash")).rejects.toThrow( + /not implemented/, + ); + await expect(provider.fetchScriptUTxOs("hash")).rejects.toThrow( + /not implemented/, + ); + await expect(provider.fetchScriptTxs("hash")).rejects.toThrow( + /not implemented/, + ); + }); + }); +});