From 1ecac341d5ced04e59bfdcd432a9bce84bedd959 Mon Sep 17 00:00:00 2001 From: Shook Date: Fri, 7 Jun 2024 13:58:25 +0800 Subject: [PATCH 1/3] feat: support including multi-origin UTXOs in the same transaction --- .changeset/orange-mice-trade.md | 9 +++ packages/btc/src/address.ts | 15 ++++ packages/btc/src/api/sendRbf.ts | 16 +--- packages/btc/src/api/sendRgbppUtxos.ts | 21 ++--- packages/btc/src/api/sendUtxos.ts | 24 ++++-- packages/btc/src/transaction/build.ts | 24 +++--- packages/btc/src/transaction/fee.ts | 108 +++++++++++++++++-------- packages/btc/src/transaction/utxo.ts | 66 ++++++++++++++- packages/btc/tests/Transaction.test.ts | 105 ++++++++++++++++++++++++ packages/btc/tests/shared/utils.ts | 60 ++++++++++---- 10 files changed, 346 insertions(+), 102 deletions(-) create mode 100644 .changeset/orange-mice-trade.md 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(); From af42947ecbbc305953933ce1184e76bde246efb6 Mon Sep 17 00:00:00 2001 From: Shook Date: Tue, 18 Jun 2024 11:06:57 +0800 Subject: [PATCH 2/3] refactor: add a type proxy AddressToPubkeyMap of the pubkeyMap prop in Builder APIs --- packages/btc/src/address.ts | 9 ++++++++- packages/btc/src/api/sendRbf.ts | 3 ++- packages/btc/src/api/sendRgbppUtxos.ts | 3 ++- packages/btc/src/api/sendUtxos.ts | 4 ++-- packages/btc/src/transaction/utxo.ts | 6 +++--- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/btc/src/address.ts b/packages/btc/src/address.ts index 659d2ebd..311b5c3b 100644 --- a/packages/btc/src/address.ts +++ b/packages/btc/src/address.ts @@ -14,6 +14,13 @@ export enum AddressType { UNKNOWN, } +/** + * Type: Record + * + * The map of address and pubkey, usually for recognizing the P2TR inputs in the transaction. + */ +export type AddressToPubkeyMap = Record; + /** * Check weather the address is supported as a from address. * Currently, only P2WPKH and P2TR addresses are supported. @@ -215,7 +222,7 @@ function getAddressTypeDust(addressType: AddressType) { * Add address/pubkey pair to a Record map */ export function addAddressToPubkeyMap( - pubkeyMap: Record, + pubkeyMap: AddressToPubkeyMap, address: string, pubkey?: string, ): Record { diff --git a/packages/btc/src/api/sendRbf.ts b/packages/btc/src/api/sendRbf.ts index 0e84e16b..e629a49c 100644 --- a/packages/btc/src/api/sendRbf.ts +++ b/packages/btc/src/api/sendRbf.ts @@ -1,5 +1,6 @@ import { BaseOutput, Utxo } from '../transaction/utxo'; import { DataSource } from '../query/source'; +import { AddressToPubkeyMap } from '../address'; import { ErrorCodes, TxBuildError } from '../error'; import { InitOutput, TxBuilder } from '../transaction/build'; import { isOpReturnScriptPubkey } from '../transaction/embed'; @@ -22,7 +23,7 @@ export interface SendRbfProps { requireGreaterFeeAndRate?: boolean; // EXPERIMENTAL: the below props are unstable and can be altered at any time - pubkeyMap?: Record; // Record + pubkeyMap?: AddressToPubkeyMap; } export async function createSendRbfBuilder(props: SendRbfProps): Promise<{ diff --git a/packages/btc/src/api/sendRgbppUtxos.ts b/packages/btc/src/api/sendRgbppUtxos.ts index f793e5b0..f75f018e 100644 --- a/packages/btc/src/api/sendRgbppUtxos.ts +++ b/packages/btc/src/api/sendRgbppUtxos.ts @@ -2,6 +2,7 @@ import { Collector, checkCkbTxInputsCapacitySufficient } from '@rgbpp-sdk/ckb'; import { isRgbppLockCell, isBtcTimeLockCell, calculateCommitment } from '@rgbpp-sdk/ckb'; import { bitcoin } from '../bitcoin'; import { BaseOutput, Utxo } from '../transaction/utxo'; +import { AddressToPubkeyMap } from '../address'; import { DataSource } from '../query/source'; import { NetworkType } from '../preset/types'; import { ErrorCodes, TxBuildError } from '../error'; @@ -31,7 +32,7 @@ export interface SendRgbppUtxosProps { excludeUtxos?: BaseOutput[]; // EXPERIMENTAL: the below props are unstable and can be altered at any time - pubkeyMap?: Record; // Record + pubkeyMap?: AddressToPubkeyMap; } /** diff --git a/packages/btc/src/api/sendUtxos.ts b/packages/btc/src/api/sendUtxos.ts index ad5f44e8..4e225d93 100644 --- a/packages/btc/src/api/sendUtxos.ts +++ b/packages/btc/src/api/sendUtxos.ts @@ -2,7 +2,7 @@ import { bitcoin } from '../bitcoin'; import { DataSource } from '../query/source'; import { TxBuilder, InitOutput } from '../transaction/build'; import { BaseOutput, Utxo, prepareUtxoInputs } from '../transaction/utxo'; -import { addAddressToPubkeyMap } from '../address'; +import { AddressToPubkeyMap, addAddressToPubkeyMap } from '../address'; export interface SendUtxosProps { inputs: Utxo[]; @@ -18,7 +18,7 @@ export interface SendUtxosProps { // EXPERIMENTAL: the below props are unstable and can be altered at any time skipInputsValidation?: boolean; - pubkeyMap?: Record; // Record + pubkeyMap?: AddressToPubkeyMap; } export async function createSendUtxosBuilder(props: SendUtxosProps): Promise<{ diff --git a/packages/btc/src/transaction/utxo.ts b/packages/btc/src/transaction/utxo.ts index d0af8c18..7efae0f0 100644 --- a/packages/btc/src/transaction/utxo.ts +++ b/packages/btc/src/transaction/utxo.ts @@ -1,7 +1,7 @@ import cloneDeep from 'lodash/cloneDeep'; import { ErrorCodes, TxBuildError } from '../error'; import { DataSource } from '../query/source'; -import { AddressType } from '../address'; +import { AddressType, AddressToPubkeyMap } from '../address'; import { TxInput } from './build'; import { limitPromiseBatchSize, remove0x, toXOnly } from '../utils'; import { isP2trScript } from '../script'; @@ -65,7 +65,7 @@ export function utxoToInput(utxo: Utxo): TxInput { */ export function fillUtxoPubkey( utxo: Utxo, - pubkeyMap: Record, // Record + pubkeyMap: AddressToPubkeyMap, options?: { requirePubkey?: boolean; }, @@ -94,7 +94,7 @@ export async function prepareUtxoInputs(props: { source: DataSource; requirePubkey?: boolean; requireConfirmed?: boolean; - pubkeyMap?: Record; // Record + pubkeyMap?: AddressToPubkeyMap; }): Promise { const pubkeyMap = props.pubkeyMap ?? {}; const utxos = props.utxos.map((utxo) => { From 2e61e730894fd8d1ff7a254a8547494c8f5269ae Mon Sep 17 00:00:00 2001 From: Shook Date: Tue, 18 Jun 2024 11:24:20 +0800 Subject: [PATCH 3/3] docs: update btc README for pubkeyMap --- packages/btc/README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/btc/README.md b/packages/btc/README.md index 351f565b..f06877f8 100644 --- a/packages/btc/README.md +++ b/packages/btc/README.md @@ -312,6 +312,7 @@ interface SendUtxosProps { // EXPERIMENTAL: the below props are unstable and can be altered at any time skipInputsValidation?: boolean; + pubkeyMap?: AddressToPubkeyMap; } ``` @@ -352,7 +353,7 @@ interface SendRgbppUtxosProps { excludeUtxos?: BaseOutput[]; // EXPERIMENTAL: the below props are experimental and can be altered at any time - onlyProvableUtxos?: boolean; + pubkeyMap?: AddressToPubkeyMap; } ``` @@ -386,7 +387,7 @@ interface SendRbfProps { requireGreaterFeeAndRate?: boolean; // EXPERIMENTAL: the below props are experimental and can be altered at any time - inputsPubkey?: Record; // Record + pubkeyMap?: AddressToPubkeyMap; } ``` @@ -513,3 +514,9 @@ enum NetworkType { REGTEST, } ``` + +#### AddressToPubkeyMap + +```typescript +type AddressToPubkeyMap = Record; +```