Bitcoin API

Bitcoin has two entry points.

Entry point Role
BitcoinApp (@openloop/sdk-core) Talking to the device. Retrieving addresses and extended public keys, message signing (BIP-137), and PSBT (Partially Signed Bitcoin Transaction) signing
@openloop/sdk-core/bitcoin (subpath) Helpers that never touch the device. Address derivation and validation, fetching and selecting UTXOs, building PSBTs, extracting the transaction from a signed PSBT, and broadcasting

With the subpath you can assemble everything a payment needs using the SDK alone. Three address types are supported.

Type BtcScript BIP-43 purpose Address
Native SegWit (P2WPKH) 'p2wpkh' 84' bc1q… / tb1q…
Nested SegWit (P2SH-P2WPKH) 'p2sh-p2wpkh' 49' 3… / 2…
Legacy (P2PKH) 'p2pkh' 44' 1… / m… / n…

Import

import {
  BitcoinApp,
  BTC_MAINNET_PATH,
  BTC_TESTNET_PATH,
  type BtcMessageSignature,
  type InputSigningPath,
} from '@openloop/sdk-core'

import {
  scanAccount,
  selectUtxos,
  buildPsbt,
  extractRawTx,
  broadcastTx,
  BtcError,
  type BtcNetwork,
  type BtcAccount,
} from '@openloop/sdk-core/bitcoin'

Signature verification (verifyBtcMessage / verifyBtcPsbt) lives in a separate subpath, @openloop/sdk-core/verify (Signature Verification).


BitcoinApp

Initialization

import { BitcoinApp } from '@openloop/sdk-core'

const btc = new BitcoinApp(transport)

transport is any transport instance that implements the ITransport interface.

Methods

