diff --git a/.changeset/spicy-cups-cheat.md b/.changeset/spicy-cups-cheat.md new file mode 100644 index 00000000..6aaebfb3 --- /dev/null +++ b/.changeset/spicy-cups-cheat.md @@ -0,0 +1,9 @@ +--- +"@rgbpp-sdk/btc": minor +--- + +Support Full-RBF feature with the sendRbf() and createSendRbfBuilder() API + + - Add `excludeUtxos`, `skipInputsValidation` options in the `sendUtxos()` API to support the RBF feature + - Add `onlyProvableUtxos` option in the `sendRgbppUtxos()` API for future update supports + - Add `changeIndex` in the return type of the BTC Builder APIs diff --git a/packages/btc/README.md b/packages/btc/README.md index e92232f7..351f565b 100644 --- a/packages/btc/README.md +++ b/packages/btc/README.md @@ -221,10 +221,35 @@ const psbt = await sendRgbppUtxos({ minUtxoSatoshi: config.btcUtxoDustLimit, // optional, default to 1000 on the testnet, 1,0000 on the mainnet rgbppMinUtxoSatoshi: config.rgbppUtxoDustLimit, // optional, default to 546 on both testnet/mainnet onlyConfirmedUtxos: false, // optional, default to false, only confirmed utxos are allowed in the transaction + onlyProvableUtxos: true, // optional, default to true, only utxos that satisfy (utxo.address == from) are allowed feeRate: 1, // optional, default to 1 on the testnet, and it is a floating number on the mainnet }); ``` +### Construct a Full-RBF transaction + +```typescript +import { sendRbf, networkTypeToConfig, DataSource, Collector, NetworkType } from '@rgbpp-sdk/btc'; +import { BtcAssetsApi } from '@rgbpp-sdk/service'; + +const networkType = NetworkType.TESTNET; +const config = networkTypeToConfig(networkType); + +const service = BtcAssetsApi.fromToken('btc_assets_api_url', 'your_token'); +const source = new DataSource(service, networkType); + +const psbt = await sendRbf({ + txHex: 'your_original_transaction_hex', + from: account.address, + feeRate: 40, // the feeRate should be greater than the feeRate of the original transaction + changeIndex: 1, // optional, return change to outputs[changeIndex], will create a new output if not specified + changeAddress: 'address_to_return_change', // optional, where should the change satoshi be returned to + requireValidOutputsValue: false, // optional, default to false, require each output's value to be >= minUtxoSatoshi + requireGreaterFeeAndRate: true, // optional, default to true, require the fee rate&amount to be greater than the original transction + source, +}); +``` + ## Types ### Transaction @@ -238,8 +263,9 @@ declare function sendBtc(props: SendBtcProps): Promise; ```typescript declare function createSendBtcBuilder(props: SendBtcProps): Promise<{ builder: TxBuilder; - feeRate: number; fee: number; + feeRate: number; + changeIndex: number; }>; ``` @@ -265,8 +291,9 @@ declare function sendUtxos(props: SendUtxosProps): Promise; ```typescript declare function createSendUtxosBuilder(props: SendUtxosProps): Promise<{ builder: TxBuilder; - feeRate: number; fee: number; + feeRate: number; + changeIndex: number; }>; ``` @@ -281,6 +308,10 @@ interface SendUtxosProps { changeAddress?: string; minUtxoSatoshi?: number; onlyConfirmedUtxos?: boolean; + excludeUtxos?: BaseOutput[]; + + // EXPERIMENTAL: the below props are unstable and can be altered at any time + skipInputsValidation?: boolean; } ``` @@ -293,8 +324,9 @@ declare function sendRgbppUtxos(props: SendRgbppUtxosProps): Promise; ``` @@ -317,6 +349,44 @@ interface SendRgbppUtxosProps { changeAddress?: string; minUtxoSatoshi?: number; onlyConfirmedUtxos?: boolean; + excludeUtxos?: BaseOutput[]; + + // EXPERIMENTAL: the below props are experimental and can be altered at any time + onlyProvableUtxos?: boolean; +} +``` + +#### sendRbf / createSendRbfBuilder / SendRbfProps + +```typescript +declare function sendRbf(props: SendRbfProps): Promise; +``` + +```typescript +declare function createSendRbfBuilder(props: SendRbfProps): Promise<{ + builder: TxBuilder; + fee: number; + feeRate: number; + changeIndex: number; +}>; +``` + +```typescript +interface SendRbfProps { + from: string; + txHex: string; + source: DataSource; + feeRate?: number; + fromPubkey?: string; + changeIndex?: number; + changeAddress?: string; + minUtxoSatoshi?: number; + onlyConfirmedUtxos?: boolean; + requireValidOutputsValue?: boolean; + requireGreaterFeeAndRate?: boolean; + + // EXPERIMENTAL: the below props are experimental and can be altered at any time + inputsPubkey?: Record; // Record } ``` @@ -329,27 +399,27 @@ type InitOutput = TxAddressOutput | TxDataOutput | TxScriptOutput; #### TxAddressOutput / TxDataOutput / TxScriptOutput ```typescript -interface TxAddressOutput extends BaseOutput { +interface TxAddressOutput extends TxBaseOutput { address: string; } ``` ```typescript -interface TxDataOutput extends BaseOutput { +interface TxDataOutput extends TxBaseOutput { data: Buffer | string; } ``` ```typescript -interface TxScriptOutput extends BaseOutput { +interface TxScriptOutput extends TxBaseOutput { script: Buffer; } ``` -#### BaseOutput +#### TxBaseOutput ```typescript -interface BaseOutput { +interface TxBaseOutput { value: number; fixed?: boolean; protected?: boolean; @@ -374,10 +444,7 @@ interface DataSource { onlyConfirmedUtxos?: boolean; noAssetsApiCache?: boolean; internalCacheKey?: string; - excludeUtxos?: { - txid: string; - vout: number; - }[]; + excludeUtxos?: BaseOutput[]; }): Promise<{ utxos: Utxo[]; satoshi: number; @@ -399,16 +466,23 @@ interface FeesRecommended { ### Basic -#### Utxo / Output +#### BaseOutput / Output / Utxo ```typescript -interface Output { +interface BaseOutput { txid: string; vout: number; +} +``` + +```typescript +interface Output extends BaseOutput { value: number; scriptPk: string; } +``` +```typescript interface Utxo extends Output { addressType: AddressType; address: string; diff --git a/packages/btc/src/api/sendBtc.ts b/packages/btc/src/api/sendBtc.ts index 09aae2bc..076f4f54 100644 --- a/packages/btc/src/api/sendBtc.ts +++ b/packages/btc/src/api/sendBtc.ts @@ -16,8 +16,9 @@ export interface SendBtcProps { export async function createSendBtcBuilder(props: SendBtcProps): Promise<{ builder: TxBuilder; - feeRate: number; fee: number; + feeRate: number; + changeIndex: number; }> { // By default, all outputs in the sendBtc() API are fixed const outputs = props.tos.map((to) => ({ diff --git a/packages/btc/src/api/sendRbf.ts b/packages/btc/src/api/sendRbf.ts new file mode 100644 index 00000000..466ee1b5 --- /dev/null +++ b/packages/btc/src/api/sendRbf.ts @@ -0,0 +1,174 @@ +import { BaseOutput, Utxo } from '../transaction/utxo'; +import { DataSource } from '../query/source'; +import { ErrorCodes, TxBuildError } from '../error'; +import { InitOutput, TxBuilder } from '../transaction/build'; +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 { + from: string; + txHex: string; + source: DataSource; + feeRate?: number; + fromPubkey?: string; + changeIndex?: number; + changeAddress?: string; + minUtxoSatoshi?: number; + onlyConfirmedUtxos?: boolean; + requireValidOutputsValue?: boolean; + requireGreaterFeeAndRate?: boolean; + + // EXPERIMENTAL: the below props are unstable and can be altered at any time + inputsPubkey?: Record; // Record +} + +export async function createSendRbfBuilder(props: SendRbfProps): Promise<{ + builder: TxBuilder; + fee: number; + feeRate: number; + changeIndex: number; +}> { + const previousTx = bitcoin.Transaction.fromHex(props.txHex); + const network = networkTypeToNetwork(props.source.networkType); + + // Rebuild inputs + const inputs: Utxo[] = []; + for (const input of previousTx.ins) { + const hash = Buffer.from(input.hash).reverse().toString('hex'); + const utxo = await props.source.getUtxo(hash, input.index); + 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); + } + + // Rebuild outputs + const requireValidOutputsValue = props.requireValidOutputsValue ?? false; + const outputs: InitOutput[] = previousTx.outs.map((output) => { + if (isOpReturnScriptPubkey(output.script)) { + return { + script: output.script, + value: output.value, + fixed: true, + }; + } else { + return { + minUtxoSatoshi: requireValidOutputsValue ? undefined : output.value, + address: bitcoin.address.fromOutputScript(output.script, network), + value: output.value, + fixed: true, + }; + } + }); + + // Set change output if specified + let changeAddress: string | undefined = props.changeAddress; + if (props.changeIndex !== undefined) { + const changeOutput = outputs[props.changeIndex]; + if (!changeOutput) { + throw TxBuildError.withComment(ErrorCodes.INVALID_CHANGE_OUTPUT, `outputs[${props.changeIndex}] is not found`); + } + const isReturnableOutput = changeOutput && 'address' in changeOutput; + if (!isReturnableOutput) { + throw TxBuildError.withComment( + ErrorCodes.INVALID_CHANGE_OUTPUT, + `outputs[${props.changeIndex}] is not a returnable output for change`, + ); + } + const changeOutputAddress = changeOutput.address; + if (changeOutputAddress && changeAddress && changeAddress !== changeOutputAddress) { + throw TxBuildError.withComment( + ErrorCodes.INVALID_CHANGE_OUTPUT, + `The address of outputs[${props.changeIndex}] does not match the specified changeAddress, expected: ${changeAddress}, actual: ${changeOutputAddress}`, + ); + } + if (changeOutputAddress && !changeAddress) { + changeAddress = changeOutputAddress; + } + const isLastOutput = outputs.length === props.changeIndex + 1; + if (isLastOutput) { + outputs.pop(); + } else { + const config = networkTypeToConfig(props.source.networkType); + const minUtxoSatoshi = props.minUtxoSatoshi ?? config.btcUtxoDustLimit; + changeOutput.minUtxoSatoshi = minUtxoSatoshi; + changeOutput.value = minUtxoSatoshi; + changeOutput.protected = true; + changeOutput.fixed = false; + } + } + + // Fee rate + const requireGreaterFeeAndRate = props.requireGreaterFeeAndRate ?? true; + let feeRate: number | undefined = props.feeRate; + if (requireGreaterFeeAndRate && !feeRate) { + const feeRates = await props.source.service.getBtcRecommendedFeeRates(); + feeRate = feeRates.fastestFee; + } + + // The RBF transaction should offer a higher fee rate + const previousInsValue = inputs.reduce((sum, input) => sum + input.value, 0); + const previousOutsValue = previousTx.outs.reduce((sum, output) => sum + output.value, 0); + const previousFee = previousInsValue - previousOutsValue; + const previousFeeRate = Math.floor(previousFee / previousTx.virtualSize()); + if (requireGreaterFeeAndRate && feeRate !== undefined && feeRate <= previousFeeRate) { + throw TxBuildError.withComment( + ErrorCodes.INVALID_FEE_RATE, + `RBF should offer a higher fee rate, previous: ${previousFeeRate}, current: ${feeRate}`, + ); + } + + // Exclude all outputs of the previous transaction during the collection + // TODO: also exclude all outputs of the previous transaction's children transactions + const previousTxId = previousTx.getId(); + const excludeUtxos: BaseOutput[] = previousTx.outs.map((_, index) => ({ + txid: previousTxId, + vout: index, + })); + + // Build RBF transaction + const res = await createSendUtxosBuilder({ + inputs, + outputs, + excludeUtxos, + changeAddress, + from: props.from, + source: props.source, + feeRate: props.feeRate, + fromPubkey: props.fromPubkey, + minUtxoSatoshi: props.minUtxoSatoshi, + onlyConfirmedUtxos: props.onlyConfirmedUtxos ?? true, + skipInputsValidation: true, + }); + + // The RBF transaction should offer a higher fee amount + if (requireGreaterFeeAndRate && res.fee <= previousFee) { + throw TxBuildError.withComment( + ErrorCodes.INVALID_FEE_RATE, + `RBF should offer a higher fee amount, previous: ${previousFee}, current: ${res.fee}`, + ); + } + + return res; +} + +export async function sendRbf(props: SendRbfProps): Promise { + const { builder } = await createSendRbfBuilder(props); + return builder.toPsbt(); +} diff --git a/packages/btc/src/api/sendRgbppUtxos.ts b/packages/btc/src/api/sendRgbppUtxos.ts index 22a52516..69b3ab8e 100644 --- a/packages/btc/src/api/sendRgbppUtxos.ts +++ b/packages/btc/src/api/sendRgbppUtxos.ts @@ -1,7 +1,7 @@ import { Collector, checkCkbTxInputsCapacitySufficient } from '@rgbpp-sdk/ckb'; import { isRgbppLockCell, isBtcTimeLockCell, calculateCommitment } from '@rgbpp-sdk/ckb'; import { bitcoin } from '../bitcoin'; -import { Utxo } from '../transaction/utxo'; +import { BaseOutput, Utxo } from '../transaction/utxo'; import { DataSource } from '../query/source'; import { NetworkType } from '../preset/types'; import { ErrorCodes, TxBuildError } from '../error'; @@ -28,6 +28,10 @@ export interface SendRgbppUtxosProps { changeAddress?: string; minUtxoSatoshi?: number; onlyConfirmedUtxos?: boolean; + excludeUtxos?: BaseOutput[]; + + // EXPERIMENTAL: the below props are unstable and can be altered at any time + onlyProvableUtxos?: boolean; } /** @@ -37,9 +41,12 @@ export const sendRgbppUtxosBuilder = createSendRgbppUtxosBuilder; export async function createSendRgbppUtxosBuilder(props: SendRgbppUtxosProps): Promise<{ builder: TxBuilder; - feeRate: number; fee: number; + feeRate: number; + changeIndex: number; }> { + const onlyProvableUtxos = props.onlyProvableUtxos ?? true; + const btcInputs: Utxo[] = []; const btcOutputs: InitOutput[] = []; let lastCkbTypeOutputIndex = -1; @@ -90,7 +97,7 @@ export async function createSendRgbppUtxosBuilder(props: SendRgbppUtxosProps): P if (!utxo) { throw TxBuildError.withComment(ErrorCodes.CANNOT_FIND_UTXO, `hash: ${args.btcTxid}, index: ${args.outIndex}`); } - if (utxo.address !== props.from) { + if (onlyProvableUtxos && utxo.address !== props.from) { throw TxBuildError.withComment( ErrorCodes.REFERENCED_UNPROVABLE_UTXO, `hash: ${args.btcTxid}, index: ${args.outIndex}`, @@ -171,6 +178,7 @@ export async function createSendRgbppUtxosBuilder(props: SendRgbppUtxosProps): P changeAddress: props.changeAddress, minUtxoSatoshi: props.minUtxoSatoshi, onlyConfirmedUtxos: props.onlyConfirmedUtxos, + excludeUtxos: props.excludeUtxos, }); } diff --git a/packages/btc/src/api/sendUtxos.ts b/packages/btc/src/api/sendUtxos.ts index 1bfff942..5f6abacf 100644 --- a/packages/btc/src/api/sendUtxos.ts +++ b/packages/btc/src/api/sendUtxos.ts @@ -1,6 +1,6 @@ import { bitcoin } from '../bitcoin'; -import { Utxo } from '../transaction/utxo'; import { DataSource } from '../query/source'; +import { BaseOutput, Utxo } from '../transaction/utxo'; import { TxBuilder, InitOutput } from '../transaction/build'; export interface SendUtxosProps { @@ -13,12 +13,17 @@ export interface SendUtxosProps { changeAddress?: string; minUtxoSatoshi?: number; onlyConfirmedUtxos?: boolean; + excludeUtxos?: BaseOutput[]; + + // EXPERIMENTAL: the below props are unstable and can be altered at any time + skipInputsValidation?: boolean; } export async function createSendUtxosBuilder(props: SendUtxosProps): Promise<{ builder: TxBuilder; - feeRate: number; fee: number; + feeRate: number; + changeIndex: number; }> { const tx = new TxBuilder({ source: props.source, @@ -30,7 +35,7 @@ export async function createSendUtxosBuilder(props: SendUtxosProps): Promise<{ tx.addInputs(props.inputs); tx.addOutputs(props.outputs); - if (props.onlyConfirmedUtxos) { + if (props.onlyConfirmedUtxos && !props.skipInputsValidation) { await tx.validateInputs(); } @@ -38,12 +43,14 @@ export async function createSendUtxosBuilder(props: SendUtxosProps): Promise<{ address: props.from, publicKey: props.fromPubkey, changeAddress: props.changeAddress, + excludeUtxos: props.excludeUtxos, }); return { builder: tx, fee: paid.fee, feeRate: paid.feeRate, + changeIndex: paid.changeIndex, }; } diff --git a/packages/btc/src/bitcoin.ts b/packages/btc/src/bitcoin.ts index 7673eeac..a208e7f4 100644 --- a/packages/btc/src/bitcoin.ts +++ b/packages/btc/src/bitcoin.ts @@ -2,10 +2,11 @@ import ECPairFactory, { ECPairInterface } from 'ecpair'; import ecc from '@bitcoinerlab/secp256k1'; import * as bitcoin from 'bitcoinjs-lib'; import { isTaprootInput } from 'bitcoinjs-lib/src/psbt/bip371'; +import { isP2TR, isP2WPKH, isP2PKH } from 'bitcoinjs-lib/src/psbt/psbtutils'; bitcoin.initEccLib(ecc); const ECPair = ECPairFactory(ecc); export type { ECPairInterface }; -export { ecc, ECPair, bitcoin, isTaprootInput }; +export { ecc, ECPair, bitcoin, isP2TR, isP2PKH, isP2WPKH, isTaprootInput }; diff --git a/packages/btc/src/error.ts b/packages/btc/src/error.ts index 14291762..8562ac09 100644 --- a/packages/btc/src/error.ts +++ b/packages/btc/src/error.ts @@ -10,6 +10,7 @@ export enum ErrorCodes { DUPLICATED_UTXO, DUST_OUTPUT, UNSUPPORTED_OUTPUT, + INVALID_CHANGE_OUTPUT, UNSUPPORTED_NETWORK_TYPE, UNSUPPORTED_ADDRESS_TYPE, UNSUPPORTED_OP_RETURN_SCRIPT, @@ -38,6 +39,7 @@ export const ErrorMessages = { [ErrorCodes.UNSPENDABLE_OUTPUT]: 'Target output is not an UTXO', [ErrorCodes.DUST_OUTPUT]: 'Output defined value is below the dust limit', [ErrorCodes.UNSUPPORTED_OUTPUT]: 'Unsupported output format', + [ErrorCodes.INVALID_CHANGE_OUTPUT]: 'Invalid change output', [ErrorCodes.UNSUPPORTED_NETWORK_TYPE]: 'Unsupported network type', [ErrorCodes.UNSUPPORTED_ADDRESS_TYPE]: 'Unsupported address type', [ErrorCodes.UNSUPPORTED_OP_RETURN_SCRIPT]: 'Unsupported OP_RETURN script format', diff --git a/packages/btc/src/index.ts b/packages/btc/src/index.ts index 13a197b7..36dbac94 100644 --- a/packages/btc/src/index.ts +++ b/packages/btc/src/index.ts @@ -6,6 +6,7 @@ export * from './utils'; export * from './error'; export * from './bitcoin'; export * from './address'; +export * from './script'; export * from './query/source'; @@ -15,5 +16,6 @@ export * from './transaction/utxo'; export * from './transaction/fee'; export * from './api/sendBtc'; +export * from './api/sendRbf'; export * from './api/sendUtxos'; export * from './api/sendRgbppUtxos'; diff --git a/packages/btc/src/query/source.ts b/packages/btc/src/query/source.ts index e368b29b..3f01aee8 100644 --- a/packages/btc/src/query/source.ts +++ b/packages/btc/src/query/source.ts @@ -1,5 +1,5 @@ import { BtcApiUtxoParams, BtcAssetsApi, BtcAssetsApiError, ErrorCodes as ServiceErrorCodes } from '@rgbpp-sdk/service'; -import { Output, Utxo } from '../transaction/utxo'; +import { BaseOutput, Output, Utxo } from '../transaction/utxo'; import { NetworkType } from '../preset/types'; import { ErrorCodes, TxBuildError } from '../error'; import { TxAddressOutput } from '../transaction/build'; @@ -107,10 +107,7 @@ export class DataSource { onlyConfirmedUtxos?: boolean; noAssetsApiCache?: boolean; internalCacheKey?: string; - excludeUtxos?: { - txid: string; - vout: number; - }[]; + excludeUtxos?: BaseOutput[]; }): Promise<{ utxos: Utxo[]; satoshi: number; diff --git a/packages/btc/src/script.ts b/packages/btc/src/script.ts new file mode 100644 index 00000000..a17339f0 --- /dev/null +++ b/packages/btc/src/script.ts @@ -0,0 +1,11 @@ +import { isP2TR, isP2WPKH } from './bitcoin'; + +export function isP2wpkhScript(script: Buffer | string): boolean { + const buffer = typeof script === 'string' ? Buffer.from(script, 'hex') : script; + return isP2WPKH(buffer); +} + +export function isP2trScript(script: Buffer | string): boolean { + const buffer = typeof script === 'string' ? Buffer.from(script, 'hex') : script; + return isP2TR(buffer); +} diff --git a/packages/btc/src/transaction/build.ts b/packages/btc/src/transaction/build.ts index 7d34ab7b..a5c84082 100644 --- a/packages/btc/src/transaction/build.ts +++ b/packages/btc/src/transaction/build.ts @@ -6,8 +6,8 @@ import { NetworkType, RgbppBtcConfig } from '../preset/types'; import { AddressType, addressToScriptPublicKeyHex, getAddressType, isSupportedFromAddress } from '../address'; import { dataToOpReturnScriptPubkey, isOpReturnScriptPubkey } from './embed'; import { networkTypeToConfig } from '../preset/config'; +import { BaseOutput, Utxo, utxoToInput } from './utxo'; import { limitPromiseBatchSize } from '../utils'; -import { Utxo, utxoToInput } from './utxo'; import { FeeEstimator } from './fee'; export interface TxInput { @@ -21,21 +21,21 @@ export interface TxInput { } export type TxOutput = TxAddressOutput | TxScriptOutput; -export interface BaseOutput { +export interface TxBaseOutput { value: number; fixed?: boolean; protected?: boolean; minUtxoSatoshi?: number; } -export interface TxAddressOutput extends BaseOutput { +export interface TxAddressOutput extends TxBaseOutput { address: string; } -export interface TxScriptOutput extends BaseOutput { +export interface TxScriptOutput extends TxBaseOutput { script: Buffer; } export type InitOutput = TxAddressOutput | TxDataOutput | TxScriptOutput; -export interface TxDataOutput extends BaseOutput { +export interface TxDataOutput extends TxBaseOutput { data: Buffer | string; } @@ -141,12 +141,14 @@ export class TxBuilder { publicKey?: string; changeAddress?: string; deductFromOutputs?: boolean; + excludeUtxos?: BaseOutput[]; feeRate?: number; }): Promise<{ fee: number; feeRate: number; + changeIndex: number; }> { - const { address, publicKey, feeRate, changeAddress, deductFromOutputs } = props; + const { address, publicKey, feeRate, changeAddress, deductFromOutputs, excludeUtxos } = props; const originalInputs = clone(this.inputs); const originalOutputs = clone(this.outputs); @@ -168,6 +170,7 @@ export class TxBuilder { let previousFee = 0; let isLoopedOnce = false; let isFeeExpected = false; + let currentChangeIndex = -1; while (!isFeeExpected) { if (isLoopedOnce) { previousFee = currentFee; @@ -181,26 +184,32 @@ export class TxBuilder { if (safeToProcess && returnAmount > 0) { // If sum(inputs) - sum(outputs) > fee, return (change - fee) to a non-fixed output or to a new output. // Note when returning change to a new output, another satoshi collection may be needed. - await this.injectChange({ + const { changeIndex } = await this.injectChange({ address: changeAddress ?? address, amount: returnAmount, fromAddress: address, fromPublicKey: publicKey, internalCacheKey, + excludeUtxos, }); + + currentChangeIndex = changeIndex; } else { // If the inputs have insufficient satoshi, a satoshi collection is required. // For protection, at least collect 1 satoshi if the inputs are empty or the fee hasn't been calculated. const protectionAmount = safeToProcess ? 0 : 1; const targetAmount = needCollect - needReturn + previousFee + protectionAmount; - await this.injectSatoshi({ + const { changeIndex } = await this.injectSatoshi({ address, publicKey, targetAmount, changeAddress, deductFromOutputs, internalCacheKey, + excludeUtxos, }); + + currentChangeIndex = changeIndex; } // Calculate network fee @@ -220,6 +229,7 @@ export class TxBuilder { return { fee: currentFee, feeRate: currentFeeRate, + changeIndex: currentChangeIndex, }; } @@ -231,12 +241,18 @@ export class TxBuilder { injectCollected?: boolean; deductFromOutputs?: boolean; internalCacheKey?: string; - }) { + excludeUtxos?: BaseOutput[]; + }): Promise<{ + collected: number; + changeIndex: number; + changeAmount: number; + }> { if (!isSupportedFromAddress(props.address)) { throw TxBuildError.withComment(ErrorCodes.UNSUPPORTED_ADDRESS_TYPE, props.address); } const targetAmount = props.targetAmount; + const excludeUtxos = props.excludeUtxos ?? []; const injectCollected = props.injectCollected ?? false; const deductFromOutputs = props.deductFromOutputs ?? true; @@ -261,7 +277,7 @@ export class TxBuilder { minUtxoSatoshi: this.minUtxoSatoshi, onlyNonRgbppUtxos: this.onlyNonRgbppUtxos, onlyConfirmedUtxos: this.onlyConfirmedUtxos, - excludeUtxos: this.inputs.map((row) => row.utxo), + excludeUtxos: [...this.inputs.map((v) => v.utxo), ...excludeUtxos], }); utxos.forEach((utxo) => { this.addInput({ @@ -331,10 +347,12 @@ export class TxBuilder { // 3. Collect from "from" one more time if: // - Need to create an output to return change (changeAmount > 0) // - The change is insufficient for a non-dust output (changeAmount < minUtxoSatoshi) - const needForChange = changeAmount > 0 && changeAmount < this.minUtxoSatoshi; - const changeUtxoNeedAmount = needForChange ? this.minUtxoSatoshi - changeAmount : 0; - if (needForChange) { - await _collect(changeUtxoNeedAmount); + const changeAddress = props.changeAddress ?? props.address; + const changeToOutputs = !this.canInjectChangeToOutputs(changeAddress); + const needChangeOutput = !changeToOutputs && changeAmount > 0 && changeAmount < this.minUtxoSatoshi; + const changeOutputNeedAmount = needChangeOutput ? this.minUtxoSatoshi - changeAmount : 0; + if (changeOutputNeedAmount > 0) { + await _collect(changeOutputNeedAmount); } // 4. If not collected enough satoshi, throw an error @@ -346,9 +364,9 @@ export class TxBuilder { `expected: ${targetAmount}, actual: ${collected}. You may wanna deposit more satoshi to prevent the error, for example: ${recommendedDeposit}`, ); } - const insufficientForChange = changeAmount > 0 && changeAmount < this.minUtxoSatoshi; + const insufficientForChange = !changeToOutputs && changeAmount > 0 && changeAmount < this.minUtxoSatoshi; if (insufficientForChange) { - const shiftedExpectAmount = collected + changeUtxoNeedAmount; + const shiftedExpectAmount = collected + changeOutputNeedAmount; throw TxBuildError.withComment( ErrorCodes.INSUFFICIENT_UTXO, `expected: ${shiftedExpectAmount}, actual: ${collected}`, @@ -360,14 +378,14 @@ export class TxBuilder { // - If changeAmount>0, return change to an output or create a change output let changeIndex: number = -1; if (changeAmount > 0) { - changeIndex = this.outputs.length; - const changeAddress = props.changeAddress ?? props.address; - await this.injectChange({ + const injectedChanged = await this.injectChange({ amount: changeAmount, address: changeAddress, fromAddress: props.address, fromPublicKey: props.publicKey, }); + + changeIndex = injectedChanged.changeIndex; } return { @@ -383,8 +401,11 @@ export class TxBuilder { fromAddress: string; fromPublicKey?: string; internalCacheKey?: string; - }) { - const { address, fromAddress, fromPublicKey, amount, internalCacheKey } = props; + excludeUtxos?: BaseOutput[]; + }): Promise<{ + changeIndex: number; + }> { + const { address, fromAddress, fromPublicKey, amount, excludeUtxos, internalCacheKey } = props; // If any (output.fixed != true) is found in the outputs (search in ASC order), // return the change value to the first matched output. @@ -398,9 +419,12 @@ export class TxBuilder { } output.value += amount; - return; + return { + changeIndex: i, + }; } + let changeIndex: number = -1; if (amount < this.minUtxoSatoshi) { // If the change is not enough to create a non-dust output, try collect more. // - injectCollected=true, expect to put all (collected + amount) of satoshi as change @@ -409,7 +433,7 @@ export class TxBuilder { // 1. Expected to return change of 500 satoshi, amount=500 // 2. Collected 2000 satoshi from the "fromAddress", collected=2000 // 3. Create a change output and return (collected + amount), output.value=2000+500=2500 - const { collected } = await this.injectSatoshi({ + const injected = await this.injectSatoshi({ address: fromAddress, publicKey: fromPublicKey, targetAmount: amount, @@ -417,16 +441,34 @@ export class TxBuilder { injectCollected: true, deductFromOutputs: false, internalCacheKey, + excludeUtxos, }); - if (collected < amount) { - throw TxBuildError.withComment(ErrorCodes.INSUFFICIENT_UTXO, `expected: ${amount}, actual: ${collected}`); + if (injected.collected < amount) { + throw TxBuildError.withComment( + ErrorCodes.INSUFFICIENT_UTXO, + `expected: ${amount}, actual: ${injected.collected}`, + ); } + + changeIndex = injected.changeIndex; } else { this.addOutput({ address: address, value: amount, }); + + changeIndex = this.outputs.length - 1; } + + return { + changeIndex, + }; + } + + canInjectChangeToOutputs(changeAddress: string): boolean { + return this.outputs.some((output) => { + return !output.fixed && (!('address' in output) || output.address === changeAddress); + }); } async calculateFee(addressType: AddressType, feeRate?: number): Promise { diff --git a/packages/btc/src/transaction/utxo.ts b/packages/btc/src/transaction/utxo.ts index 1cddd514..a5076e14 100644 --- a/packages/btc/src/transaction/utxo.ts +++ b/packages/btc/src/transaction/utxo.ts @@ -3,9 +3,12 @@ import { AddressType } from '../address'; import { TxInput } from './build'; import { remove0x, toXOnly } from '../utils'; -export interface Output { +export interface BaseOutput { txid: string; vout: number; +} + +export interface Output extends BaseOutput { value: number; scriptPk: string; } diff --git a/packages/btc/tests/Script.test.ts b/packages/btc/tests/Script.test.ts new file mode 100644 index 00000000..18298a1f --- /dev/null +++ b/packages/btc/tests/Script.test.ts @@ -0,0 +1,17 @@ +import { isP2trScript, isP2wpkhScript } from '../src'; +import { describe, expect, it } from 'vitest'; +import { accounts } from './shared/env'; + +describe('Script', () => { + const p2wpkh = accounts.charlie.p2wpkh.scriptPubkey; + const p2tr = accounts.charlie.p2tr.scriptPubkey; + + it('isP2trScript()', () => { + expect(isP2trScript(p2tr)).toBe(true); + expect(isP2trScript(p2wpkh)).toBe(false); + }); + it('isP2wpkhScript()', () => { + expect(isP2wpkhScript(p2wpkh)).toBe(true); + expect(isP2wpkhScript(p2tr)).toBe(false); + }); +}); diff --git a/packages/btc/tests/Transaction.test.ts b/packages/btc/tests/Transaction.test.ts index 0de77c9b..7c7f8e18 100644 --- a/packages/btc/tests/Transaction.test.ts +++ b/packages/btc/tests/Transaction.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { expectPsbtFeeInRange } from './shared/utils'; import { accounts, config, network, service, source } from './shared/env'; -import { bitcoin, ErrorMessages, ErrorCodes, AddressType, createSendUtxosBuilder } from '../src'; -import { createSendBtcBuilder, sendBtc, sendUtxos, tweakSigner } from '../src'; +import { expectPsbtFeeInRange, signAndBroadcastPsbt, waitFor } from './shared/utils'; +import { bitcoin, ErrorMessages, ErrorCodes, AddressType } from '../src'; +import { createSendUtxosBuilder, createSendBtcBuilder, sendBtc, sendUtxos, sendRbf, tweakSigner } from '../src'; const STATIC_FEE_RATE = 1; const BTC_UTXO_DUST_LIMIT = config.btcUtxoDustLimit; @@ -19,6 +19,7 @@ describe('Transaction', () => { value: 1000, }, ], + feeRate: STATIC_FEE_RATE, source, }); @@ -27,7 +28,7 @@ describe('Transaction', () => { psbt.signAllInputs(accounts.charlie.keyPair); psbt.finalizeAllInputs(); - console.log('tx paid fee:', psbt.getFee()); + expect(feeRate).toEqual(STATIC_FEE_RATE); expectPsbtFeeInRange(psbt, feeRate); // Broadcast transaction @@ -45,6 +46,7 @@ describe('Transaction', () => { value: 1000, }, ], + feeRate: STATIC_FEE_RATE, source, }); @@ -57,11 +59,7 @@ describe('Transaction', () => { psbt.signAllInputs(tweakedSigner); psbt.finalizeAllInputs(); - console.log(psbt.txInputs); - console.log(psbt.txOutputs); - - console.log('tx paid fee:', psbt.getFee()); - expectPsbtFeeInRange(psbt); + expectPsbtFeeInRange(psbt, STATIC_FEE_RATE); // Broadcast transaction // const tx = psbt.extractTransaction(); @@ -78,6 +76,7 @@ describe('Transaction', () => { value: 546, }, ], + feeRate: STATIC_FEE_RATE, source, }), ).rejects.toThrow(); @@ -99,6 +98,7 @@ describe('Transaction', () => { }, ], minUtxoSatoshi: impossibleLimit, + feeRate: STATIC_FEE_RATE, source, }), ).rejects.toThrow(ErrorMessages[ErrorCodes.INSUFFICIENT_UTXO]); @@ -116,6 +116,7 @@ describe('Transaction', () => { value: 1000, }, ], + feeRate: STATIC_FEE_RATE, source, }); @@ -141,8 +142,7 @@ describe('Transaction', () => { expect(data).toBeInstanceOf(Buffer); expect((data as Buffer).toString('hex')).toEqual('00'.repeat(32)); - console.log('tx paid fee:', psbt.getFee()); - expectPsbtFeeInRange(psbt); + expectPsbtFeeInRange(psbt, STATIC_FEE_RATE); // Broadcast transaction // const tx = psbt.extractTransaction(); @@ -153,7 +153,7 @@ describe('Transaction', () => { describe('sendUtxos()', () => { it('Transfer fixed UTXO, sum(ins) = sum(outs)', async () => { - const { builder, feeRate } = await createSendUtxosBuilder({ + const { builder, feeRate, changeIndex } = await createSendUtxosBuilder({ from: accounts.charlie.p2wpkh.address, inputs: [ { @@ -173,6 +173,7 @@ describe('Transaction', () => { }, ], source, + feeRate: STATIC_FEE_RATE, }); // Sign & finalize inputs @@ -182,9 +183,9 @@ describe('Transaction', () => { expect(psbt.txInputs.length).toBeGreaterThanOrEqual(2); expect(psbt.txOutputs).toHaveLength(2); - - console.log('tx paid fee:', psbt.getFee()); - expectPsbtFeeInRange(psbt, feeRate); + expect(changeIndex).toEqual(1); + expect(feeRate).toEqual(STATIC_FEE_RATE); + expectPsbtFeeInRange(psbt, STATIC_FEE_RATE); // Broadcast transaction // const tx = psbt.extractTransaction(); @@ -212,8 +213,6 @@ describe('Transaction', () => { expect(psbt.txInputs.length).toBeGreaterThanOrEqual(1); expect(psbt.txOutputs).toHaveLength(2); - - console.log('tx paid fee:', psbt.getFee()); expectPsbtFeeInRange(psbt, feeRate); // Broadcast transaction @@ -241,6 +240,7 @@ describe('Transaction', () => { fixed: true, }, ], + feeRate: STATIC_FEE_RATE, source, }); @@ -250,9 +250,7 @@ describe('Transaction', () => { expect(psbt.txInputs.length).toBeGreaterThanOrEqual(2); expect(psbt.txOutputs).toHaveLength(2); - - console.log('tx paid fee:', psbt.getFee()); - expectPsbtFeeInRange(psbt); + expectPsbtFeeInRange(psbt, STATIC_FEE_RATE); // Broadcast transaction // const tx = psbt.extractTransaction(); @@ -279,6 +277,7 @@ describe('Transaction', () => { fixed: true, }, ], + feeRate: STATIC_FEE_RATE, source, }); @@ -288,9 +287,7 @@ describe('Transaction', () => { expect(psbt.txInputs.length).toBeGreaterThanOrEqual(2); expect(psbt.txOutputs).toHaveLength(2); - - console.log('tx paid fee:', psbt.getFee()); - expectPsbtFeeInRange(psbt); + expectPsbtFeeInRange(psbt, STATIC_FEE_RATE); // Broadcast transaction // const tx = psbt.extractTransaction(); @@ -327,8 +324,6 @@ describe('Transaction', () => { expect(psbt.txInputs).toHaveLength(1); expect(psbt.txOutputs).toHaveLength(2); - - console.log('tx paid fee:', psbt.getFee()); expectPsbtFeeInRange(psbt, STATIC_FEE_RATE); // Broadcast transaction @@ -366,8 +361,6 @@ describe('Transaction', () => { expect(psbt.txInputs).toHaveLength(1); expect(psbt.txOutputs).toHaveLength(2); - - console.log('tx paid fee:', psbt.getFee()); expectPsbtFeeInRange(psbt, STATIC_FEE_RATE); // Broadcast transaction @@ -376,7 +369,7 @@ describe('Transaction', () => { // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); }); it('Transfer fixed UTXO, sum(ins) > sum(outs), change < fee', async () => { - const { builder, feeRate } = await createSendUtxosBuilder({ + const { builder, feeRate, changeIndex } = await createSendUtxosBuilder({ from: accounts.charlie.p2wpkh.address, inputs: [ { @@ -395,7 +388,7 @@ describe('Transaction', () => { fixed: true, }, ], - feeRate: 10, + feeRate: STATIC_FEE_RATE, source, }); @@ -406,9 +399,8 @@ describe('Transaction', () => { expect(psbt.txInputs.length).toBeGreaterThanOrEqual(2); expect(psbt.txOutputs).toHaveLength(2); - - console.log('tx fee:', psbt.getFee()); - console.log('tx fee rate:', psbt.getFeeRate()); + expect(changeIndex).toEqual(1); + expect(feeRate).toEqual(STATIC_FEE_RATE); expectPsbtFeeInRange(psbt, feeRate); // Broadcast transaction @@ -417,7 +409,7 @@ describe('Transaction', () => { // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); }); it('Transfer fixed UTXO, sum(ins) > sum(outs), change = fee', async () => { - const psbt = await sendUtxos({ + const { builder, feeRate, changeIndex } = await createSendUtxosBuilder({ from: accounts.charlie.p2wpkh.address, inputs: [ { @@ -432,7 +424,7 @@ describe('Transaction', () => { outputs: [ { address: accounts.charlie.p2wpkh.address, - value: 1856, + value: 1859, fixed: true, }, { @@ -445,13 +437,14 @@ describe('Transaction', () => { }); // Sign & finalize inputs + const psbt = builder.toPsbt(); psbt.signAllInputs(accounts.charlie.keyPair); psbt.finalizeAllInputs(); expect(psbt.txInputs).toHaveLength(1); expect(psbt.txOutputs).toHaveLength(2); - - console.log('tx paid fee:', psbt.getFee()); + expect(changeIndex).toEqual(-1); + expect(feeRate).toEqual(STATIC_FEE_RATE); expectPsbtFeeInRange(psbt, STATIC_FEE_RATE); // Broadcast transaction @@ -496,12 +489,8 @@ describe('Transaction', () => { psbt.signAllInputs(accounts.charlie.keyPair); psbt.finalizeAllInputs(); - console.log(psbt.data.inputs.map((row) => row.finalScriptWitness!.byteLength)); - expect(psbt.txInputs).toHaveLength(2); expect(psbt.txOutputs).toHaveLength(1); - - console.log('tx paid fee:', psbt.getFee()); expectPsbtFeeInRange(psbt, STATIC_FEE_RATE); // Broadcast transaction @@ -529,6 +518,7 @@ describe('Transaction', () => { protected: true, }, ], + feeRate: STATIC_FEE_RATE, source, }); @@ -538,9 +528,7 @@ describe('Transaction', () => { expect(psbt.txInputs.length).toBeGreaterThanOrEqual(2); expect(psbt.txOutputs).toHaveLength(1); - - console.log('tx paid fee:', psbt.getFee()); - expectPsbtFeeInRange(psbt); + expectPsbtFeeInRange(psbt, STATIC_FEE_RATE); // Broadcast transaction // const tx = psbt.extractTransaction(); @@ -577,8 +565,6 @@ describe('Transaction', () => { expect(psbt.txInputs).toHaveLength(1); expect(psbt.txOutputs).toHaveLength(1); - - console.log('tx paid fee:', psbt.getFee()); expectPsbtFeeInRange(psbt, STATIC_FEE_RATE); // Broadcast transaction @@ -617,9 +603,7 @@ describe('Transaction', () => { expect(psbt.txInputs.length).toBeGreaterThanOrEqual(2); expect(psbt.txOutputs).toHaveLength(1); - - console.log('tx paid fee:', psbt.getFee()); - console.log('tx paid fee rate:', psbt.getFeeRate()); + expect(feeRate).toEqual(10); expectPsbtFeeInRange(psbt, feeRate); // Broadcast transaction @@ -628,6 +612,95 @@ describe('Transaction', () => { // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); }); + it('Transfer protected UTXO, change to the first address-matched output', async () => { + const { builder, changeIndex } = await createSendUtxosBuilder({ + from: accounts.charlie.p2wpkh.address, + inputs: [ + { + txid: '4e1e9f8ff4bf245793c05bf2da58bff812c332a296d93c6935fbc980d906e567', + vout: 1, + value: 10000, + addressType: AddressType.P2WPKH, + address: accounts.charlie.p2wpkh.address, + scriptPk: accounts.charlie.p2wpkh.scriptPubkey.toString('hex'), + }, + ], + outputs: [ + { + address: accounts.charlie.p2tr.address, + value: 1000, + protected: true, + }, + { + address: accounts.charlie.p2wpkh.address, + value: 1000, + protected: true, + }, + ], + feeRate: STATIC_FEE_RATE, + source, + }); + + // Sign & finalize inputs + const psbt = builder.toPsbt(); + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + expect(changeIndex).toEqual(1); + expect(psbt.txInputs).toHaveLength(1); + expect(psbt.txOutputs).toHaveLength(2); + expectPsbtFeeInRange(psbt, STATIC_FEE_RATE); + + // Broadcast transaction + // const tx = psbt.extractTransaction(); + // const res = await service.sendBtcTransaction(tx.toHex()); + // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); + }); + it('Transfer mixed UTXO, change to the first non-fixed output', async () => { + const { builder, changeIndex } = await createSendUtxosBuilder({ + from: accounts.charlie.p2wpkh.address, + inputs: [ + { + txid: '4e1e9f8ff4bf245793c05bf2da58bff812c332a296d93c6935fbc980d906e567', + vout: 1, + value: 10000, + addressType: AddressType.P2WPKH, + address: accounts.charlie.p2wpkh.address, + scriptPk: accounts.charlie.p2wpkh.scriptPubkey.toString('hex'), + }, + ], + outputs: [ + { + address: accounts.charlie.p2wpkh.address, + value: 1000, + fixed: true, + }, + { + address: accounts.charlie.p2wpkh.address, + value: 1000, + protected: true, + }, + ], + feeRate: STATIC_FEE_RATE, + source, + }); + + // Sign & finalize inputs + const psbt = builder.toPsbt(); + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + expect(changeIndex).toEqual(1); + expect(psbt.txInputs).toHaveLength(1); + expect(psbt.txOutputs).toHaveLength(2); + expectPsbtFeeInRange(psbt, STATIC_FEE_RATE); + + // Broadcast transaction + // const tx = psbt.extractTransaction(); + // const res = await service.sendBtcTransaction(tx.toHex()); + // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); + }); + it('Transfer protected RGBPP_UTXOs, sum(ins) = sum(outs)', async () => { const psbt = await sendUtxos({ from: accounts.charlie.p2wpkh.address, @@ -663,6 +736,7 @@ describe('Transaction', () => { protected: true, }, ], + feeRate: STATIC_FEE_RATE, source, }); @@ -675,8 +749,7 @@ describe('Transaction', () => { expect(psbt.txOutputs[0].value).toBeGreaterThan(RGBPP_UTXO_DUST_LIMIT); expect(psbt.txOutputs[1].value).toBe(RGBPP_UTXO_DUST_LIMIT); - console.log('tx paid fee:', psbt.getFee()); - expectPsbtFeeInRange(psbt); + expectPsbtFeeInRange(psbt, STATIC_FEE_RATE); // Broadcast transaction // const tx = psbt.extractTransaction(); @@ -724,13 +797,11 @@ describe('Transaction', () => { psbt.signAllInputs(accounts.charlie.keyPair); psbt.finalizeAllInputs(); - console.log(psbt.txOutputs); expect(psbt.txInputs).toHaveLength(1); expect(psbt.txOutputs).toHaveLength(3); expect(psbt.txOutputs[0].value).toBeLessThan(psbt.txOutputs[1].value); expect(psbt.txOutputs[1].value).toBeLessThan(psbt.txOutputs[2].value); - console.log('tx paid fee:', psbt.getFee()); expectPsbtFeeInRange(psbt, STATIC_FEE_RATE); // Broadcast transaction @@ -773,13 +844,11 @@ describe('Transaction', () => { psbt.signAllInputs(accounts.charlie.keyPair); psbt.finalizeAllInputs(); - console.log(psbt.txOutputs); expect(psbt.txInputs.length).toBeGreaterThanOrEqual(2); expect(psbt.txOutputs).toHaveLength(2); expect(psbt.txOutputs[0].value).toBeGreaterThan(RGBPP_UTXO_DUST_LIMIT); expect(psbt.txOutputs[1].value).toBe(RGBPP_UTXO_DUST_LIMIT); - console.log('tx paid fee:', psbt.getFee()); expectPsbtFeeInRange(psbt, STATIC_FEE_RATE); // Broadcast transaction @@ -809,12 +878,317 @@ describe('Transaction', () => { }, ], onlyConfirmedUtxos: true, + feeRate: STATIC_FEE_RATE, source, }), ).rejects.toThrow(); }); }); + describe.skip('sendRbf()', () => { + it('Full RBF', async () => { + /** + * TX_1, feeRate=minimumFee/2 + */ + const feeRates = await service.getBtcRecommendedFeeRates(); + const expectFeeRate = Math.max(Math.round(feeRates.minimumFee / 2), 1); + const psbt = await sendBtc({ + from: accounts.charlie.p2wpkh.address, + tos: [ + { + address: accounts.charlie.p2wpkh.address, + value: BTC_UTXO_DUST_LIMIT, + }, + ], + feeRate: expectFeeRate, + source, + }); + const { txHex } = await signAndBroadcastPsbt({ + psbt, + account: accounts.charlie, + feeRate: expectFeeRate, + }); + + /** + * Wait for 2 seconds + */ + await waitFor(2000); + console.log('---'); + + /** + * TX_2, feeRate=fastestFee + */ + const expectFeeRate2 = Math.max(feeRates.fastestFee, expectFeeRate + 1); + const psbt2 = await sendRbf({ + txHex: txHex, + from: accounts.charlie.p2wpkh.address, + feeRate: expectFeeRate2, + source, + }); + await signAndBroadcastPsbt({ + psbt: psbt2, + account: accounts.charlie, + feeRate: expectFeeRate2, + }); + }, 0); + it('Full RBF with changeIndex', async () => { + /** + * TX_1, feeRate=1 + */ + const feeRates = await service.getBtcRecommendedFeeRates(); + const expectFeeRate = Math.max(Math.round(feeRates.minimumFee / 2), 1); + const psbt = await sendBtc({ + from: accounts.charlie.p2wpkh.address, + tos: [ + { + address: accounts.charlie.p2wpkh.address, + value: BTC_UTXO_DUST_LIMIT, + }, + ], + feeRate: expectFeeRate, + source, + }); + const { tx, txHex } = await signAndBroadcastPsbt({ + psbt, + account: accounts.charlie, + feeRate: expectFeeRate, + }); + + /** + * Wait for 2 seconds + */ + await waitFor(2000); + console.log('---'); + + /** + * TX_2, feeRate=fastestFee + */ + const expectFeeRate2 = Math.max(feeRates.fastestFee, expectFeeRate + 1); + const changeIndex = tx.outs.length - 1; + const psbt2 = await sendRbf({ + txHex: txHex, + from: accounts.charlie.p2wpkh.address, + feeRate: expectFeeRate2, + changeIndex, + source, + }); + await signAndBroadcastPsbt({ + psbt: psbt2, + account: accounts.charlie, + feeRate: expectFeeRate2, + }); + }, 0); + it('Full RBF with changeIndex, outputs.length == 1', async () => { + /** + * TX_1, feeRate=1 + */ + const feeRates = await service.getBtcRecommendedFeeRates(); + const expectFeeRate = Math.max(Math.round(feeRates.minimumFee / 2), 1); + const psbt = await sendBtc({ + from: accounts.charlie.p2wpkh.address, + tos: [ + { + address: accounts.charlie.p2wpkh.address, + value: BTC_UTXO_DUST_LIMIT, + protected: true, + fixed: false, + }, + ], + feeRate: expectFeeRate, + source, + }); + const { tx, txHex } = await signAndBroadcastPsbt({ + psbt, + account: accounts.charlie, + feeRate: expectFeeRate, + }); + + console.log(tx.outs); + + /** + * Wait for 2 seconds + */ + await waitFor(2000); + console.log('---'); + + /** + * TX_2, feeRate=fastestFee + */ + const expectFeeRate2 = Math.max(feeRates.fastestFee, expectFeeRate + 1); + const changeIndex = tx.outs.length - 1; + const psbt2 = await sendRbf({ + txHex: txHex, + from: accounts.charlie.p2wpkh.address, + feeRate: expectFeeRate2, + changeIndex, + source, + }); + await signAndBroadcastPsbt({ + psbt: psbt2, + account: accounts.charlie, + feeRate: expectFeeRate2, + }); + }, 0); + it('Full RBF with changeIndex, outputs.length != changeIndex + 1', async () => { + /** + * TX_1, feeRate=1 + */ + const feeRates = await service.getBtcRecommendedFeeRates(); + const expectFeeRate = Math.max(Math.round(feeRates.minimumFee / 2), 1); + const psbt = await sendBtc({ + from: accounts.charlie.p2wpkh.address, + tos: [ + { + address: accounts.charlie.p2wpkh.address, + value: 3000, + }, + ], + feeRate: expectFeeRate, + source, + }); + const { txHex } = await signAndBroadcastPsbt({ + psbt, + account: accounts.charlie, + feeRate: expectFeeRate, + }); + + /** + * Wait for 2 seconds + */ + await waitFor(2000); + console.log('---'); + + /** + * TX_2, feeRate=fastestFee + */ + const expectFeeRate2 = Math.max(feeRates.fastestFee, expectFeeRate + 1); + const psbt2 = await sendRbf({ + txHex: txHex, + from: accounts.charlie.p2wpkh.address, + feeRate: expectFeeRate2, + changeIndex: 0, + source, + }); + await signAndBroadcastPsbt({ + psbt: psbt2, + account: accounts.charlie, + feeRate: expectFeeRate2, + }); + }, 0); + it('Try Full RBF with invalid change', async () => { + /** + * TX_1, feeRate=1 + */ + const expectFeeRate = 1; + const psbt = await sendBtc({ + from: accounts.charlie.p2wpkh.address, + tos: [ + { + data: Buffer.from('hello'), + value: 0, + }, + { + address: accounts.charlie.p2tr.address, + value: BTC_UTXO_DUST_LIMIT, + }, + ], + feeRate: expectFeeRate, + source, + }); + const { tx, txHex } = await signAndBroadcastPsbt({ + psbt, + account: accounts.charlie, + feeRate: expectFeeRate, + }); + + /** + * Wait for 2 seconds + */ + console.log('---'); + await waitFor(2000); + const expectFeeRate2 = expectFeeRate * 2; + + /** + * TX_2, outputs[changeIndex] == undefined + */ + await expect(() => + sendRbf({ + txHex: txHex, + from: accounts.charlie.p2wpkh.address, + changeAddress: accounts.charlie.p2tr.address, + feeRate: expectFeeRate2, + changeIndex: 3, + source, + }), + ).rejects.toHaveProperty('code', ErrorCodes.INVALID_CHANGE_OUTPUT); + /** + * TX_3, changeOutput is not returnable + */ + await expect(() => + sendRbf({ + txHex: txHex, + from: accounts.charlie.p2wpkh.address, + feeRate: expectFeeRate2, + changeIndex: 0, + source, + }), + ).rejects.toHaveProperty('code', ErrorCodes.INVALID_CHANGE_OUTPUT); + /** + * TX_4, changeAddress !== changeOutputAddress + */ + await expect(() => + sendRbf({ + txHex: txHex, + from: accounts.charlie.p2wpkh.address, + changeAddress: accounts.charlie.p2tr.address, + changeIndex: tx.outs.length - 1, + feeRate: expectFeeRate2, + source, + }), + ).rejects.toHaveProperty('code', ErrorCodes.INVALID_CHANGE_OUTPUT); + }, 0); + it('Try Full RBF with invalid feeRate', async () => { + /** + * TX_1, feeRate=1 + */ + const expectFeeRate = 1; + const psbt = await sendBtc({ + from: accounts.charlie.p2wpkh.address, + tos: [ + { + address: accounts.charlie.p2wpkh.address, + value: BTC_UTXO_DUST_LIMIT, + }, + ], + feeRate: expectFeeRate, + source, + }); + const { txHex } = await signAndBroadcastPsbt({ + psbt, + account: accounts.charlie, + feeRate: expectFeeRate, + }); + + /** + * Wait for 2 seconds + */ + await waitFor(2000); + console.log('---'); + + /** + * TX_2, feeRate=1 + */ + await expect( + await sendRbf({ + txHex: txHex, + from: accounts.charlie.p2wpkh.address, + feeRate: expectFeeRate, + source, + }), + ).rejects.toHaveProperty('code', ErrorCodes.INVALID_FEE_RATE); + }, 0); + }); + describe.todo('sendRgbppUtxos()', () => { // TODO: fill tests }); diff --git a/packages/btc/tests/shared/env.ts b/packages/btc/tests/shared/env.ts index 195a200b..fffb444d 100644 --- a/packages/btc/tests/shared/env.ts +++ b/packages/btc/tests/shared/env.ts @@ -1,5 +1,6 @@ import { BtcAssetsApi } from '@rgbpp-sdk/service'; -import { bitcoin, ECPair, toXOnly, networkTypeToConfig, DataSource, NetworkType } from '../../src'; +import { DataSource, NetworkType, networkTypeToConfig } from '../../src'; +import { createAccount } from './utils'; export const networkType = NetworkType.TESTNET; export const config = networkTypeToConfig(networkType); @@ -14,35 +15,8 @@ export const service = BtcAssetsApi.fromToken( export const source = new DataSource(service, networkType); export const accounts = { - charlie: createAccount('8d3c23d340ac0841e6c3b58a9bbccb9a28e94ab444f972cff35736fa2fcf9f3f', network), + charlie: createAccount({ + privateKey: '8d3c23d340ac0841e6c3b58a9bbccb9a28e94ab444f972cff35736fa2fcf9f3f', + network, + }), }; - -function createAccount(privateKey: string, _network?: bitcoin.Network) { - const keyPair = ECPair.fromPrivateKey(Buffer.from(privateKey, 'hex'), { network: _network }); - const p2wpkh = bitcoin.payments.p2wpkh({ - pubkey: keyPair.publicKey, - network: _network, - }); - const p2tr = bitcoin.payments.p2tr({ - internalPubkey: toXOnly(keyPair.publicKey), - network: _network, - }); - - return { - keyPair, - privateKey, - publicKey: keyPair.publicKey.toString('hex'), - p2wpkh: { - scriptPubkey: p2wpkh.output!, - address: p2wpkh.address!, - pubkey: p2wpkh.pubkey!, - data: p2wpkh.data!, - }, - p2tr: { - scriptPubkey: p2tr.output!, - address: p2tr.address!, - pubkey: p2tr.pubkey!, - data: p2tr.data!, - }, - }; -} diff --git a/packages/btc/tests/shared/utils.ts b/packages/btc/tests/shared/utils.ts index e259f56b..b8598ca5 100644 --- a/packages/btc/tests/shared/utils.ts +++ b/packages/btc/tests/shared/utils.ts @@ -1,6 +1,15 @@ import { expect } from 'vitest'; -import { bitcoin } from '../../src'; -import { config } from './env'; +import { ECPairInterface } from 'ecpair'; +import { NetworkType, bitcoin, ECPair } from '../../src'; +import { toXOnly, remove0x, tweakSigner, isP2trScript, isP2wpkhScript } from '../../src'; +import { config, network, networkType, service } from './env'; + +/** + * Wait for a number of milliseconds. + */ +export function waitFor(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} /** * Estimate a network fee of a PSBT. @@ -22,9 +31,128 @@ export function expectPsbtFeeInRange(psbt: bitcoin.Psbt, feeRate?: number) { const estimated = calculatePsbtFee(psbt, feeRate); const paid = psbt.getFee(); + console.log('fee rate:', psbt.getFeeRate(), 'expected:', feeRate ?? config.feeRate); + console.log('fee:', paid, 'expected:', estimated); + const inputs = psbt.data.inputs.length; const diff = paid - estimated; expect(diff).toBeGreaterThanOrEqual(0); expect(diff).toBeLessThanOrEqual(diff + inputs); } + +/** + * Report transaction info in log + */ +export function logTransaction(tx: bitcoin.Transaction, networkType: NetworkType) { + const id = tx.getId(); + const hex = tx.toHex(); + const url = networkType === NetworkType.MAINNET ? 'https://mempool.space/tx' : 'https://mempool.space/testnet/tx'; + + console.log('id:', id); + console.log('hex:', hex); + console.log('explorer:', `${url}/${id}`); +} + +/** + * Create accounts in tests for signing transactions + */ +export interface Account { + keyPair: ECPairInterface; + privateKey: Buffer; + publicKey: string; + p2wpkh: { + scriptPubkey: Buffer; + address: string; + pubkey: Buffer; + data: Buffer[]; + }; + p2tr: { + scriptPubkey: Buffer; + address: string; + pubkey: Buffer; + data: Buffer[]; + }; +} +export function createAccount(props: { privateKey: string; network?: bitcoin.Network }): Account { + const privateKey = Buffer.from(remove0x(props.privateKey), 'hex'); + const keyPair = ECPair.fromPrivateKey(privateKey, { + network: props.network, + }); + + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: keyPair.publicKey, + network: props.network, + }); + const p2tr = bitcoin.payments.p2tr({ + internalPubkey: toXOnly(keyPair.publicKey), + network: props.network, + }); + + return { + keyPair, + privateKey, + publicKey: keyPair.publicKey.toString('hex'), + p2wpkh: { + scriptPubkey: p2wpkh.output!, + address: p2wpkh.address!, + pubkey: p2wpkh.pubkey!, + data: p2wpkh.data!, + }, + p2tr: { + scriptPubkey: p2tr.output!, + address: p2tr.address!, + pubkey: p2tr.pubkey!, + data: p2tr.data!, + }, + }; +} + +/** + * Sign and broadcast a transaction to the service + */ +export async function signAndBroadcastPsbt(props: { + psbt: bitcoin.Psbt; + account: Account; + feeRate?: number; + send?: boolean; +}): Promise<{ + tx: bitcoin.Transaction; + txId: string; + txHex: string; +}> { + 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); + } + } + }); + + psbt.finalizeAllInputs(); + expectPsbtFeeInRange(psbt, feeRate); + + const tx = psbt.extractTransaction(); + logTransaction(tx, networkType); + + if (send) { + const res = await service.sendBtcTransaction(tx.toHex()); + expect(res.txid).toEqual(tx.getId()); + } + + return { + tx, + txId: tx.getId(), + txHex: tx.toHex(), + }; +}