@node-dlc/core
Version:
425 lines (352 loc) • 15.4 kB
text/typescript
import { Value } from '@node-dlc/bitcoin';
import {
DlcAcceptWithoutSigs,
DlcOffer,
FundingInput,
} from '@node-dlc/messaging';
import { expect } from 'chai';
import { BatchDlcTxBuilder, DlcTxBuilder, DUST_LIMIT } from '../../lib';
describe('TxBuilder', () => {
// Helper function to create test FundingInput
const createTestFundingInput = (
value: bigint,
serialId = 1,
maxWitnessLen = 108,
): FundingInput => {
// Create proper transaction hex with correct output value encoding
// Based on working format from existing tests
const valueBuffer = Buffer.allocUnsafe(8);
valueBuffer.writeBigUInt64LE(value, 0);
const valueHex = valueBuffer.toString('hex');
// Use a working transaction format: version + input + outputs + locktime
const prevTxHex =
'02000000' + // version
'01' + // num inputs
'f58f85b356ad5bb5b6d0ef3eb863be8a6cb95e08e1e9e92885b4b22e7e51eb9d' + // input txid
'00000000' + // input vout
'00' + // script sig length
'ffffffff' + // sequence
'01' + // num outputs
valueHex + // output value (8 bytes, little endian)
'2200201234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + // P2WSH script
'00000000'; // locktime
return FundingInput.fromJSON({
inputSerialId: serialId,
prevTx: prevTxHex,
prevTxVout: 0,
sequence: 4294967295,
maxWitnessLen,
redeemScript: '',
});
};
// Helper function to create test DlcOffer
const createTestDlcOffer = (
offerCollateral: bigint,
fundingInputs: FundingInput[],
feeRate = BigInt(1),
): DlcOffer => {
const offer = new DlcOffer();
offer.offerCollateral = offerCollateral;
offer.fundingInputs = fundingInputs;
offer.feeRatePerVb = feeRate;
// Valid compressed public key (Bitcoin Generator Point)
offer.fundingPubkey = Buffer.from(
'0279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798',
'hex',
);
offer.payoutSpk = Buffer.from(
'0014' + Buffer.alloc(20).toString('hex'),
'hex',
);
offer.changeSpk = Buffer.from(
'0014' + Buffer.alloc(20).toString('hex'),
'hex',
);
offer.fundOutputSerialId = BigInt(1);
offer.payoutSerialId = BigInt(2);
offer.changeSerialId = BigInt(3);
return offer;
};
// Helper function to create test DlcAccept
const createTestDlcAccept = (
acceptCollateral: bigint,
fundingInputs: FundingInput[] = [],
): DlcAcceptWithoutSigs => {
return new DlcAcceptWithoutSigs(
1, // protocolVersion
Buffer.alloc(32), // temporaryContractId
acceptCollateral,
// Valid compressed public key (different from offer)
Buffer.from(
'02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9',
'hex',
),
Buffer.from('0014' + Buffer.alloc(20).toString('hex'), 'hex'), // payoutSpk
BigInt(4), // payoutSerialId
fundingInputs,
Buffer.from('0014' + Buffer.alloc(20).toString('hex'), 'hex'), // changeSpk
BigInt(5), // changeSerialId
);
};
describe('DUST_LIMIT', () => {
it('should export the dust limit constant', () => {
expect(DUST_LIMIT).to.equal(BigInt(1000));
});
});
describe('BatchDlcTxBuilder.calculateMaxCollateral', () => {
it('should calculate maximum collateral for single input', () => {
const fundingInputs = [createTestFundingInput(BigInt(1000000))];
const feeRate = BigInt(1);
const maxCollateral = BatchDlcTxBuilder.calculateMaxCollateral(
fundingInputs,
feeRate,
1,
);
// Should be input value minus fees
expect(Number(maxCollateral)).to.be.greaterThan(0);
expect(Number(maxCollateral)).to.be.lessThan(1000000);
});
it('should calculate maximum collateral for multiple inputs', () => {
const fundingInputs = [
createTestFundingInput(BigInt(500000)),
createTestFundingInput(BigInt(300000), 2),
];
const feeRate = BigInt(1);
const maxCollateral = BatchDlcTxBuilder.calculateMaxCollateral(
fundingInputs,
feeRate,
1,
);
// Should be total input value minus fees
expect(Number(maxCollateral)).to.be.greaterThan(0);
expect(Number(maxCollateral)).to.be.lessThan(800000);
});
it('should account for higher fee rates', () => {
const fundingInputs = [createTestFundingInput(BigInt(1000000))];
const lowFeeRate = BigInt(1);
const highFeeRate = BigInt(10);
const maxCollateralLowFee = BatchDlcTxBuilder.calculateMaxCollateral(
fundingInputs,
lowFeeRate,
1,
);
const maxCollateralHighFee = BatchDlcTxBuilder.calculateMaxCollateral(
fundingInputs,
highFeeRate,
1,
);
// Higher fee rate should result in lower max collateral
expect(Number(maxCollateralHighFee)).to.be.lessThan(
Number(maxCollateralLowFee),
);
});
it('should account for multiple contracts', () => {
const fundingInputs = [createTestFundingInput(BigInt(1000000))];
const feeRate = BigInt(1);
const maxCollateralSingle = BatchDlcTxBuilder.calculateMaxCollateral(
fundingInputs,
feeRate,
1,
);
const maxCollateralMultiple = BatchDlcTxBuilder.calculateMaxCollateral(
fundingInputs,
feeRate,
3,
);
// Multiple contracts should result in lower max collateral due to higher fees
expect(Number(maxCollateralMultiple)).to.be.lessThan(
Number(maxCollateralSingle),
);
});
it('should return zero when fees exceed input value', () => {
const fundingInputs = [createTestFundingInput(BigInt(100))]; // Very small input
const feeRate = BigInt(1000); // Very high fee rate
const maxCollateral = BatchDlcTxBuilder.calculateMaxCollateral(
fundingInputs,
feeRate,
1,
);
expect(maxCollateral).to.equal(BigInt(0));
});
it('should handle empty funding inputs', () => {
const fundingInputs: FundingInput[] = [];
const feeRate = BigInt(1);
const maxCollateral = BatchDlcTxBuilder.calculateMaxCollateral(
fundingInputs,
feeRate,
1,
);
expect(maxCollateral).to.equal(BigInt(0));
});
});
describe('BatchDlcTxBuilder.buildFundingTransaction - Dust Filtering', () => {
it('should create change outputs when above dust threshold', () => {
// Create inputs with sufficient value for both collateral and change
const offerInput = createTestFundingInput(BigInt(1010000)); // 1.01 BTC in sats
const acceptInput = createTestFundingInput(BigInt(1005000), 2); // 1.005 BTC in sats
const offer = createTestDlcOffer(BigInt(1000000), [offerInput]); // 1 BTC collateral
const accept = createTestDlcAccept(BigInt(1000000), [acceptInput]); // 1 BTC collateral
const builder = new BatchDlcTxBuilder([offer], [accept]);
const tx = builder.buildFundingTransaction();
// Should have 3 outputs: funding output + 2 change outputs
expect(tx.outputs.length).to.equal(3);
// Verify all outputs have value above zero
tx.outputs.forEach((output) => {
expect(Number(output.value.sats)).to.be.greaterThan(0);
});
});
it('should filter out dust change outputs', () => {
// Create inputs that result in dust change
const offerInput = createTestFundingInput(BigInt(1000500)); // Just above collateral + fees
const acceptInput = createTestFundingInput(BigInt(1000300), 2); // Just above collateral + fees
const offer = createTestDlcOffer(BigInt(1000000), [offerInput]); // 1 BTC collateral
const accept = createTestDlcAccept(BigInt(1000000), [acceptInput]); // 1 BTC collateral
const builder = new BatchDlcTxBuilder([offer], [accept]);
const tx = builder.buildFundingTransaction();
// Should have only 1 output: just the funding output (no change outputs due to dust)
expect(tx.outputs.length).to.be.lessThan(3);
expect(tx.outputs.length).to.be.at.least(1);
// The funding output should be present
const fundingOutputValue = tx.outputs[0].value.sats;
expect(Number(fundingOutputValue)).to.be.greaterThan(2000000); // Should contain both collaterals + future fees
});
it('should handle exact amount scenarios (no change)', () => {
// Use calculateMaxCollateral to determine exact amounts
const offerInput = createTestFundingInput(BigInt(1000000));
const maxCollateral = BatchDlcTxBuilder.calculateMaxCollateral(
[offerInput],
BigInt(1),
1,
);
const offer = createTestDlcOffer(maxCollateral, [offerInput]);
const accept = createTestDlcAccept(BigInt(0), []); // Single-funded DLC
const builder = new BatchDlcTxBuilder([offer], [accept]);
const tx = builder.buildFundingTransaction();
// Should have only 1 output: the funding output
expect(tx.outputs.length).to.equal(1);
// Verify the funding output value
const fundingOutputValue = tx.outputs[0].value.sats;
expect(Number(fundingOutputValue)).to.be.greaterThan(0);
});
it('should handle mixed scenarios (one party has change, other has dust)', () => {
// Offer has significant change, accept has dust change
const offerInput = createTestFundingInput(BigInt(1050000)); // Large change
const acceptInput = createTestFundingInput(BigInt(1000300), 2); // Dust change
const offer = createTestDlcOffer(BigInt(1000000), [offerInput]);
const accept = createTestDlcAccept(BigInt(1000000), [acceptInput]);
const builder = new BatchDlcTxBuilder([offer], [accept]);
const tx = builder.buildFundingTransaction();
// Should have 2 outputs: funding output + offer change output (accept change filtered)
expect(tx.outputs.length).to.equal(2);
// Find the change output (not the largest one which should be funding)
const sortedOutputs = tx.outputs.sort((a, b) =>
Number(b.value.sats - a.value.sats),
);
const changeOutput = sortedOutputs[1];
// Verify change output is above dust threshold
expect(Number(changeOutput.value.sats)).to.be.at.least(
Number(DUST_LIMIT),
);
});
});
describe('BatchDlcTxBuilder.buildFundingTransaction - Error Handling', () => {
it('should throw error when offerer has insufficient funds', () => {
const offerInput = createTestFundingInput(BigInt(500000)); // Insufficient
const acceptInput = createTestFundingInput(BigInt(1000000), 2);
const offer = createTestDlcOffer(BigInt(1000000), [offerInput]); // Requires more than input
const accept = createTestDlcAccept(BigInt(500000), [acceptInput]);
const builder = new BatchDlcTxBuilder([offer], [accept]);
expect(() => builder.buildFundingTransaction()).to.throw(
/Insufficient funds for offerer/,
);
});
it('should throw error when accepter has insufficient funds', () => {
const offerInput = createTestFundingInput(BigInt(1000000));
const acceptInput = createTestFundingInput(BigInt(300000), 2); // Insufficient
const offer = createTestDlcOffer(BigInt(500000), [offerInput]);
const accept = createTestDlcAccept(BigInt(1000000), [acceptInput]); // Requires more than input
const builder = new BatchDlcTxBuilder([offer], [accept]);
expect(() => builder.buildFundingTransaction()).to.throw(
/Insufficient funds for accepter/,
);
});
it('should provide clear error messages with actual values', () => {
const offerInput = createTestFundingInput(BigInt(500000));
const offer = createTestDlcOffer(BigInt(1000000), [offerInput]);
const accept = createTestDlcAccept(BigInt(0), []);
const builder = new BatchDlcTxBuilder([offer], [accept]);
try {
builder.buildFundingTransaction();
expect.fail('Should have thrown an error');
} catch (error) {
expect(error.message).to.include('need');
expect(error.message).to.include('have');
expect(error.message).to.include('500000');
}
});
});
describe('DlcTxBuilder', () => {
it('should build funding transaction for single DLC', () => {
const offerInput = createTestFundingInput(BigInt(1000000));
const acceptInput = createTestFundingInput(BigInt(1000000), 2);
const offer = createTestDlcOffer(BigInt(500000), [offerInput]);
const accept = createTestDlcAccept(BigInt(500000), [acceptInput]);
const builder = new DlcTxBuilder(offer, accept);
const tx = builder.buildFundingTransaction();
expect(tx.outputs.length).to.be.at.least(1);
expect(tx.inputs.length).to.equal(2);
});
});
describe('Integration Tests', () => {
it('should handle DLC splicing scenario with exact amounts', () => {
// Simulate splicing: use a DLC funding output + additional UTXO
const dlcFundingInput = createTestFundingInput(BigInt(970332)); // Existing DLC output
const additionalInput = createTestFundingInput(BigInt(100000), 2); // Additional UTXO
const totalInputs = [dlcFundingInput, additionalInput];
const maxCollateral = BatchDlcTxBuilder.calculateMaxCollateral(
totalInputs,
BigInt(1),
1,
);
// Create offer using exact max collateral (no change scenario)
const offer = createTestDlcOffer(maxCollateral, totalInputs);
const accept = createTestDlcAccept(BigInt(0), []); // Single-funded
const builder = new BatchDlcTxBuilder([offer], [accept]);
const tx = builder.buildFundingTransaction();
// Should successfully create transaction with no change outputs
expect(tx.outputs.length).to.equal(1); // Only funding output
expect(tx.inputs.length).to.equal(2); // Both inputs used
// Verify the transaction balances
const totalInputValue = BigInt(970332) + BigInt(100000);
const outputValue = tx.outputs[0].value.sats;
// Output should be less than input (due to fees) but close
expect(Number(outputValue)).to.be.lessThan(Number(totalInputValue));
expect(Number(outputValue)).to.be.greaterThan(
Number(totalInputValue - BigInt(10000)),
); // Reasonable fee range
});
it('should handle batch DLC scenarios', () => {
const offerInput1 = createTestFundingInput(BigInt(500000));
const offerInput2 = createTestFundingInput(BigInt(500000), 2);
const acceptInput = createTestFundingInput(BigInt(800000), 3);
const offer1 = createTestDlcOffer(BigInt(400000), [
offerInput1,
offerInput2,
]);
const offer2 = createTestDlcOffer(BigInt(400000), [
offerInput1,
offerInput2,
]);
const accept1 = createTestDlcAccept(BigInt(300000), [acceptInput]);
const accept2 = createTestDlcAccept(BigInt(300000), [acceptInput]);
const builder = new BatchDlcTxBuilder(
[offer1, offer2],
[accept1, accept2],
);
const tx = builder.buildFundingTransaction();
// Should have 2 funding outputs + change outputs (if any)
expect(tx.outputs.length).to.be.at.least(2);
expect(tx.inputs.length).to.equal(3); // All unique inputs
});
});
});