Method Description
getAddress(testnet?) Retrieves a bech32 address at the default BIP-84 path (testnet switches between mainnet and testnet)
getAddressWithPath(path) Retrieves an address at an arbitrary BIP32 path
getAccountXpub(coinType) Retrieves the public key and chain code at the account level (84'/coin'/0')
getExtendedPubkey(path, display?) Retrieves the extended public key at an arbitrary path (BtcXpub)
getMasterFingerprint() Retrieves the master key fingerprint (4 bytes hex)
signMessage(message, path) BIP-137 message signing
signPsbt(psbt) PSBT signing (binary in and out)
signPsbtHex(psbt) PSBT signing (hex in and out)
signPsbtWithPaths(psbt, inputPaths) PSBT signing with explicit input paths

Every method takes the transport’s exclusive lock (lock() / unlock()) while it runs.

Paths can be passed as "84'/0'/0'/0/0" or "m/84'/0'/0'/0/0". The only hardened marker is ' (h is not accepted).


getAddress

Retrieves a bech32 (P2WPKH) address.

async getAddress(testnet?: boolean): Promise<{
  publicKey: string   // uncompressed public key (hex, 65 bytes)
  address: string     // bech32 address ("bc1..." or "tb1...")
  chainCode: string   // chain code (hex, 32 bytes)
}>

Parameters

Name Type Default Description
testnet boolean false true: the testnet path (84'/1'/0'/0/0)

Default Paths

Network Path Constant
Mainnet 84'/0'/0'/0/0 BTC_MAINNET_PATH
Testnet 84'/1'/0'/0/0 BTC_TESTNET_PATH

Example

// Mainnet
const { address } = await btc.getAddress()
// address: "bc1q..."

// Testnet
const { address: testnetAddress } = await btc.getAddress(true)
// testnetAddress: "tb1q..."

getAddressWithPath

Retrieves an address at an arbitrary BIP32 path. Use it for multi-account setups and for change addresses.

async getAddressWithPath(path: string): Promise<{
  publicKey: string   // uncompressed public key (hex, 65 bytes)
  address: string
  chainCode: string
}>

The current firmware returns a bech32 (P2WPKH) address regardless of the path’s purpose (44' / 49' / 84'). The network follows the second path element (coin type): 1' gives tb1…, anything else gives bc1…. When you need a Legacy (P2PKH) or Nested SegWit (P2SH-P2WPKH) address, fetch the account’s extended public key with getExtendedPubkey and derive it with the subpath’s deriveAddresses.

Example

// First address of the second account
const second = await btc.getAddressWithPath("84'/0'/1'/0/0")

// Change address
const change = await btc.getAddressWithPath("84'/0'/0'/1/0")

// The "m/" prefix is also accepted
const sixth = await btc.getAddressWithPath("m/84'/0'/0'/0/5")

getAccountXpub

Retrieves the account-level public key and chain code (m/84'/coin'/0'). The path is fixed: only BIP-84 account 0 can be retrieved. When you need Legacy (44'), Nested SegWit (49'), or account 1 and later, use getExtendedPubkey.

async getAccountXpub(coinType: number): Promise<{
  publicKey: string   // compressed public key (hex, 33 bytes)
  chainCode: string   // chain code (hex, 32 bytes)
}>

Parameters

Name Type Description
coinType number 0: mainnet, 1: testnet

Example

const { publicKey, chainCode } = await btc.getAccountXpub(0)  // mainnet

The return value does not include depth or the parent fingerprint, so it cannot be passed to the subpath’s deriveAddresses (a BtcXpub is required). If the device’s response is not 65 bytes, it throws an Error.


getExtendedPubkey

Retrieves the extended public key at an arbitrary path (GET_EXTENDED_PUBKEY of the Ledger Bitcoin v2 protocol; CLA 0xE1 / INS 0x00). The returned BtcXpub can be passed directly to the subpath’s deriveAddresses / scanAccount / findAddressPath.

async getExtendedPubkey(path: string, display?: boolean): Promise<BtcXpub>

interface BtcXpub {
  publicKey: string         // compressed public key (hex, 33 bytes)
  chainCode: string         // chain code (hex, 32 bytes)
  parentFingerprint: number // fingerprint of the immediate parent (not the master)
  depth: number             // number of levels from m
  xpub: string              // the serialized string ("xpub…" / "tpub…")
}

Parameters

Name Type Default Description
path string — e.g. "m/84'/1'/0'". Up to 10 levels. "m" (the root) is also accepted
display boolean false Has no effect on what the current device shows on its screen

Validation

The SDK checks that the device’s answer belongs to the path you asked for. It throws an Error if any of the following disagrees.

Paths longer than 10 levels, and elements that cannot be read as numbers, are also rejected with an Error before anything is sent to the device.

Firmware requirement and the returned form

Example

import { accountPathOf } from '@openloop/sdk-core/bitcoin'

// Extended public key for Native SegWit, account 0, testnet ("m/84'/1'/0'")
const xpub = await btc.getExtendedPubkey(accountPathOf({ script: 'p2wpkh', account: 0 }, 'testnet4'))
console.log(xpub.xpub.slice(0, 4), xpub.depth) // "tpub" 3

▲ An extended public key lets anyone derive every address the account will ever create. Do not let the receiving side leak it.

The SDK also exposes the following (from @openloop/sdk-core).

Name Description
buildExtendedPubkeyRequest(path, display?) A pure function that builds the data part of GET_EXTENDED_PUBKEY (a Uint8Array)
parseExtendedPubkeyResponse(xpub, requestedPath) A pure function that reads the device’s answer (a string), runs the validation above, and returns a BtcXpub
MAX_EXTENDED_PUBKEY_PATH_LEVELS The maximum number of path levels accepted (10)

getMasterFingerprint

Retrieves the wallet’s BIP32 master key fingerprint — hash160(master public key)[:4]. It is identical to the value the device computes internally, and it is the value to set in a PSBT input’s BIP32 derivation (master_fingerprint).

async getMasterFingerprint(): Promise<string>  // 8 lowercase hex characters (big-endian), e.g. "631698e0"

When to Use It

In a normal wallet integration you fetch this value once, at account import time, and embed it in the input derivations of every PSBT you build afterwards. It matters most on the AirGap / watch-only (QR PSBT) path: the device decides an input is its own by matching this fingerprint, so a PSBT input without the correct master_fingerprint is treated as belonging to another wallet and cannot be signed — there is no input-path injection there like signPsbtWithPaths over USB.

Internally it fetches the public key at depth 0 (master m) and returns the first 4 bytes of its hash160. This is the same processing Sparrow / HWI perform.

Example

const fp = await btc.getMasterFingerprint()  // e.g. "631698e0"

// Embed it in the PSBT input's BIP32 derivation (type 0x06):
//   value = fingerprint(4B) ‖ path(4B LE × N)

signMessage

Signs a message in BIP-137 format.

async signMessage(
  message: string,
  path: string
): Promise<BtcMessageSignature>

Parameters

Name Type Description
message string UTF-8 message
path string BIP32 path (e.g. "84'/0'/0'/0/0")

Returns: BtcMessageSignature

Field Type Description
v number The BIP-137 header byte (see the table below). v minus the base value is the recovery ID (0–3)
r string R value (32 bytes hex)
s string S value (32 bytes hex)
signature string Base64 of the full signature (65 bytes: V || R || S). The first byte is the v above

The SDK adjusts v to match the path’s purpose (first element). On every route (USB / BLE / WalletConnect, and so on) the header is one that external verifiers accept.

Path purpose Range of v Matching address type
84' 39–42 P2WPKH (native SegWit)
49' 35–38 P2SH-P2WPKH
44' 31–34 P2PKH (compressed key)
anything else 35–38 no adjustment

If the device’s response is not 65 bytes, it throws an Error. If the user rejects, it throws an ApduError (0x6985).

Example

const sig = await btc.signMessage('Hello, Bitcoin!', BTC_MAINNET_PATH)
console.log(sig.v)         // 39–42 (BTC_MAINNET_PATH is 84')
console.log(sig.signature) // Base64 encoded signature

signPsbt

Signs a PSBT (Partially Signed Bitcoin Transaction). The paths are detected automatically from the BIP32_DERIVATION fields inside the PSBT.

async signPsbt(psbt: Uint8Array | string): Promise<Uint8Array>

Parameters

Name Type Description
psbt Uint8Array \| string PSBT binary data or hex string (0x prefix accepted)

Returns

The signed PSBT as binary data (Uint8Array). After signing, the device finalizes the inputs (FINAL_SCRIPTSIG / FINAL_SCRIPTWITNESS) and returns them, so the result can go straight to extractRawTx (after converting to hex).

Errors

Thrown When
ApduError The device rejected or refused the request (0x6985 is a rejection, among others)
BtcError (code: 'psbt_too_large') The signed PSBT exceeded the read-back limit (256 chunks × 250 bytes = 64,000 bytes)

The SDK does not limit the number of inputs. If you exceed the device’s limit (for example 32 inputs for the whole PSBT), the device refuses and you get an ApduError.

The BtcError thrown by BitcoinApp is the same class as the BtcError imported from @openloop/sdk-core/bitcoin (0.8.1 and later; recognise it with instanceof BtcError. On 0.8.0 and earlier it is a different class, so branch on e.name === 'BtcError' and e.code).

import { BtcError } from '@openloop/sdk-core/bitcoin'

try {
  await btc.signPsbt('70736274ff...')
} catch (e) {
  if (e instanceof BtcError) {
    console.log(e.code) // 'psbt_too_large'
  }
}

signPsbtHex

PSBT signing with hex strings — both input and output are hex strings (the output has no 0x prefix).

async signPsbtHex(psbt: string): Promise<string>

Example

const signedHex = await btc.signPsbtHex('70736274ff...')

signPsbtWithPaths

Signs a PSBT with the BIP32 path of each input given explicitly. Use it when the PSBT has no BIP32_DERIVATION fields, or when you want to sign only specific inputs. The paths returned by the subpath’s buildPsbt can be passed straight in as the second argument.

async signPsbtWithPaths(
  psbt: string,
  inputPaths: InputSigningPath[]
): Promise<string>

Parameters

Name Type Description
psbt string PSBT hex string (0x prefix accepted)
inputPaths InputSigningPath[] The path for each input

The return value is the signed PSBT as a hex string (no 0x). As with signPsbt, it is already finalized.

A signature with explicit paths is limited to 10 inputs on the current device. Beyond that the device refuses and you get an ApduError.

InputSigningPath

Field Type Description
index number Input index in the PSBT
path string BIP32 path (e.g. "84'/0'/0'/0/5")

Example

const signedHex = await btc.signPsbtWithPaths('70736274ff...', [
  { index: 0, path: "84'/0'/0'/0/0" },
  { index: 1, path: "84'/0'/0'/0/3" },
  { index: 2, path: "84'/0'/0'/1/0" }, // change
])

@openloop/sdk-core/bitcoin

A set of functions that assemble everything around a payment without touching the device. The SDK has no UI and no default servers, so it keeps to three rules.

The fragments below assume the following variables are already defined.

import { BitcoinApp, type ITransport } from '@openloop/sdk-core'
import {
  accountPathOf,
  createAddressCache,
  scanAccount,
  type BtcNetwork,
  type BtcAccount,
} from '@openloop/sdk-core/bitcoin'

declare const transport: ITransport // a connected transport

const btc = new BitcoinApp(transport)

// The caller chooses the endpoints (the SDK has no defaults)
const net: BtcNetwork = {
  id: 'testnet4',
  esplora: ['https://mempool.space/testnet4/api'],
  feeEndpoints: ['https://mempool.space/testnet4/api'],
  timeoutMs: 15_000,
}
const acct: BtcAccount = { script: 'p2wpkh', account: 0 }

// Fetch the account's extended public key from the device just once
const xpub = await btc.getExtendedPubkey(accountPathOf(acct, net.id))

// Scan the account to get the UTXOs and the next change address
const cache = createAddressCache()
const { utxos, nextChange } = await scanAccount(xpub, acct, net, { cache })

Types

type BtcNetworkId = 'mainnet' | 'testnet3' | 'testnet4'
type BtcScript = 'p2pkh' | 'p2sh-p2wpkh' | 'p2wpkh'

interface BtcNetwork {
  id: BtcNetworkId
  esplora: string[]       // base URLs of Esplora-compatible APIs, tried best first
  feeEndpoints: string[]  // endpoints that serve `/v1/fees/recommended` (mempool.space compatible)
  timeoutMs: number       // timeout of one call in milliseconds, including reading the body
}

interface BtcAccount {
  script: BtcScript       // determines the purpose (44' / 49' / 84')
  account: number         // the account number (hardened)
}

interface BtcUtxo {
  txid: string
  vout: number
  value: number           // sats
  address: string
  path: string            // e.g. "m/84'/1'/0'/0/3"
  publicKey: string       // compressed public key (hex, 33 bytes)
  confirmed: boolean      // false below 1 confirmation
  blockHeight?: number
}

type BtcAddressKind = 'p2pkh' | 'p2sh' | 'p2wpkh' | 'p2wsh' | 'p2tr'

Function Overview

Function Category Description
pathOf(acct, net, change, index) Paths The full path down to the leaf ("m/84'/1'/0'/1/3")
accountPathOf(acct, net) Paths The path to the account ("m/84'/1'/0'")
publicKeyToAddress(publicKey, script, net) Addresses Builds the address of that type from a public key
deriveAddresses(xpub, acct, net, change, from, count) Addresses Derives consecutive addresses from the account’s extended public key
validateAddress(address, net) Addresses Validates a destination address (3 formats, network check)
addressToScriptPubKey(address, net) Addresses Builds the scriptPubKey from an address
encodeSegwitAddress(hrp, version, program) / decodeSegwitAddress(address) Addresses bech32 / bech32m encoding and checksum-verifying decoding
createAddressCache() Addresses Creates an address → path/public key cache
findAddressPath(address, xpub, acct, net, cache?, opts?) Addresses Looks up the path for an address
scanAccount(xpub, acct, net, opts?) Endpoints Scans the account for UTXOs and the next change address
addressStats(address, net, deps?) Endpoints Asks whether one address has been used (transaction count, total received, total spent)
fetchUtxos(addresses, net, deps?) Endpoints Collects the UTXOs of the given addresses
fetchPrevTx(txid, net, deps?) Endpoints The raw hex of a previous transaction (required for Legacy inputs)
getFeeRate(net, deps?) Endpoints Fee rate (sat/vB)
broadcastTx(rawTxHex, net, expectedTxid?, deps?) Endpoints Broadcasts a raw transaction
selectUtxos(utxos, opts) Building Selects which UTXOs to spend
estimateVbytes(numInputs, script, outputVbytes) Building Estimates the transaction size (vB)
buildPsbt(args) Building Builds a PSBT
extractRawTx(signedPsbtHex) Building Extracts the raw transaction and txid from a signed PSBT

The BIP-32 helpers (deriveChildPublicKey and others) and the constants are covered below.


pathOf

function pathOf(acct: BtcAccount, net: BtcNetworkId, change: 0 | 1, index: number): string
function accountPathOf(acct: BtcAccount, net: BtcNetworkId): string
Argument Description
acct The account (script determines the purpose, account the third element)
net The network (determines the coin type; 1 for both Testnet3 and Testnet4)
change 0: the external (receiving) chain, 1: the change chain
index The leaf number (an integer from 0 up to but not including 2^31)
import { pathOf, accountPathOf } from '@openloop/sdk-core/bitcoin'

pathOf({ script: 'p2wpkh', account: 0 }, 'testnet4', 1, 3)   // "m/84'/1'/0'/1/3"
pathOf({ script: 'p2pkh', account: 1 }, 'mainnet', 0, 0)     // "m/44'/0'/1'/0/0"
accountPathOf({ script: 'p2sh-p2wpkh', account: 0 }, 'mainnet') // "m/49'/0'/0'"

An unknown script / net, a non-integer or out-of-range account / index, or a change other than 0 / 1 throws a BtcError (bad_argument).

publicKeyToAddress

function publicKeyToAddress(publicKey: string, script: BtcScript, net: BtcNetworkId): string

Builds the address of each type from a compressed public key (hex, 33 bytes, starting with 02 / 03; a 0x prefix is accepted).

script Address built
'p2pkh' base58check(0x00 (0x6F on testnet) ‖ hash160(public key))
'p2sh-p2wpkh' base58check(0x05 (0xC4 on testnet) ‖ hash160(0x0014 ‖ hash160(public key)))
'p2wpkh' bech32(bc / tb, witness v0, hash160(public key))
import { publicKeyToAddress } from '@openloop/sdk-core/bitcoin'

const compressed = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'
publicKeyToAddress(compressed, 'p2wpkh', 'mainnet')       // "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"
publicKeyToAddress(compressed, 'p2pkh', 'mainnet')        // "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH"
publicKeyToAddress(compressed, 'p2sh-p2wpkh', 'testnet4') // "2NAUYAHhujozruyzpsFRP63mbrdaU5wnEpN"

A string that is not a compressed public key (including an uncompressed 65-byte key) throws a BtcError (bad_argument). The publicKey returned by getAddress is uncompressed, so it cannot be passed in as is.

deriveAddresses

function deriveAddresses(
  xpub: BtcXpub,
  acct: BtcAccount,
  net: BtcNetworkId,
  change: 0 | 1,
  from: number,
  count: number
): Array<{ address: string; path: string; publicKey: string }>

Derives count addresses of the change chain starting at index from, from the account’s extended public key. It does not touch the device.

import { deriveAddresses } from '@openloop/sdk-core/bitcoin'

const receive = deriveAddresses(xpub, acct, net.id, 0, 0, 5) // receiving 0-4
console.log(receive[0].address, receive[0].path)             // "tb1q…" "m/84'/1'/0'/0/0"

validateAddress

function validateAddress(address: string, net: BtcNetworkId): { kind: BtcAddressKind }

Validates a destination address. It reads all three formats — bech32, bech32m, and base58check — and checks the network (so a testnet address can never receive real coins).

kind Accepted form
'p2wpkh' witness v0, 20 bytes (bc1q… / tb1q…)
'p2wsh' witness v0, 32 bytes
'p2tr' witness v1, 32 bytes (bc1p… / tb1p…)
'p2pkh' base58check (1… / m… / n…)
'p2sh' base58check (3… / 2…)
Thrown code When
invalid_address It cannot be read as an address (empty, checksum, characters, length, unsupported witness version)
network_mismatch It reads as an address but does not belong to the given network

Because invalid_address and network_mismatch are reported separately, you can offer “switch networks?” only for the latter.

import { validateAddress, BtcError } from '@openloop/sdk-core/bitcoin'

try {
  const { kind } = validateAddress('tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx', 'mainnet')
  console.log(kind)
} catch (e) {
  if (e instanceof BtcError && e.code === 'network_mismatch') {
    console.log('This is an address for another network')
  }
}

addressToScriptPubKey

function addressToScriptPubKey(address: string, net: BtcNetworkId): Uint8Array

Turns an address into a scriptPubKey. It performs the same checks as validateAddress first, so it throws the same codes (invalid_address / network_mismatch).

encodeSegwitAddress / decodeSegwitAddress

function encodeSegwitAddress(hrp: string, version: number, program: Uint8Array): string
function decodeSegwitAddress(address: string): { hrp: string; version: number; program: Uint8Array }

Encodes a witness address (bech32 for v0, bech32m for v1 and later) and decodes one while verifying the checksum. All-uppercase addresses are accepted; mixed case is rejected. They throw a plain Error, not a BtcError.

createAddressCache

function createAddressCache(): BtcAddressCache

interface BtcAddressCache {
  get(address: string): { path: string; publicKey: string } | undefined
  set(address: string, entry: { path: string; publicKey: string }): void
  syncToDevice(deviceId: string | null): void
  readonly size: number
  clear(): void
}

A cache of address → path/public key. It is not one per module: the caller owns it (so you can keep separate caches per network, type, and account). Pass it to scanAccount and findAddressPath and they remember the addresses they derive.

syncToDevice(deviceId) declares which device owns the cache. When the owner changes, everything is discarded (treating another device’s addresses as your own would hand out paths that cannot be signed). The same non-null ID does nothing. null (owner unknown) discards everything every time.

cache.syncToDevice('device-A')
cache.set('tb1q…', { path: "m/84'/1'/0'/0/0", publicKey: '02…' })
cache.syncToDevice('device-B') // the owner changed → everything is discarded
console.log(cache.size)        // 0

findAddressPath

function findAddressPath(
  address: string,
  xpub: BtcXpub,
  acct: BtcAccount,
  net: BtcNetworkId,
  cache?: BtcAddressCache,
  opts?: { maxIndex?: number; step?: number }
): string | null

Looks up the path for an address. It checks cache first; if the address is not there it derives step addresses at a time (default 20), receiving chain first and then the change chain, from 0 up to maxIndex - 1 (default 200). The addresses it derives along the way are stored in cache.

It returns null when nothing is found (it does not throw — an address that is not yours is not an error). A maxIndex / step below 1 or not an integer, or an empty address, throws a BtcError (bad_argument).

import { findAddressPath } from '@openloop/sdk-core/bitcoin'

const path = findAddressPath(nextChange.address, xpub, acct, net.id, cache)
// e.g. "m/84'/1'/0'/1/0"; null when not found

scanAccount

function scanAccount(
  xpub: BtcXpub,
  acct: BtcAccount,
  net: BtcNetwork,
  opts?: {
    cache?: BtcAddressCache
    gapLimit?: number      // default 20
    concurrency?: number   // default 4 (API_PARALLEL_LIMIT)
    knownUsed?: Array<{ chain: 0 | 1; index: number }>  // indexes the caller already knows were used
  } & FetchDeps
): Promise<BtcScanResult>

interface BtcScanResult {
  used: Array<{ address: string; path: string }>  // addresses that have been used (both chains)
  utxos: BtcUtxo[]
  nextChange: { index: number; address: string; path: string; publicKey: string }
}

Scans the account. It examines both the receiving chain and the change chain until gapLimit unused addresses in a row, and queries UTXOs only for addresses that have a balance.

import { scanAccount } from '@openloop/sdk-core/bitcoin'

const { used, utxos, nextChange } = await scanAccount(xpub, acct, net, { cache, gapLimit: 20 })
const total = utxos.reduce((sum, u) => sum + u.value, 0)
console.log(`${used.length} used addresses, balance ${total} sats`)
console.log('Next change destination', nextChange.address, nextChange.path)

addressStats

function addressStats(
  address: string,
  net: BtcNetwork,
  deps?: FetchDeps
): Promise<BtcAddressStats>

interface BtcAddressStats {
  txCount: number   // Number of transactions (confirmed + unconfirmed). Greater than 0 means "used"
  funded: number    // Total received (sats, confirmed + unconfirmed)
  spent: number     // Total spent (sats, confirmed + unconfirmed). Balance is funded - spent
}

Asks the endpoint (Esplora address) whether one address has been used. It is for callers that have only the addresses a wallet returned (for example over WalletConnect) and no extended public key, so scanAccount is not available, and that want to pick a change address with no transactions.

fetchUtxos

function fetchUtxos(
  addresses: Array<{ address: string; path: string; publicKey: string }>,
  net: BtcNetwork,
  deps?: FetchDeps
): Promise<BtcUtxo[]>

Collects all the UTXOs of the given addresses (at most API_PARALLEL_LIMIT at a time). If even one address fails on every endpoint, it throws fetch_failed. The shape of each answer (txid, vout, amount) is checked, and an answer with the wrong shape counts as that endpoint failing and moves on to the next. bad_argument if addresses is not an array.

import { fetchUtxos, deriveAddresses } from '@openloop/sdk-core/bitcoin'

const firstFive = deriveAddresses(xpub, acct, net.id, 0, 0, 5)
const found = await fetchUtxos(firstFive, net)

fetchPrevTx

function fetchPrevTx(txid: string, net: BtcNetwork, deps?: FetchDeps): Promise<string>

Fetches the raw hex (lowercase) of a previous transaction. A Legacy (P2PKH) input reads its amount from the whole previous transaction, so buildPsbt needs it.

Thrown code When
bad_argument txid is not 64 hex digits, or net.esplora is empty
fetch_failed No endpoint could provide it
bad_response The transaction obtained does not hash to txid (checked against the reversed sha256d, so a false amount is never shown on the device)
prev_tx_too_large It exceeds the device’s limit (MAX_PREV_TX_BYTES = 4,096 bytes)
import { fetchPrevTx } from '@openloop/sdk-core/bitcoin'

const prevTxHex = await fetchPrevTx(utxos[0].txid, net)

getFeeRate

function getFeeRate(net: BtcNetwork, deps?: FetchDeps): Promise<number>

Fetches the fee rate (sat/vB): the halfHourFee of /v1/fees/recommended on feeEndpoints (this is specific to mempool.space and separate from the Esplora endpoints).

A value that arrives from outside is never used as is: anything outside MIN_FEE_RATE_SAT_VB (1) to MAX_FEE_RATE_SAT_VB (5000) is not rounded but rejected with a BtcError (fee_rate_out_of_range). fetch_failed when it cannot be fetched, and bad_argument when net.feeEndpoints is empty.

import { getFeeRate } from '@openloop/sdk-core/bitcoin'

const feeRate = await getFeeRate(net) // e.g. 5

selectUtxos

function selectUtxos(
  utxos: BtcUtxo[],
  opts: {
    to: BtcRecipient[]            // { address: string; value: number }[], at least one
    feeRate: number               // sat/vB
    script: BtcScript             // input type (one type per account)
    net: BtcNetworkId
    excluded?: Iterable<string>   // used-up markers "txid:vout"
    changeAddress?: string        // change destination, used to estimate the output size
  }
): BtcSelection

interface BtcSelection {
  inputs: BtcUtxo[]
  fee: number      // the fee actually paid (sats) = total in − total out
  change: number   // change (sats). 0 means no change output is created
  vbytes: number   // estimated size (vB)
}

Selects which UTXOs to spend.

Thrown code When
insufficient The balance is insufficient
insufficient_unconfirmed Counting the unconfirmed UTXOs would cover it (waiting would make the payment possible)
fee_too_high The fee exceeds MAX_ABSOLUTE_FEE_SATS (10,000,000 sats = 0.1 BTC)
fee_exceeds_amount The amount is FEE_FRACTION_MIN_AMOUNT_SATS (1,000,000 sats) or more and the fee exceeds it
fee_rate_out_of_range feeRate is outside 1–5000
invalid_address / network_mismatch An address in to / changeAddress is invalid or for another network
bad_argument utxos is not an array, to is empty, a value is not a positive integer, or an unknown script
import { selectUtxos, getFeeRate } from '@openloop/sdk-core/bitcoin'

const sel = selectUtxos(utxos, {
  to: [{ address: 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx', value: 20_000 }],
  feeRate: await getFeeRate(net),
  script: acct.script,
  net: net.id,
  excluded: new Set<string>(),
  changeAddress: nextChange.address,
})
console.log(sel.inputs.length, sel.fee, sel.change, sel.vbytes)

estimateVbytes

function estimateVbytes(numInputs: number, script: BtcScript, outputVbytes: number[]): number

Estimates the transaction size (vB) as ceil(TX_OVERHEAD_VBYTES + number of inputs × INPUT_VBYTES[script] + sum of output vB). Pass the output vB values from OUTPUT_VBYTES (per destination type). An unknown script throws bad_argument.

import { estimateVbytes, OUTPUT_VBYTES } from '@openloop/sdk-core/bitcoin'

// 2 P2WPKH inputs, 1 P2WPKH destination + 1 change output
estimateVbytes(2, 'p2wpkh', [OUTPUT_VBYTES.p2wpkh, OUTPUT_VBYTES.p2wpkh]) // 209

buildPsbt

function buildPsbt(args: {
  script: BtcScript
  net: BtcNetworkId
  inputs: BtcPsbtInput[]
  to: Array<{ address: string; value: number }>
  change?: BtcChangeOutput   // omit when no change output is created
}): BtcPsbt

interface BtcPsbtInput extends BtcUtxo {
  prevTxHex?: string         // required only for Legacy ('p2pkh')
}
interface BtcChangeOutput {  // scanAccount's nextChange plus a value
  address: string
  path: string
  publicKey: string
  value: number
}
interface BtcPsbt {
  psbtHex: string
  paths: Array<{ index: number; path: string }>   // can be passed straight to signPsbtWithPaths
}

Builds a PSBT (v0). The transaction version is 2, locktime is 0, and every input’s sequence is RBF_SEQUENCE (0xfffffffd), so RBF (fee bumping) is possible.

Input type What is attached
'p2wpkh' WITNESS_UTXO + BIP32_DERIVATION
'p2sh-p2wpkh' WITNESS_UTXO + REDEEM_SCRIPT + BIP32_DERIVATION
'p2pkh' NON_WITNESS_UTXO (prevTxHex) + BIP32_DERIVATION
Thrown code When
bad_argument No inputs or destinations, a value is not a positive integer, a bad txid / public key / hex, a Legacy input without prevTxHex, or a prevTxHex that does not match its txid
prev_tx_too_large prevTxHex exceeds MAX_PREV_TX_BYTES (4,096)
invalid_address / network_mismatch A destination or change address is invalid or for another network

It does not refuse in advance because of the size of what it built. A PSBT that is too large is refused by the device.

import { buildPsbt } from '@openloop/sdk-core/bitcoin'

const to = [{ address: 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx', value: 20_000 }]
const { psbtHex, paths } = buildPsbt({
  script: acct.script,
  net: net.id,
  inputs: sel.inputs,
  to,
  change: sel.change > 0 ? { ...nextChange, value: sel.change } : undefined,
})

For Legacy, fetch the previous transaction for each input and pass it in.

import { fetchPrevTx, buildPsbt, type BtcAccount } from '@openloop/sdk-core/bitcoin'

const legacyAcct: BtcAccount = { script: 'p2pkh', account: 0 }
const withPrev = await Promise.all(
  sel.inputs.map(async u => ({ ...u, prevTxHex: await fetchPrevTx(u.txid, net) }))
)
const legacy = buildPsbt({
  script: legacyAcct.script,
  net: net.id,
  inputs: withPrev,
  to: [{ address: 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx', value: 20_000 }],
})

extractRawTx

function extractRawTx(signedPsbtHex: string): { rawTxHex: string; txid: string }

Extracts the broadcastable raw transaction and its txid from a signed PSBT (the return value of signPsbtWithPaths / signPsbtHex). It handles all three types and reads each input’s FINAL_SCRIPTSIG (0x07) and FINAL_SCRIPTWITNESS (0x08). If even one input has a witness, it uses the form with marker and flag (BIP-144). It does not verify signatures.

A PSBT that cannot be read (magic bytes missing, truncated, or an input that is not finalized) throws a BtcError (bad_psbt); a string that is not hex throws bad_argument.

import { extractRawTx } from '@openloop/sdk-core/bitcoin'

const { rawTxHex, txid } = extractRawTx(signedHex)

broadcastTx

function broadcastTx(
  rawTxHex: string,
  net: BtcNetwork,
  expectedTxid?: string,
  deps?: FetchDeps
): Promise<string>   // txid

Broadcasts a raw transaction with POST /tx and returns the txid. It tries the endpoints best first and does not resend to another endpoint once one has accepted (so it is never broadcast twice).

Thrown code When
bad_argument rawTxHex is not hex
broadcast_rejected No endpoint accepted the broadcast (this also covers unreachable endpoints, HTTP errors, and answers that are not a txid; it is never fetch_failed)
bad_response The txid an endpoint returned does not match expectedTxid
import { broadcastTx } from '@openloop/sdk-core/bitcoin'

const sentTxid = await broadcastTx(rawTxHex, net, txid)

BtcError

class BtcError extends OpenloopError {
  readonly code: BtcErrorCode
}

type BtcErrorCode =
  | 'bad_argument' | 'invalid_address' | 'network_mismatch'
  | 'fetch_failed' | 'bad_response'
  | 'prev_tx_too_large' | 'fee_rate_out_of_range'
  | 'insufficient' | 'insufficient_unconfirmed' | 'fee_too_high' | 'fee_exceeds_amount'
  | 'psbt_too_large' | 'bad_psbt' | 'broadcast_rejected'

code is the key to branch on. Messages are in English, and the SDK carries no translations.

code Meaning Mainly thrown by
bad_argument A bad argument (path, index, network, length, shape). A mistake in how the caller built the call everywhere
invalid_address Cannot be read as an address validateAddress / selectUtxos / buildPsbt
network_mismatch Readable, but not for the given network same as above
fetch_failed The endpoints could not be queried (never treated as empty) scanAccount / fetchUtxos / fetchPrevTx / getFeeRate
bad_response An endpoint’s answer was not what was expected fetchPrevTx / broadcastTx
prev_tx_too_large The previous transaction exceeds the device’s limit (4,096 bytes) fetchPrevTx / buildPsbt
fee_rate_out_of_range The fee rate is outside 1–5000 sat/vB getFeeRate / selectUtxos
insufficient The balance is insufficient selectUtxos
insufficient_unconfirmed Counting unconfirmed UTXOs would cover it selectUtxos
fee_too_high The fee exceeds the absolute cap (0.1 BTC) selectUtxos
fee_exceeds_amount The fee exceeds the amount sent selectUtxos
psbt_too_large The signed PSBT exceeded the read-back limit (64,000 bytes) BitcoinApp PSBT signing (the BtcError on the @openloop/sdk-core side)
bad_psbt Cannot be read as a PSBT extractRawTx
broadcast_rejected The broadcast was not accepted broadcastTx

FetchDeps and Retries

The functions that query endpoints (opts of scanAccount; deps of fetchUtxos / fetchPrevTx / getFeeRate / broadcastTx) accept an extra argument of this shape.

interface FetchDeps {
  fetchImpl?: typeof fetch   // default: the runtime's globalThis.fetch
  retryDelayMs?: number      // interval between retries (ms). Default API_RETRY_DELAY_MS (1000)
  retryCount?: number        // attempts per URL. Default API_RETRY_COUNT (3)
}

Endpoints are tried from the first entry of esplora (or feeEndpoints) onward, each URL up to retryCount times. The first answer that is HTTP 200 and has the expected shape is returned. If every endpoint fails, it throws fetch_failed. In a runtime without fetch, you need fetchImpl.

import { getFeeRate } from '@openloop/sdk-core/bitcoin'

const fetchImpl: typeof fetch = (url, init) => fetch(url, init) // e.g. to inject measurement or a proxy
const rate = await getFeeRate(net, { fetchImpl, retryCount: 1, retryDelayMs: 0 })

Constants

All are importable from @openloop/sdk-core/bitcoin.

Constant Value Description
MIN_FEE_RATE_SAT_VB 1 Lower bound of the fee rate (sat/vB)
MAX_FEE_RATE_SAT_VB 5000 Upper bound of the fee rate. A value above it is treated as a fault of the provider
MAX_ABSOLUTE_FEE_SATS 10_000_000 Absolute cap on the fee paid in one payment (0.1 BTC)
MAX_FEE_FRACTION 1.0 Largest fee allowed relative to the amount sent
FEE_FRACTION_MIN_AMOUNT_SATS 1_000_000 Smallest amount sent to which the ratio check above applies
DUST_SATS 546 Change below this creates no output and goes to the fee
MAX_PREV_TX_BYTES 4096 Maximum size of a previous transaction (the device’s limit)
PSBT_RESPONSE_CHUNK_SIZE 250 Size of one chunk of a signed PSBT (bytes)
PSBT_MAX_RESPONSE_CHUNKS 256 Number of chunks that can be read back (P1 is one byte)
MAX_SIGNED_PSBT_BYTES 64000 Largest signed PSBT that can be read back (256 × 250)
RBF_SEQUENCE 0xfffffffd Sequence of every input (RBF)
INPUT_VBYTES { p2pkh: 148, 'p2sh-p2wpkh': 91, p2wpkh: 68 } vB of one input (counted on the high side)
OUTPUT_VBYTES { p2wpkh: 31, p2tr: 43, p2wsh: 43, p2pkh: 34, p2sh: 32 } vB of one output (per destination type)
TX_OVERHEAD_VBYTES 10.5 vB of the transaction header
PURPOSE_OF_SCRIPT { p2pkh: 44, 'p2sh-p2wpkh': 49, p2wpkh: 84 } Type → BIP-43 purpose
COIN_TYPE_OF_NETWORK { mainnet: 0, testnet3: 1, testnet4: 1 } Network → BIP-44 coin type
API_RETRY_COUNT 3 Default attempts per URL
API_RETRY_DELAY_MS 1000 Default interval between retries (ms)
API_PARALLEL_LIMIT 4 Default number of fetches run at once

BIP-32 Helpers

Child derivation from public keys alone, and reading and writing extended public keys. They can be imported from both @openloop/sdk-core and @openloop/sdk-core/bitcoin (the same implementation, re-exported).

Name Description
deriveChildPublicKey(parentPubkey, parentChainCode, index) Derives the public key and chain code of a non-hardened child ({ publicKey, chainCode }, hex). index is an integer from 0 up to but not including 2^31
decodeExtendedPubkey(serialized) Reads xpub… / tpub… into an ExtendedPublicKey (version / depth / parentFingerprint / childNumber / chainCode / publicKey). Rejects an xprv and points not on the curve
base58CheckDecode(str) / base58CheckEncode(payload) base58check (the 4-byte checksum is always verified). Decode returns a Uint8Array without the checksum
BIP32_VERSION_XPUB / BIP32_VERSION_TPUB Extended public key versions (0x0488b21e / 0x043587cf)

Private keys are not handled (hardened derivation is not possible). Invalid input throws a plain Error.


Sending a Transaction End to End

Combining the functions above gives the following (Native SegWit, testnet4).

import { BitcoinApp, type ITransport } from '@openloop/sdk-core'
import {
  BtcError,
  accountPathOf,
  broadcastTx,
  buildPsbt,
  createAddressCache,
  extractRawTx,
  getFeeRate,
  scanAccount,
  selectUtxos,
  validateAddress,
  type BtcAccount,
  type BtcNetwork,
} from '@openloop/sdk-core/bitcoin'

const net: BtcNetwork = {
  id: 'testnet4',
  esplora: ['https://mempool.space/testnet4/api'],
  feeEndpoints: ['https://mempool.space/testnet4/api'],
  timeoutMs: 15_000,
}
const acct: BtcAccount = { script: 'p2wpkh', account: 0 }

async function send(transport: ITransport, toAddress: string, sats: number): Promise<string> {
  const btc = new BitcoinApp(transport)

  validateAddress(toAddress, net.id)                           // check the destination and network

  const xpub = await btc.getExtendedPubkey(accountPathOf(acct, net.id))
  const cache = createAddressCache()
  const { utxos, nextChange } = await scanAccount(xpub, acct, net, { cache })

  const sel = selectUtxos(utxos, {
    to: [{ address: toAddress, value: sats }],
    feeRate: await getFeeRate(net),
    script: acct.script,
    net: net.id,
    changeAddress: nextChange.address,
  })

  const { psbtHex, paths } = buildPsbt({
    script: acct.script,
    net: net.id,
    inputs: sel.inputs,
    to: [{ address: toAddress, value: sats }],
    change: sel.change > 0 ? { ...nextChange, value: sel.change } : undefined,
  })

  const signedHex = await btc.signPsbtWithPaths(psbtHex, paths) // review and approve on the device
  const { rawTxHex, txid } = extractRawTx(signedHex)
  return broadcastTx(rawTxHex, net, txid)
}

async function main(transport: ITransport) {
  try {
    const txid = await send(transport, 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx', 20_000)
    console.log('Broadcast', txid)
  } catch (e) {
    if (e instanceof BtcError) {
      console.log(e.code, e.message) // e.g. 'insufficient'
    } else {
      throw e // ApduError (rejection, etc.), TransportError
    }
  }
}

Signature Verification

Verifies a signature returned by the device with a reference implementation (@noble). It lives in the @openloop/sdk-core/verify subpath and does not depend on the transport. The full list is in the API Reference.

Function What it verifies
verifyBtcMessage(message, sig, identity) A BIP-137 message signature
verifyBtcPsbt(signedPsbtHex) The signatures of a signed PSBT

Both return a VerifyResult ({ recovered: string; expected?: string; ok: boolean }).

verifyBtcMessage

function verifyBtcMessage(message: string, sig: BtcMessageSignature, identity: string): VerifyResult

Recovers the public key from the sig returned by signMessage and compares it with identity. identity is a public key (hex, compressed 33 bytes or uncompressed 65 bytes) or an address. An address can be matched only when it carries the hash160 of the public key (bech32 P2WPKH and base58 P2PKH); a P2SH-P2WPKH address (the hash160 of the redeem script) does not match. In that case pass the public key. A broken signature returns ok: false instead of throwing.

import { verifyBtcMessage } from '@openloop/sdk-core/verify'

const message = 'Hello, Bitcoin!'
const sig = await btc.signMessage(message, BTC_MAINNET_PATH)
const { address } = await btc.getAddress()
const result = verifyBtcMessage(message, sig, address)
console.log(result.ok) // true

verifyBtcPsbt

function verifyBtcPsbt(signedPsbtHex: string): VerifyResult

Verifies the signatures of a signed PSBT (such as the return value of signPsbtHex) by recomputing the signature hash. Three input types can be verified.

Type Signature hash
Native SegWit (P2WPKH) BIP-143
Nested SegWit (P2SH-P2WPKH) BIP-143 (the public key hash is checked against the redeem script)
Legacy (P2PKH) The legacy signature hash (the scriptPubKey of the spent output is read from the previous transaction in NON_WITNESS_UTXO and substituted in)

ok tells whether the signature is cryptographically valid. The public key is read from inside the PSBT and put in expected. An input of any other type, or a PSBT whose shape cannot be read, throws an exception.

import { verifyBtcPsbt } from '@openloop/sdk-core/verify'

const signedHex = await btc.signPsbtHex('70736274ff...')
const result = verifyBtcPsbt(signedHex)
console.log(result.ok)

PSBT Protocol Details

PSBT signing is a three-phase exchange with the device (CLA 0xF0).

Diagram 0

Phase 1: Sending the PSBT

Chunk P1 Data format
First 0x00 [4B total_size][1B num_paths][paths...][psbt_data...]
Continuation 0x80 [psbt_data...]
Final 0xFF [remaining_data] → the device starts signing

Phase 2: Signing on the Device

The user reviews the transaction details on the device screen and presses the approve button. If the user rejects, it becomes an ApduError (0x6985). After signing, the device finalizes the inputs and holds the PSBT.

Phase 3: Retrieving the Signed PSBT

Response Status word Meaning
[signed_psbt_chunk] 0x61XX More data follows — request the next chunk
[signed_psbt_chunk] 0x9000 Final chunk — retrieval complete

Increment the chunk index (P1) from 0 and repeat until 0x9000 comes back. The transport does not throw on 0x61XX; it returns it as the last two bytes of the response. The SDK takes a trailing 0x61XX to mean more data follows, strips those two bytes, and concatenates the chunks.

A chunk is 250 bytes (PSBT_RESPONSE_CHUNK_SIZE) and P1 is one byte, so at most 256 chunks (MAX_SIGNED_PSBT_BYTES = 64,000 bytes) can be read. Beyond that it stops with a BtcError (psbt_too_large).


bitcoinjs-lib Integration

Sign a PSBT built with bitcoinjs-lib. The PSBT the device returns is already finalized, so do not call finalizeAllInputs() (it would fail with Can not finalize input); call extractTransaction() directly.

import * as bitcoin from 'bitcoinjs-lib'
import { BitcoinApp, BTC_MAINNET_PATH, type ITransport } from '@openloop/sdk-core'

async function signWithBitcoinjs(transport: ITransport): Promise<string> {
  const btc = new BitcoinApp(transport)

  // Get the address
  const { address } = await btc.getAddress()

  // Build the PSBT
  const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin })
  psbt.addInput({
    hash: '7d067b4a697a09d2c3cff7d4d9506c9955e93bff41bf82d439da7d030382bc3e',
    index: 0,
    witnessUtxo: {
      script: bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin),
      value: 50000,
    },
  })
  psbt.addOutput({
    address: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4',
    value: 40000,
  })

  // Sign the PSBT on the device
  const psbtHex = psbt.toHex()
  const signedHex = await btc.signPsbtWithPaths(psbtHex, [
    { index: 0, path: BTC_MAINNET_PATH },
  ])

  // Load the signed (already finalized) PSBT and extract the raw transaction to broadcast
  const signedPsbt = bitcoin.Psbt.fromHex(signedHex)
  return signedPsbt.extractTransaction().toHex()
}

With the subpath’s buildPsbt / extractRawTx you can do the same without bitcoinjs-lib (Sending a Transaction End to End).

Next Steps