diff --git a/.changeset/orange-mice-trade.md b/.changeset/orange-mice-trade.md
new file mode 100644
index 00000000..2ac305a4
--- /dev/null
+++ b/.changeset/orange-mice-trade.md
@@ -0,0 +1,9 @@
+---
+"@rgbpp-sdk/btc": minor
+---
+
+Support including multi-origin UTXOs in the same transaction
+
+ - Add `pubkeyMap` option in the sendUtxos(), sendRgbppUtxos() and sendRbf() API
+ - Rename `inputsPubkey` option to `pubkeyMap` in the sendRbf() API
+ - Delete `onlyProvableUtxos` option from the sendRgbppUtxos() API
diff --git a/packages/btc/src/address.ts b/packages/btc/src/address.ts
index fa562d45..659d2ebd 100644
--- a/packages/btc/src/address.ts
+++ b/packages/btc/src/address.ts
@@ -210,3 +210,18 @@ function getAddressTypeDust(addressType: AddressType) {
return 546;
}
}
+
+/**
+ * Add address/pubkey pair to a Record
map
+ */
+export function addAddressToPubkeyMap(
+ pubkeyMap: Record,
+ address: string,
+ pubkey?: string,
+): Record {
+ const newMap = { ...pubkeyMap };
+ if (pubkey) {
+ newMap[address] = pubkey;
+ }
+ return newMap;
+}
diff --git a/packages/btc/src/api/sendRbf.ts b/packages/btc/src/api/sendRbf.ts
index 466ee1b5..0e84e16b 100644
--- a/packages/btc/src/api/sendRbf.ts
+++ b/packages/btc/src/api/sendRbf.ts
@@ -6,7 +6,6 @@ import { isOpReturnScriptPubkey } from '../transaction/embed';
import { networkTypeToNetwork } from '../preset/network';
import { networkTypeToConfig } from '../preset/config';
import { createSendUtxosBuilder } from './sendUtxos';
-import { isP2trScript } from '../script';
import { bitcoin } from '../bitcoin';
export interface SendRbfProps {
@@ -23,7 +22,7 @@ export interface SendRbfProps {
requireGreaterFeeAndRate?: boolean;
// EXPERIMENTAL: the below props are unstable and can be altered at any time
- inputsPubkey?: Record; // Record
+ pubkeyMap?: Record; // Record
}
export async function createSendRbfBuilder(props: SendRbfProps): Promise<{
@@ -43,18 +42,6 @@ export async function createSendRbfBuilder(props: SendRbfProps): Promise<{
if (!utxo) {
throw TxBuildError.withComment(ErrorCodes.CANNOT_FIND_UTXO, `hash: ${hash}, index: ${input.index}`);
}
-
- // Ensure each P2TR input has a corresponding pubkey
- const fromPubkey = utxo.address === props.from ? props.fromPubkey : undefined;
- const inputPubkey = props.inputsPubkey?.[utxo.address];
- const pubkey = inputPubkey ?? fromPubkey;
- if (pubkey) {
- utxo.pubkey = pubkey;
- }
- if (isP2trScript(utxo.scriptPk) && !utxo.pubkey) {
- throw TxBuildError.withComment(ErrorCodes.MISSING_PUBKEY, utxo.address);
- }
-
inputs.push(utxo);
}
@@ -151,6 +138,7 @@ export async function createSendRbfBuilder(props: SendRbfProps): Promise<{
from: props.from,
source: props.source,
feeRate: props.feeRate,
+ pubkeyMap: props.pubkeyMap,
fromPubkey: props.fromPubkey,
minUtxoSatoshi: props.minUtxoSatoshi,
onlyConfirmedUtxos: props.onlyConfirmedUtxos ?? true,
diff --git a/packages/btc/src/api/sendRgbppUtxos.ts b/packages/btc/src/api/sendRgbppUtxos.ts
index 69b3ab8e..f793e5b0 100644
--- a/packages/btc/src/api/sendRgbppUtxos.ts
+++ b/packages/btc/src/api/sendRgbppUtxos.ts
@@ -31,7 +31,7 @@ export interface SendRgbppUtxosProps {
excludeUtxos?: BaseOutput[];
// EXPERIMENTAL: the below props are unstable and can be altered at any time
- onlyProvableUtxos?: boolean;
+ pubkeyMap?: Record; // Record
}
/**
@@ -45,8 +45,6 @@ export async function createSendRgbppUtxosBuilder(props: SendRgbppUtxosProps): P
feeRate: number;
changeIndex: number;
}> {
- const onlyProvableUtxos = props.onlyProvableUtxos ?? true;
-
const btcInputs: Utxo[] = [];
const btcOutputs: InitOutput[] = [];
let lastCkbTypeOutputIndex = -1;
@@ -86,33 +84,23 @@ export async function createSendRgbppUtxosBuilder(props: SendRgbppUtxosProps): P
for (let i = 0; i < ckbVirtualTx.inputs.length; i++) {
const { lockArgs, isRgbppLock } = ckbLiveCells[i];
- // If input.lock == RgbppLock, add to inputs if:
+ // Add to inputs if all the following conditions are met:
// 1. input.lock.args can be unpacked to RgbppLockArgs
// 2. utxo can be found via the DataSource.getUtxo() API
- // 3. utxo.scriptPk == addressToScriptPk(props.from)
- // 4. utxo is not duplicated in the inputs
+ // 3. utxo is not duplicated in the inputs
if (isRgbppLock) {
const args = lockArgs!;
const utxo = btcUtxos[i];
if (!utxo) {
throw TxBuildError.withComment(ErrorCodes.CANNOT_FIND_UTXO, `hash: ${args.btcTxid}, index: ${args.outIndex}`);
}
- if (onlyProvableUtxos && utxo.address !== props.from) {
- throw TxBuildError.withComment(
- ErrorCodes.REFERENCED_UNPROVABLE_UTXO,
- `hash: ${args.btcTxid}, index: ${args.outIndex}`,
- );
- }
const foundInInputs = btcInputs.some((v) => v.txid === utxo.txid && v.vout === utxo.vout);
if (foundInInputs) {
continue;
}
- btcInputs.push({
- ...utxo,
- pubkey: props.fromPubkey, // For P2TR addresses, a pubkey is required
- });
+ btcInputs.push(utxo);
}
}
@@ -179,6 +167,7 @@ export async function createSendRgbppUtxosBuilder(props: SendRgbppUtxosProps): P
minUtxoSatoshi: props.minUtxoSatoshi,
onlyConfirmedUtxos: props.onlyConfirmedUtxos,
excludeUtxos: props.excludeUtxos,
+ pubkeyMap: props.pubkeyMap,
});
}
diff --git a/packages/btc/src/api/sendUtxos.ts b/packages/btc/src/api/sendUtxos.ts
index 5f6abacf..ad5f44e8 100644
--- a/packages/btc/src/api/sendUtxos.ts
+++ b/packages/btc/src/api/sendUtxos.ts
@@ -1,7 +1,8 @@
import { bitcoin } from '../bitcoin';
import { DataSource } from '../query/source';
-import { BaseOutput, Utxo } from '../transaction/utxo';
import { TxBuilder, InitOutput } from '../transaction/build';
+import { BaseOutput, Utxo, prepareUtxoInputs } from '../transaction/utxo';
+import { addAddressToPubkeyMap } from '../address';
export interface SendUtxosProps {
inputs: Utxo[];
@@ -17,6 +18,7 @@ export interface SendUtxosProps {
// EXPERIMENTAL: the below props are unstable and can be altered at any time
skipInputsValidation?: boolean;
+ pubkeyMap?: Record; // Record
}
export async function createSendUtxosBuilder(props: SendUtxosProps): Promise<{
@@ -32,16 +34,24 @@ export async function createSendUtxosBuilder(props: SendUtxosProps): Promise<{
onlyConfirmedUtxos: props.onlyConfirmedUtxos,
});
- tx.addInputs(props.inputs);
- tx.addOutputs(props.outputs);
+ // Prepare the UTXO inputs:
+ // 1. Fill pubkey for each P2TR UTXO, and throw if the corresponding pubkey is not found
+ // 2. Throw if unconfirmed UTXOs are found (if onlyConfirmedUtxos == true && skipInputsValidation == false)
+ const pubkeyMap = addAddressToPubkeyMap(props.pubkeyMap ?? {}, props.from, props.fromPubkey);
+ const inputs = await prepareUtxoInputs({
+ utxos: props.inputs,
+ source: props.source,
+ requireConfirmed: props.onlyConfirmedUtxos && !props.skipInputsValidation,
+ requirePubkey: true,
+ pubkeyMap,
+ });
- if (props.onlyConfirmedUtxos && !props.skipInputsValidation) {
- await tx.validateInputs();
- }
+ tx.addInputs(inputs);
+ tx.addOutputs(props.outputs);
const paid = await tx.payFee({
address: props.from,
- publicKey: props.fromPubkey,
+ publicKey: pubkeyMap[props.from],
changeAddress: props.changeAddress,
excludeUtxos: props.excludeUtxos,
});
diff --git a/packages/btc/src/transaction/build.ts b/packages/btc/src/transaction/build.ts
index a5c84082..7407fb59 100644
--- a/packages/btc/src/transaction/build.ts
+++ b/packages/btc/src/transaction/build.ts
@@ -3,7 +3,7 @@ import { bitcoin } from '../bitcoin';
import { DataSource } from '../query/source';
import { ErrorCodes, TxBuildError } from '../error';
import { NetworkType, RgbppBtcConfig } from '../preset/types';
-import { AddressType, addressToScriptPublicKeyHex, getAddressType, isSupportedFromAddress } from '../address';
+import { isSupportedFromAddress } from '../address';
import { dataToOpReturnScriptPubkey, isOpReturnScriptPubkey } from './embed';
import { networkTypeToConfig } from '../preset/config';
import { BaseOutput, Utxo, utxoToInput } from './utxo';
@@ -213,8 +213,7 @@ export class TxBuilder {
}
// Calculate network fee
- const addressType = getAddressType(address);
- currentFee = await this.calculateFee(addressType, currentFeeRate);
+ currentFee = await this.calculateFee(currentFeeRate);
// If (fee = previousFee ±1), the fee is considered acceptable/expected.
isFeeExpected = [-1, 0, 1].includes(currentFee - previousFee);
@@ -471,14 +470,14 @@ export class TxBuilder {
});
}
- async calculateFee(addressType: AddressType, feeRate?: number): Promise {
+ async calculateFee(feeRate?: number): Promise {
if (!feeRate && !this.feeRate) {
throw TxBuildError.withComment(ErrorCodes.INVALID_FEE_RATE, `${feeRate ?? this.feeRate}`);
}
const currentFeeRate = feeRate ?? this.feeRate!;
- const psbt = await this.createEstimatedPsbt(addressType);
+ const psbt = await this.createEstimatedPsbt();
const tx = psbt.extractTransaction(true);
const inputs = tx.ins.length;
@@ -490,20 +489,17 @@ export class TxBuilder {
return Math.ceil(virtualSize * currentFeeRate);
}
- async createEstimatedPsbt(addressType: AddressType): Promise {
- const estimate = FeeEstimator.fromRandom(addressType, this.networkType);
- const estimateScriptPk = addressToScriptPublicKeyHex(estimate.address, this.networkType);
+ async createEstimatedPsbt(): Promise {
+ const estimator = FeeEstimator.fromRandom(this.networkType);
const tx = this.clone();
- const utxos = tx.inputs.map((input) => input.utxo);
- tx.inputs = utxos.map((utxo) => {
- utxo.scriptPk = estimateScriptPk;
- utxo.pubkey = estimate.publicKey;
- return utxoToInput(utxo);
+ tx.inputs = tx.inputs.map((input) => {
+ const replacedUtxo = estimator.replaceUtxo(input.utxo);
+ return utxoToInput(replacedUtxo);
});
const psbt = tx.toPsbt();
- await estimate.signPsbt(psbt);
+ await estimator.signPsbt(psbt);
return psbt;
}
diff --git a/packages/btc/src/transaction/fee.ts b/packages/btc/src/transaction/fee.ts
index 110f1d8e..3653ef85 100644
--- a/packages/btc/src/transaction/fee.ts
+++ b/packages/btc/src/transaction/fee.ts
@@ -1,61 +1,105 @@
import { ECPairInterface } from 'ecpair';
+import { AddressType } from '../address';
import { NetworkType } from '../preset/types';
+import { toXOnly, tweakSigner } from '../utils';
import { networkTypeToNetwork } from '../preset/network';
-import { AddressType, publicKeyToAddress } from '../address';
+import { isP2trScript, isP2wpkhScript } from '../script';
import { bitcoin, ECPair, isTaprootInput } from '../bitcoin';
-import { toXOnly, tweakSigner } from '../utils';
+import { Utxo } from './utxo';
+
+interface FeeEstimateAccount {
+ payment: bitcoin.Payment;
+ addressType: AddressType;
+ address: string;
+ scriptPubkey: string;
+ tapInternalKey?: Buffer;
+}
export class FeeEstimator {
public networkType: NetworkType;
- public addressType: AddressType;
public network: bitcoin.Network;
private readonly keyPair: ECPairInterface;
- public publicKey: string;
- public address: string;
+ public readonly pubkey: string;
+ public accounts: {
+ p2wpkh: FeeEstimateAccount;
+ p2tr: FeeEstimateAccount;
+ };
- constructor(wif: string, networkType: NetworkType, addressType: AddressType) {
+ constructor(wif: string, networkType: NetworkType) {
const network = networkTypeToNetwork(networkType);
- const keyPair = ECPair.fromWIF(wif, network);
+ this.networkType = networkType;
+ this.network = network;
+ const keyPair = ECPair.fromWIF(wif, network);
+ this.pubkey = keyPair.publicKey.toString('hex');
this.keyPair = keyPair;
- this.publicKey = keyPair.publicKey.toString('hex');
- this.address = publicKeyToAddress(this.publicKey, addressType, networkType);
- this.addressType = addressType;
- this.networkType = networkType;
- this.network = network;
+ const p2wpkh = bitcoin.payments.p2wpkh({
+ pubkey: keyPair.publicKey,
+ network,
+ });
+ const p2tr = bitcoin.payments.p2tr({
+ internalPubkey: toXOnly(keyPair.publicKey),
+ network,
+ });
+ this.accounts = {
+ p2wpkh: {
+ payment: p2wpkh,
+ address: p2wpkh.address!,
+ addressType: AddressType.P2WPKH,
+ scriptPubkey: p2wpkh.output!.toString('hex'),
+ },
+ p2tr: {
+ payment: p2tr,
+ address: p2tr.address!,
+ addressType: AddressType.P2TR,
+ tapInternalKey: toXOnly(keyPair.publicKey),
+ scriptPubkey: p2tr.output!.toString('hex'),
+ },
+ };
}
- static fromRandom(addressType: AddressType, networkType: NetworkType) {
+ static fromRandom(networkType: NetworkType) {
const network = networkTypeToNetwork(networkType);
const keyPair = ECPair.makeRandom({ network });
- return new FeeEstimator(keyPair.toWIF(), networkType, addressType);
+ return new FeeEstimator(keyPair.toWIF(), networkType);
+ }
+
+ replaceUtxo(utxo: Utxo): Utxo {
+ if (utxo.addressType === AddressType.P2WPKH || isP2wpkhScript(utxo.scriptPk)) {
+ utxo.scriptPk = this.accounts.p2wpkh.scriptPubkey;
+ utxo.pubkey = this.pubkey;
+ }
+ if (utxo.addressType === AddressType.P2TR || isP2trScript(utxo.scriptPk)) {
+ utxo.scriptPk = this.accounts.p2tr.scriptPubkey;
+ utxo.pubkey = this.pubkey;
+ }
+
+ return utxo;
}
async signPsbt(psbt: bitcoin.Psbt): Promise {
- psbt.data.inputs.forEach((v) => {
- const isNotSigned = !(v.finalScriptSig || v.finalScriptWitness);
- const isP2TR = this.addressType === AddressType.P2TR;
- const lostInternalPubkey = !v.tapInternalKey;
- // Special measures taken for compatibility with certain applications.
- if (isNotSigned && isP2TR && lostInternalPubkey) {
- const tapInternalKey = toXOnly(Buffer.from(this.publicKey, 'hex'));
- const { output } = bitcoin.payments.p2tr({
- internalPubkey: tapInternalKey,
- network: networkTypeToNetwork(this.networkType),
- });
- if (v.witnessUtxo?.script.toString('hex') == output?.toString('hex')) {
- v.tapInternalKey = tapInternalKey;
- }
- }
+ // Tweak signer for P2TR inputs
+ const tweakedSigner = tweakSigner(this.keyPair, {
+ network: this.network,
});
psbt.data.inputs.forEach((input, index) => {
+ // Fill tapInternalKey for P2TR inputs if missing
+ if (input.witnessUtxo) {
+ const isNotSigned = !(input.finalScriptSig || input.finalScriptWitness);
+ const isP2trInput = isP2trScript(input.witnessUtxo.script);
+ const lostInternalPubkey = !input.tapInternalKey;
+ if (isNotSigned && isP2trInput && lostInternalPubkey) {
+ if (input.witnessUtxo.script.toString('hex') === this.accounts.p2tr.scriptPubkey) {
+ input.tapInternalKey = this.accounts.p2tr.tapInternalKey!;
+ }
+ }
+ }
+
+ // Sign P2WPKH/P2TR inputs
if (isTaprootInput(input)) {
- const tweakedSigner = tweakSigner(this.keyPair, {
- network: this.network,
- });
psbt.signInput(index, tweakedSigner);
} else {
psbt.signInput(index, this.keyPair);
diff --git a/packages/btc/src/transaction/utxo.ts b/packages/btc/src/transaction/utxo.ts
index a5076e14..d0af8c18 100644
--- a/packages/btc/src/transaction/utxo.ts
+++ b/packages/btc/src/transaction/utxo.ts
@@ -1,7 +1,10 @@
+import cloneDeep from 'lodash/cloneDeep';
import { ErrorCodes, TxBuildError } from '../error';
+import { DataSource } from '../query/source';
import { AddressType } from '../address';
import { TxInput } from './build';
-import { remove0x, toXOnly } from '../utils';
+import { limitPromiseBatchSize, remove0x, toXOnly } from '../utils';
+import { isP2trScript } from '../script';
export interface BaseOutput {
txid: string;
@@ -37,7 +40,7 @@ export function utxoToInput(utxo: Utxo): TxInput {
}
if (utxo.addressType === AddressType.P2TR) {
if (!utxo.pubkey) {
- throw new TxBuildError(ErrorCodes.MISSING_PUBKEY);
+ throw TxBuildError.withComment(ErrorCodes.MISSING_PUBKEY, utxo.address);
}
const data = {
hash: utxo.txid,
@@ -56,3 +59,62 @@ export function utxoToInput(utxo: Utxo): TxInput {
throw new TxBuildError(ErrorCodes.UNSUPPORTED_ADDRESS_TYPE);
}
+
+/**
+ * Fill pubkey for P2TR UTXO, and optionally throw an error if pubkey is missing
+ */
+export function fillUtxoPubkey(
+ utxo: Utxo,
+ pubkeyMap: Record, // Record
+ options?: {
+ requirePubkey?: boolean;
+ },
+): Utxo {
+ const newUtxo = cloneDeep(utxo);
+ if (isP2trScript(newUtxo.scriptPk) && !newUtxo.pubkey) {
+ const pubkey = pubkeyMap[newUtxo.address];
+ if (options?.requirePubkey && !pubkey) {
+ throw TxBuildError.withComment(ErrorCodes.MISSING_PUBKEY, newUtxo.address);
+ }
+ if (pubkey) {
+ newUtxo.pubkey = pubkey;
+ }
+ }
+
+ return newUtxo;
+}
+
+/**
+ * Prepare and validate UTXOs for transaction building:
+ * 1. Fill pubkey for P2TR UTXOs, and optionally throw an error if pubkey is missing
+ * 2. Optionally check if the UTXOs are confirmed, and throw an error if not
+ */
+export async function prepareUtxoInputs(props: {
+ utxos: Utxo[];
+ source: DataSource;
+ requirePubkey?: boolean;
+ requireConfirmed?: boolean;
+ pubkeyMap?: Record; // Record
+}): Promise {
+ const pubkeyMap = props.pubkeyMap ?? {};
+ const utxos = props.utxos.map((utxo) => {
+ return fillUtxoPubkey(utxo, pubkeyMap, {
+ requirePubkey: props.requirePubkey,
+ });
+ });
+
+ if (props.requireConfirmed) {
+ await Promise.all(
+ utxos.map(async (utxo) => {
+ return limitPromiseBatchSize(async () => {
+ const transactionConfirmed = await props.source.isTransactionConfirmed(utxo.txid);
+ if (!transactionConfirmed) {
+ throw TxBuildError.withComment(ErrorCodes.UNCONFIRMED_UTXO, `hash: ${utxo.txid}, index: ${utxo.vout}`);
+ }
+ });
+ }),
+ );
+ }
+
+ return utxos;
+}
diff --git a/packages/btc/tests/Transaction.test.ts b/packages/btc/tests/Transaction.test.ts
index 7c7f8e18..b4fcd4c1 100644
--- a/packages/btc/tests/Transaction.test.ts
+++ b/packages/btc/tests/Transaction.test.ts
@@ -857,6 +857,111 @@ describe('Transaction', () => {
// console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`);
});
+ it('Transfer P2TR, pay fee with P2WPKH', async () => {
+ const p2trUtxos = await source.getUtxos(accounts.charlie.p2tr.address, {
+ min_satoshi: BTC_UTXO_DUST_LIMIT,
+ only_confirmed: true,
+ });
+
+ const psbt = await sendUtxos({
+ inputs: [p2trUtxos[0]],
+ outputs: [
+ {
+ address: accounts.charlie.p2tr.address,
+ value: p2trUtxos[0].value,
+ fixed: true,
+ },
+ ],
+ from: accounts.charlie.p2wpkh.address,
+ feeRate: STATIC_FEE_RATE,
+ pubkeyMap: {
+ [accounts.charlie.p2tr.address]: accounts.charlie.publicKey,
+ },
+ source,
+ });
+
+ // Sign & finalize inputs
+ await signAndBroadcastPsbt({
+ psbt,
+ account: accounts.charlie,
+ feeRate: STATIC_FEE_RATE,
+ send: false,
+ });
+ });
+ it('Transfer P2WPKH, pay fee with P2TR', async () => {
+ const p2wpkhUtxos = await source.getUtxos(accounts.charlie.p2wpkh.address, {
+ min_satoshi: BTC_UTXO_DUST_LIMIT,
+ only_confirmed: true,
+ });
+
+ const psbt = await sendUtxos({
+ inputs: [p2wpkhUtxos[0]],
+ outputs: [
+ {
+ address: accounts.charlie.p2wpkh.address,
+ value: p2wpkhUtxos[0].value,
+ fixed: true,
+ },
+ ],
+ from: accounts.charlie.p2tr.address,
+ fromPubkey: accounts.charlie.publicKey,
+ feeRate: STATIC_FEE_RATE,
+ source,
+ });
+
+ // Sign & finalize inputs
+ await signAndBroadcastPsbt({
+ psbt,
+ account: accounts.charlie,
+ feeRate: STATIC_FEE_RATE,
+ send: false,
+ });
+ });
+ it('Try mixed transfer, without pubkeyMap', async () => {
+ const p2trUtxos = await source.getUtxos(accounts.charlie.p2tr.address, {
+ min_satoshi: BTC_UTXO_DUST_LIMIT,
+ only_confirmed: true,
+ });
+
+ await expect(() =>
+ sendUtxos({
+ inputs: [p2trUtxos[0]],
+ outputs: [
+ {
+ address: accounts.charlie.p2tr.address,
+ value: p2trUtxos[0].value,
+ fixed: true,
+ },
+ ],
+ from: accounts.charlie.p2wpkh.address,
+ feeRate: STATIC_FEE_RATE,
+ source,
+ }),
+ ).rejects.toHaveProperty('code', ErrorCodes.MISSING_PUBKEY);
+ });
+ it('Try mixed transfer, pay fee with P2TR without fromPubkey', async () => {
+ const p2wpkhUtxos = await source.getUtxos(accounts.charlie.p2wpkh.address, {
+ min_satoshi: BTC_UTXO_DUST_LIMIT,
+ only_confirmed: true,
+ });
+
+ await expect(() =>
+ sendUtxos({
+ inputs: [p2wpkhUtxos[0]],
+ outputs: [
+ {
+ address: accounts.charlie.p2wpkh.address,
+ value: p2wpkhUtxos[0].value,
+ fixed: true,
+ },
+ ],
+ from: accounts.charlie.p2tr.address,
+ feeRate: STATIC_FEE_RATE,
+ source,
+ }),
+ ).rejects.toHaveProperty('code', ErrorCodes.MISSING_PUBKEY);
+ });
+
it('Try transfer non-existence UTXO', async () => {
await expect(() =>
sendUtxos({
diff --git a/packages/btc/tests/shared/utils.ts b/packages/btc/tests/shared/utils.ts
index b8598ca5..4efdb08d 100644
--- a/packages/btc/tests/shared/utils.ts
+++ b/packages/btc/tests/shared/utils.ts
@@ -109,11 +109,47 @@ export function createAccount(props: { privateKey: string; network?: bitcoin.Net
}
/**
- * Sign and broadcast a transaction to the service
+ * Sign a PSBT with one or multiple BtcAccounts
+ */
+export function signPsbt(props: {
+ psbt: bitcoin.Psbt;
+ account: Account | Account[];
+ finalizeInputs?: boolean;
+}): bitcoin.Psbt {
+ const accounts = Array.isArray(props.account) ? props.account : [props.account];
+
+ const psbt = props.psbt;
+ for (const account of accounts) {
+ // Create a tweaked signer for P2TR
+ const tweakedSigner = tweakSigner(account.keyPair, { network });
+
+ // Sign each input
+ psbt.data.inputs.forEach((input, index) => {
+ if (input.witnessUtxo) {
+ const script = input.witnessUtxo.script.toString('hex');
+ if (isP2wpkhScript(script) && script === account.p2wpkh.scriptPubkey.toString('hex')) {
+ psbt.signInput(index, account.keyPair);
+ }
+ if (isP2trScript(script) && script === account.p2tr.scriptPubkey.toString('hex')) {
+ psbt.signInput(index, tweakedSigner);
+ }
+ }
+ });
+ }
+
+ if (props.finalizeInputs) {
+ psbt.finalizeAllInputs();
+ }
+
+ return psbt;
+}
+
+/**
+ * Sign and broadcast a PSBT to the service
*/
export async function signAndBroadcastPsbt(props: {
psbt: bitcoin.Psbt;
- account: Account;
+ account: Account | Account[];
feeRate?: number;
send?: boolean;
}): Promise<{
@@ -123,23 +159,13 @@ export async function signAndBroadcastPsbt(props: {
}> {
const { psbt, account, feeRate, send = true } = props;
- // Create a tweaked signer for P2TR
- const tweakedSigner = tweakSigner(account.keyPair, { network });
-
- // Sign each input
- psbt.data.inputs.forEach((input, index) => {
- if (input.witnessUtxo) {
- const script = input.witnessUtxo.script.toString('hex');
- if (isP2wpkhScript(script) && script === account.p2wpkh.scriptPubkey.toString('hex')) {
- psbt.signInput(index, account.keyPair);
- }
- if (isP2trScript(script) && script === account.p2tr.scriptPubkey.toString('hex')) {
- psbt.signInput(index, tweakedSigner);
- }
- }
+ // Sign inputs
+ signPsbt({
+ psbt,
+ account,
+ finalizeInputs: true,
});
- psbt.finalizeAllInputs();
expectPsbtFeeInRange(psbt, feeRate);
const tx = psbt.extractTransaction();