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 {
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).
import { BitcoinApp } from '@openloop/sdk-core'
const btc = new BitcoinApp(transport)transport is any transport instance that implements the
ITransport interface.
| 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).
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)
}>| Name | Type | Default | Description |
|---|---|---|---|
testnet |
boolean |
false |
true: the testnet path
(84'/1'/0'/0/0) |
| Network | Path | Constant |
|---|---|---|
| Mainnet | 84'/0'/0'/0/0 |
BTC_MAINNET_PATH |
| Testnet | 84'/1'/0'/0/0 |
BTC_TESTNET_PATH |
// Mainnet
const { address } = await btc.getAddress()
// address: "bc1q..."
// Testnet
const { address: testnetAddress } = await btc.getAddress(true)
// testnetAddress: "tb1q..."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.
// 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")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)
}>| Name | Type | Description |
|---|---|---|
coinType |
number |
0: mainnet, 1: testnet |
const { publicKey, chainCode } = await btc.getAccountXpub(0) // mainnetThe 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.
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…")
}| 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 |
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.
depth does not match the number of levels
requested1 but the key is not
a tpub, or it is anything else but the key is not an
xpub (paths with fewer than two elements are not
checked)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.
xpub /
tpub prefix (not ypub / zpub, the
same as Ledger’s protocol v2).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) |
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"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 itshash160. This is the same processing Sparrow / HWI perform.
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)Signs a message in BIP-137 format.
async signMessage(
message: string,
path: string
): Promise<BtcMessageSignature>| Name | Type | Description |
|---|---|---|
message |
string |
UTF-8 message |
path |
string |
BIP32 path (e.g. "84'/0'/0'/0/0") |
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).
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 signatureSigns 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>| Name | Type | Description |
|---|---|---|
psbt |
Uint8Array \| string |
PSBT binary data or hex string (0x prefix
accepted) |
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).
| 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
BtcErrorthrown byBitcoinAppis the same class as theBtcErrorimported from@openloop/sdk-core/bitcoin(0.8.1 and later; recognise it withinstanceof BtcError. On 0.8.0 and earlier it is a different class, so branch one.name === 'BtcError'ande.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'
}
}PSBT signing with hex strings — both input and output are hex strings
(the output has no 0x prefix).
async signPsbtHex(psbt: string): Promise<string>const signedHex = await btc.signPsbtHex('70736274ff...')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>| 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") |
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
])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.
BtcError code (an English message is attached,
but branch on code). The caller decides what to show on
screen.fetch_failed (it
will never read “balance 0” and build a payment).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 })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'1 for both).BtcXpub is the return type of getExtendedPubkey.script and the coin type from
id, so BtcAccount does not carry them (see
PURPOSE_OF_SCRIPT /
COIN_TYPE_OF_NETWORK).| 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.
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).
function publicKeyToAddress(publicKey: string, script: BtcScript, net: BtcNetworkId): stringBuilds 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.
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.
xpub must be a BtcXpub at the
account level (m/purpose'/coin'/account',
depth is 3). A different depth
would silently build the addresses of another account, so it is rejected
with a BtcError (bad_argument).from / count are integers of
0 or more (an empty array is returned when
count is 0). Otherwise
bad_argument.path has the form
"m/84'/1'/0'/0/5" and publicKey is the
compressed public key (hex).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"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')
}
}function addressToScriptPubKey(address: string, net: BtcNetworkId): Uint8ArrayTurns an address into a scriptPubKey. It performs the same checks as
validateAddress first, so it throws the same
codes (invalid_address /
network_mismatch).
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.
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) // 0function findAddressPath(
address: string,
xpub: BtcXpub,
acct: BtcAccount,
net: BtcNetworkId,
cache?: BtcAddressCache,
opts?: { maxIndex?: number; step?: number }
): string | nullLooks 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 foundfunction 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.
nextChange is the first index with no
transactions on the change chain. It can be passed straight to
buildPsbt’s change (so addresses are not
reused).fetch_failed.bad_argument if xpub.depth is not
3. bad_argument also if gapLimit
/ concurrency is below 1 or not an
integer.utxos include unconfirmed ones
(confirmed: false).opts.knownUsed
(Array<{ chain: 0 | 1; index: number }>): indexes the
caller already knows were used. They are counted as
used even if the endpoint says unused (they enter
used, the gap is counted again, and nextChange
skips them). The test networks (Testnet3 / 4) share keys, so you can
pass the indexes used on the other network. The endpoint is still
queried, so balances remain the endpoint’s answer. Omit it and nothing
changes; an invalid entry is bad_argument.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)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.
fetch_failed when every endpoint fails).net is refused before any
endpoint is queried (invalid_address /
network_mismatch).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)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)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. 5function 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.
confirmed: true) are
candidates. Your own change is not used either until it has a
confirmation. UTXOs whose "txid:vout" is in
excluded are also left out.ceil(vbytes × feeRate). The size is
estimated from the input type, the destination types, and the change
output (estimateVbytes). If
you omit changeAddress, the change is counted as the same
type as the inputs.DUST_SATS (546), no output is
created and it is added to the fee (change is
0, and fee is total in − amount sent).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)function estimateVbytes(numInputs: number, script: BtcScript, outputVbytes: number[]): numberEstimates 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]) // 209function 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 |
BIP32_DERIVATION is
00000000. Specify the paths by passing the returned
paths to signPsbtWithPaths.publicKey, not
from address.change output also gets a
BIP32_DERIVATION. The device matches it against its own
keys and does not show the change as a “destination”.prevTxHex is checked against
txid before it is used.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 }],
})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)function broadcastTx(
rawTxHex: string,
net: BtcNetwork,
expectedTxid?: string,
deps?: FetchDeps
): Promise<string> // txidBroadcasts 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).
200 <html> is never returned as a txid).expectedTxid (the txid
returned by extractRawTx). When you do, two things
take effect:
expectedTxid is returned (this is the answer you get when
the same transaction is sent again).expectedTxid, it throws bad_response.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)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 |
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 })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 |
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.
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
}
}
}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 }).
function verifyBtcMessage(message: string, sig: BtcMessageSignature, identity: string): VerifyResultRecovers 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) // truefunction verifyBtcPsbt(signedPsbtHex: string): VerifyResultVerifies 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 signing is a three-phase exchange with the device
(CLA 0xF0).
| 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 |
num_paths=0: detected automatically from
BIP32_DERIVATION inside the PSBTnum_paths>0: the path for each input is given
explicitly
([1B input_index][1B path_len][4B * path_len elements],
each element big-endian, hardened is 0x80000000)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.
| 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).
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).