diff --git a/packages/core/__tests__/dlc/CoinSelect.spec.ts b/packages/core/__tests__/dlc/CoinSelect.spec.ts index 72b51e73..d88cd85f 100644 --- a/packages/core/__tests__/dlc/CoinSelect.spec.ts +++ b/packages/core/__tests__/dlc/CoinSelect.spec.ts @@ -1,7 +1,13 @@ import { Value } from '@node-dlc/bitcoin'; import { expect } from 'chai'; -import { dualFees, dualFundingCoinSelect, UTXO } from '../../lib'; +import { + dualFees, + dualFundingCoinSelect, + dustThreshold, + getMaxWitnessLen, + UTXO, +} from '../../lib'; const getUtxos = (totalCollateral: bigint, numUtxos = 1) => { const utxos: UTXO[] = []; @@ -174,5 +180,58 @@ describe('CoinSelect', () => { expect(inputs.length).to.be.lessThan(10); expect(fee).to.equal(BigInt(687)); }); + + it('should calculate lower fees for taproot inputs', () => { + const feeRate = BigInt(450); + const p2wpkhFee = dualFees(feeRate, 1, 1); + const p2trFee = dualFees(feeRate, [getMaxWitnessLen('p2tr')], 1); + + expect(p2trFee < p2wpkhFee).to.equal(true); + }); + + it('should select taproot UTXOs using taproot witness costs', () => { + const feeRate = BigInt(10); + const offerCollateral = Value.fromSats(20000); + const p2trAddress = + 'bcrt1pqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqpqqenm'; + const p2trFee = dualFees(feeRate, [getMaxWitnessLen('p2tr')], 1); + const p2wpkhFee = dualFees(feeRate, [getMaxWitnessLen('p2wpkh')], 1); + const changeBuffer = dustThreshold(feeRate); + const p2trRequired = offerCollateral.sats + p2trFee + changeBuffer; + const p2wpkhRequired = offerCollateral.sats + p2wpkhFee + changeBuffer; + + // Value sits above Taproot requirements but below P2WPKH requirements. + const p2trUtxo: UTXO = { + address: p2trAddress, + txid: 'c7bf12ac16aba1cf6c7769117294853453f7da3006363dfe4e8979847e32f7e1', + value: Number(p2trRequired), + vout: 0, + scriptType: 'p2tr', + }; + + const p2wpkhUtxo: UTXO = { + ...p2trUtxo, + address: 'bcrt1qjzut0906d9sk4hml4k6sz6cssljktf4c7yl80f', + scriptType: 'p2wpkh', + }; + + expect(p2trRequired < p2wpkhRequired).to.equal(true); + expect(BigInt(p2trUtxo.value)).to.equal(p2trRequired); + expect(BigInt(p2trUtxo.value) < p2wpkhRequired).to.equal(true); + + const taprootSelection = dualFundingCoinSelect( + [p2trUtxo], + [offerCollateral.sats], + feeRate, + ); + const p2wpkhSelection = dualFundingCoinSelect( + [p2wpkhUtxo], + [offerCollateral.sats], + feeRate, + ); + + expect(taprootSelection.inputs.length).to.equal(1); + expect(p2wpkhSelection.inputs.length).to.equal(0); + }); }); }); diff --git a/packages/core/__tests__/dlc/TxFinalizer.spec.ts b/packages/core/__tests__/dlc/TxFinalizer.spec.ts new file mode 100644 index 00000000..3cb3a9b8 --- /dev/null +++ b/packages/core/__tests__/dlc/TxFinalizer.spec.ts @@ -0,0 +1,53 @@ +import { expect } from 'chai'; + +import { + DEFAULT_MAX_WITNESS_LEN, + getFinalizerByCount, + getMaxWitnessLen, +} from '../../lib'; + +describe('TxFinalizer', () => { + describe('getFinalizerByCount', () => { + it('should default synthetic inputs to p2wpkh witness length', () => { + const defaultFinalizer = getFinalizerByCount(BigInt(1), 1, 1, 1); + const explicitFinalizer = getFinalizerByCount( + BigInt(1), + 1, + 1, + 1, + DEFAULT_MAX_WITNESS_LEN, + DEFAULT_MAX_WITNESS_LEN, + ); + + expect(defaultFinalizer.offerFundingFee).to.equal( + explicitFinalizer.offerFundingFee, + ); + }); + + it('should calculate lower funding fees for p2tr synthetic inputs', () => { + const feeRate = BigInt(10); + const p2wpkhFinalizer = getFinalizerByCount(feeRate, 1, 1, 1); + const p2trFinalizer = getFinalizerByCount( + feeRate, + 1, + 1, + 1, + getMaxWitnessLen('p2tr'), + getMaxWitnessLen('p2tr'), + ); + + expect( + p2trFinalizer.offerFundingFee < p2wpkhFinalizer.offerFundingFee, + ).to.equal(true); + expect( + p2trFinalizer.acceptFundingFee < p2wpkhFinalizer.acceptFundingFee, + ).to.equal(true); + }); + + it('should reject mismatched witness length arrays', () => { + expect(() => + getFinalizerByCount(BigInt(1), 2, 1, 1, [getMaxWitnessLen('p2tr')]), + ).to.throw('Expected 2 witness lengths, got 1'); + }); + }); +}); diff --git a/packages/core/__tests__/dlc/finance/CsoInfo.spec.ts b/packages/core/__tests__/dlc/finance/CsoInfo.spec.ts index 8f3775ab..3103e4ce 100644 --- a/packages/core/__tests__/dlc/finance/CsoInfo.spec.ts +++ b/packages/core/__tests__/dlc/finance/CsoInfo.spec.ts @@ -701,7 +701,7 @@ describe('CsoInfo', () => { expect(actualNormalizedMaxGain.sats).to.equal( expectedNormalizedMaxGain.sats, ); // TODO: Fix issue with this line - expect(minPayout).to.equal(BigInt(121200)); + expect(minPayout).to.equal(BigInt(121700)); expect(maxPayout).to.equal(collateral.sats); expect(actualContractSize.sats).to.equal(contractSize.sats); expect(actualOfferCollateral.sats).to.equal( @@ -785,7 +785,7 @@ describe('CsoInfo', () => { expect(actualMaxLossForContractSize.sats).to.equal( expectedMaxLossForContractSize.sats, ); - expect(minPayout).to.equal(BigInt(121200)); + expect(minPayout).to.equal(BigInt(121700)); expect(maxPayout).to.equal(collateral.sats); expect(actualContractSize.sats).to.equal(contractSize.sats); expect(actualOfferCollateral.sats).to.equal( @@ -869,7 +869,7 @@ describe('CsoInfo', () => { expect(actualMaxLossForContractSize.sats).to.equal( expectedMaxLossForContractSize.sats, ); - expect(minPayout).to.equal(BigInt(121200)); + expect(minPayout).to.equal(BigInt(121700)); expect(maxPayout).to.equal(collateral.sats); expect(actualContractSize.sats).to.equal(contractSize.sats); expect(actualOfferCollateral.sats).to.equal( diff --git a/packages/core/lib/dlc/CoinSelect.ts b/packages/core/lib/dlc/CoinSelect.ts index 76013744..a8753255 100644 --- a/packages/core/lib/dlc/CoinSelect.ts +++ b/packages/core/lib/dlc/CoinSelect.ts @@ -1,6 +1,11 @@ -import { FundingInput } from '@node-dlc/messaging'; - -import { DualFundingTxFinalizer } from './TxFinalizer'; +import { + DEFAULT_MAX_WITNESS_LEN, + DualFundingTxFinalizer, + FundingInputScriptType, + fundingInputVBytes, + fundingInputWithWitnessLen, + getMaxWitnessLen, +} from './TxFinalizer'; export interface UTXO { txid: string; @@ -8,43 +13,79 @@ export interface UTXO { value: number; address: string; derivationPath?: string; + scriptPubKey?: Buffer | string; + scriptType?: FundingInputScriptType; } -const TX_INPUT_SIZE = { - LEGACY: 148, - P2SH: 92, - BECH32: 69, +const scriptPubKeyBuffer = (scriptPubKey: Buffer | string): Buffer => { + return Buffer.isBuffer(scriptPubKey) + ? scriptPubKey + : Buffer.from(scriptPubKey, 'hex'); }; -const inputBytes = () => { - return BigInt(TX_INPUT_SIZE.BECH32); +const scriptTypeFromUtxo = (utxo?: UTXO): FundingInputScriptType => { + if (!utxo) return 'p2wpkh'; + + if (utxo.scriptType) return utxo.scriptType; + + if (utxo.scriptPubKey) { + const scriptPubKey = scriptPubKeyBuffer(utxo.scriptPubKey); + if ( + scriptPubKey.length === 34 && + scriptPubKey[0] === 0x51 && + scriptPubKey[1] === 0x20 + ) + return 'p2tr'; + if ( + scriptPubKey.length === 34 && + scriptPubKey[0] === 0x00 && + scriptPubKey[1] === 0x20 + ) + return 'p2wsh'; + } + + if (/^(bc|tb|bcrt)1p/i.test(utxo.address)) return 'p2tr'; + + return 'p2wpkh'; }; -export const dustThreshold = (feeRate: bigint): bigint => { - return BigInt(inputBytes()) * feeRate; +const inputBytes = (utxo?: UTXO): bigint => { + return fundingInputVBytes(getMaxWitnessLen(scriptTypeFromUtxo(utxo))); +}; + +export const dustThreshold = (feeRate: bigint, utxo?: UTXO): bigint => { + return inputBytes(utxo) * feeRate; }; // order by descending value, minus the inputs approximate fee const utxoScore = (x: UTXO, feeRate: bigint): bigint => { - return BigInt(x.value) - feeRate * inputBytes(); + return BigInt(x.value) - feeRate * inputBytes(x); }; +const witnessLensFromInputCount = ( + numInputsOrWitnessLens: number | number[], +): number[] => + Array.isArray(numInputsOrWitnessLens) + ? numInputsOrWitnessLens + : Array.from( + { length: numInputsOrWitnessLens }, + () => DEFAULT_MAX_WITNESS_LEN, + ); + export const dualFees = ( feeRate: bigint, - numInputs: number, + numInputsOrWitnessLens: number | number[], numContracts: number, ): bigint => { - const input = new FundingInput(); - input.maxWitnessLen = 108; - input.redeemScript = Buffer.from('', 'hex'); - const fakeSPK = Buffer.from( '0014663117d27e78eb432505180654e603acb30e8a4a', 'hex', ); - const offerInputs = Array.from({ length: numInputs }, () => input); - const acceptInputs = Array.from({ length: 1 }, () => input); + const offerInputs = witnessLensFromInputCount(numInputsOrWitnessLens).map( + fundingInputWithWitnessLen, + ); + const acceptInputs = [fundingInputWithWitnessLen(DEFAULT_MAX_WITNESS_LEN)]; return new DualFundingTxFinalizer( offerInputs, @@ -85,7 +126,7 @@ export const dualFundingCoinSelect = ( for (let i = 0; i < utxos.length; ++i) { const utxo = utxos[i]; - const utxoBytes = inputBytes(); + const utxoBytes = inputBytes(utxo); const utxoFee = feeRate * utxoBytes; const utxoValue = utxo.value; @@ -102,7 +143,11 @@ export const dualFundingCoinSelect = ( inAccum += utxoValue; inputs.push(utxo); - const fee = dualFees(feeRate, inputs.length, collaterals.length); + const fee = dualFees( + feeRate, + inputs.map((input) => getMaxWitnessLen(scriptTypeFromUtxo(input))), + collaterals.length, + ); // go again? if (inAccum < outAccum + fee) continue; diff --git a/packages/core/lib/dlc/TxFinalizer.ts b/packages/core/lib/dlc/TxFinalizer.ts index b91f08e5..ba48483a 100644 --- a/packages/core/lib/dlc/TxFinalizer.ts +++ b/packages/core/lib/dlc/TxFinalizer.ts @@ -4,6 +4,60 @@ import { Decimal } from 'decimal.js'; const BATCH_FUND_TX_BASE_WEIGHT = 42; const FUNDING_OUTPUT_SIZE = 43; +export type FundingInputScriptType = 'p2wpkh' | 'p2wsh' | 'p2tr'; + +export const FUNDING_INPUT_MAX_WITNESS_LEN: Record< + FundingInputScriptType, + number +> = { + p2wpkh: 108, + p2wsh: 108, + p2tr: 66, +}; + +export const DEFAULT_MAX_WITNESS_LEN = FUNDING_INPUT_MAX_WITNESS_LEN.p2wpkh; + +export const getMaxWitnessLen = ( + scriptType: FundingInputScriptType = 'p2wpkh', +): number => FUNDING_INPUT_MAX_WITNESS_LEN[scriptType]; + +export const fundingInputVBytes = ( + maxWitnessLen = DEFAULT_MAX_WITNESS_LEN, + scriptSigLength = 0, +): bigint => + BigInt( + new Decimal(164 + maxWitnessLen + scriptSigLength).div(4).ceil().toNumber(), + ); + +export const fundingInputWithWitnessLen = ( + maxWitnessLen: number, +): FundingInput => { + const input = new FundingInput(); + input.maxWitnessLen = maxWitnessLen; + input.redeemScript = Buffer.from('', 'hex'); + return input; +}; + +const fundingInputsWithWitnessLens = ( + count: number, + maxWitnessLenOrLens: number | number[] = DEFAULT_MAX_WITNESS_LEN, +): FundingInput[] => { + if ( + Array.isArray(maxWitnessLenOrLens) && + maxWitnessLenOrLens.length !== count + ) { + throw new Error( + `Expected ${count} witness lengths, got ${maxWitnessLenOrLens.length}`, + ); + } + + const witnessLens = Array.isArray(maxWitnessLenOrLens) + ? maxWitnessLenOrLens + : Array.from({ length: count }, () => maxWitnessLenOrLens); + + return witnessLens.map(fundingInputWithWitnessLen); +}; + export class DualFundingTxFinalizer { constructor( readonly offerInputs: FundingInput[], @@ -175,19 +229,19 @@ export const getFinalizer = ( offerInputs?: FundingInput[], acceptInputs?: FundingInput[], numContracts?: number, + offerMaxWitnessLen: number | number[] = DEFAULT_MAX_WITNESS_LEN, + acceptMaxWitnessLen: number | number[] = DEFAULT_MAX_WITNESS_LEN, ): DualFundingTxFinalizer => { - const input = new FundingInput(); - input.maxWitnessLen = 108; - input.redeemScript = Buffer.from('', 'hex'); - const fakeSPK = Buffer.from( '0014663117d27e78eb432505180654e603acb30e8a4a', 'hex', ); - offerInputs = offerInputs || Array.from({ length: 1 }, () => input); + offerInputs = + offerInputs || fundingInputsWithWitnessLens(1, offerMaxWitnessLen); - acceptInputs = acceptInputs || Array.from({ length: 1 }, () => input); + acceptInputs = + acceptInputs || fundingInputsWithWitnessLens(1, acceptMaxWitnessLen); return new DualFundingTxFinalizer( offerInputs, @@ -206,12 +260,16 @@ export const getFinalizerByCount = ( numOfferInputs: number, numAcceptInputs: number, numContracts: number, + offerMaxWitnessLen: number | number[] = DEFAULT_MAX_WITNESS_LEN, + acceptMaxWitnessLen: number | number[] = DEFAULT_MAX_WITNESS_LEN, ): DualFundingTxFinalizer => { - const input = new FundingInput(); - input.maxWitnessLen = 108; - input.redeemScript = Buffer.from('', 'hex'); - - const offerInputs = Array.from({ length: numOfferInputs }, () => input); - const acceptInputs = Array.from({ length: numAcceptInputs }, () => input); + const offerInputs = fundingInputsWithWitnessLens( + numOfferInputs, + offerMaxWitnessLen, + ); + const acceptInputs = fundingInputsWithWitnessLens( + numAcceptInputs, + acceptMaxWitnessLen, + ); return getFinalizer(feeRate, offerInputs, acceptInputs, numContracts); };