Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion packages/core/__tests__/dlc/CoinSelect.spec.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];
Expand Down Expand Up @@ -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);
});
});
});
53 changes: 53 additions & 0 deletions packages/core/__tests__/dlc/TxFinalizer.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
6 changes: 3 additions & 3 deletions packages/core/__tests__/dlc/finance/CsoInfo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
87 changes: 66 additions & 21 deletions packages/core/lib/dlc/CoinSelect.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,91 @@
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;
vout: number;
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,
Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand Down
82 changes: 70 additions & 12 deletions packages/core/lib/dlc/TxFinalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
w3irdrobot marked this conversation as resolved.

return witnessLens.map(fundingInputWithWitnessLen);
};

export class DualFundingTxFinalizer {
constructor(
readonly offerInputs: FundingInput[],
Expand Down Expand Up @@ -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,
Expand All @@ -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);
};
Loading