@btc-vision/transaction
Version:
OPNet transaction library allows you to create and sign transactions for the OPNet network.
538 lines (432 loc) • 21 kB
text/typescript
import { beforeAll, describe, expect, it } from 'vitest';
import { networks, opcodes, payments, script, toHex, toXOnly } from '@btc-vision/bitcoin';
import { type UniversalSigner } from '@btc-vision/ecpair';
import type { UTXO } from '../build/opnet.js';
import {
CancelTransaction,
FundingTransaction,
MLDSASecurityLevel,
Mnemonic,
TransactionBuilder,
} from '../build/opnet.js';
const network = networks.regtest;
const testMnemonic =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
function createTaprootUtxo(
addr: string,
value: bigint,
txId: string = '0'.repeat(64),
index: number = 0,
): UTXO {
const p2tr = payments.p2tr({ address: addr, network });
return {
transactionId: txId,
outputIndex: index,
value,
scriptPubKey: {
hex: toHex(p2tr.output as Uint8Array),
address: addr,
},
};
}
describe('addRefundOutput , deterministic fee estimation', () => {
let signer: UniversalSigner;
let taprootAddress: string;
beforeAll(() => {
const mnemonic = new Mnemonic(testMnemonic, '', network, MLDSASecurityLevel.LEVEL2);
const wallet = mnemonic.derive(0);
signer = wallet.keypair;
taprootAddress = wallet.p2tr;
});
// ----------------------------------------------------------------
// Helper: build a FundingTransaction and return the signed tx
// ----------------------------------------------------------------
function buildFunding(opts: {
utxoValue: bigint;
amount: bigint;
feeRate?: number;
feeUtxos?: UTXO[];
autoAdjustAmount?: boolean;
}) {
const utxo = createTaprootUtxo(taprootAddress, opts.utxoValue);
return new FundingTransaction({
signer,
network,
utxos: [utxo],
to: taprootAddress,
amount: opts.amount,
feeRate: opts.feeRate ?? 1,
priorityFee: 0n,
gasSatFee: 0n,
mldsaSigner: null,
...(opts.feeUtxos !== undefined && { feeUtxos: opts.feeUtxos }),
...(opts.autoAdjustAmount !== undefined && { autoAdjustAmount: opts.autoAdjustAmount }),
});
}
// ================================================================
// 1-call path: change output IS viable
// ================================================================
describe('1-call path (change output viable)', () => {
it('should produce a change output when plenty of funds remain', async () => {
const utxoValue = 100_000n;
const amount = 50_000n;
const tx = buildFunding({ utxoValue, amount });
const signed = await tx.signTransaction();
// Must have at least 2 outputs: send + change
expect(signed.outs.length).toBeGreaterThanOrEqual(2);
// Total output value + fee = total input value
const totalOut = signed.outs.reduce((sum, o) => sum + BigInt(o.value), 0n);
const fee = utxoValue - totalOut;
expect(fee).toBeGreaterThan(0n);
expect(totalOut).toBeLessThan(utxoValue);
});
it('should set transactionFee and overflowFees correctly', async () => {
const utxoValue = 200_000n;
const amount = 50_000n;
const tx = buildFunding({ utxoValue, amount });
await tx.signTransaction();
// transactionFee should be positive
expect(tx.transactionFee).toBeGreaterThan(0n);
// overflowFees should be the change amount
expect(tx.overflowFees).toBeGreaterThan(0n);
// overflowFees should be >= MINIMUM_DUST (330n)
expect(tx.overflowFees).toBeGreaterThanOrEqual(TransactionBuilder.MINIMUM_DUST);
// totalInput = amount + overflowFees + transactionFee
expect(tx.overflowFees + amount + tx.transactionFee).toBe(utxoValue);
});
it('should produce exact change when using multiple UTXOs', async () => {
const utxo1 = createTaprootUtxo(taprootAddress, 50_000n, '1'.repeat(64), 0);
const utxo2 = createTaprootUtxo(taprootAddress, 50_000n, '2'.repeat(64), 1);
const totalInput = 100_000n;
const amount = 30_000n;
const tx = new FundingTransaction({
signer,
network,
utxos: [utxo1, utxo2],
to: taprootAddress,
amount,
feeRate: 1,
priorityFee: 0n,
gasSatFee: 0n,
mldsaSigner: null,
});
const signed = await tx.signTransaction();
const totalOut = signed.outs.reduce((sum, o) => sum + BigInt(o.value), 0n);
const fee = totalInput - totalOut;
expect(fee).toBeGreaterThan(0n);
expect(tx.transactionFee).toBe(fee);
});
});
// ================================================================
// 2-call path: change output NOT viable (below dust)
// ================================================================
describe('2-call path (no change output)', () => {
it('should drop the change output when leftover is below dust', async () => {
// Choose amount so that leftover after fee is < 330 sats
// With feeRate=1 and a simple 1-in-1-out taproot tx (~110 vbytes),
// fee ~110 sats. So utxoValue = amount + fee + small_leftover
// e.g. 50_000 + 110 + 200 = 50_310 → leftover ~200 < 330 dust
const utxoValue = 50_310n;
const amount = 50_000n;
const tx = buildFunding({ utxoValue, amount });
const signed = await tx.signTransaction();
// Should have only 1 output (the send output, no change)
expect(signed.outs.length).toBe(1);
// The output should be the send amount
const firstOut = signed.outs[0];
expect(firstOut).toBeDefined();
expect(BigInt(firstOut?.value ?? 0)).toBe(amount);
// overflowFees should be 0 (no change output)
expect(tx.overflowFees).toBe(0n);
// Fee = all the leftover (absorbed by miner)
const totalOut = signed.outs.reduce((sum, o) => sum + BigInt(o.value), 0n);
expect(utxoValue - totalOut).toBeGreaterThan(0n);
});
it('should set transactionFee when there is no change output', async () => {
const utxoValue = 50_200n;
const amount = 50_000n;
const tx = buildFunding({ utxoValue, amount });
const signed = await tx.signTransaction();
// transactionFee should be set (the estimated fee without change)
expect(tx.transactionFee).toBeGreaterThan(0n);
expect(tx.overflowFees).toBe(0n);
// Only 1 output
expect(signed.outs.length).toBe(1);
});
});
// ================================================================
// Insufficient funds
// ================================================================
describe('insufficient funds', () => {
it('should throw when amount equals total UTXO value', async () => {
const utxoValue = 100_000n;
const tx = buildFunding({ utxoValue, amount: utxoValue });
await expect(tx.signTransaction()).rejects.toThrow(/Insufficient funds/);
});
it('should throw when amount exceeds total UTXO value', async () => {
const utxoValue = 50_000n;
const tx = buildFunding({ utxoValue, amount: 60_000n });
await expect(tx.signTransaction()).rejects.toThrow(/Insufficient funds/);
});
});
// ================================================================
// Tolerance: sendBack < 0 but totalInput > amountSpent
// ================================================================
describe('tolerance , effective fee is positive but less than estimated', () => {
it('should succeed when amount is close to totalInput leaving a small effective fee', async () => {
const utxoValue = 100_000n;
// Leave 200 sats for fees , less than the estimated fee at high rate
// but totalInput > amountSpent so it's valid
const amount = utxoValue - 200n;
const tx = buildFunding({ utxoValue, amount, feeRate: 10 });
const signed = await tx.signTransaction();
expect(signed.ins.length).toBeGreaterThan(0);
expect(signed.outs.length).toBeGreaterThan(0);
// Verify fee is exactly the 200 sats leftover
const totalOut = signed.outs.reduce((sum, o) => sum + BigInt(o.value), 0n);
expect(utxoValue - totalOut).toBe(200n);
});
it('should succeed with very small leftover (1 sat) as long as totalInput > amountSpent', async () => {
const utxoValue = 100_000n;
const amount = utxoValue - 1n;
const tx = buildFunding({ utxoValue, amount, feeRate: 5 });
const signed = await tx.signTransaction();
expect(signed.ins.length).toBeGreaterThan(0);
const totalOut = signed.outs.reduce((sum, o) => sum + BigInt(o.value), 0n);
expect(utxoValue - totalOut).toBe(1n);
});
});
// ================================================================
// CancelTransaction (amountSpent = 0n)
// ================================================================
describe('CancelTransaction , amountSpent = 0n', () => {
it('should not throw from addRefundOutput when amountSpent is 0', async () => {
const utxo = createTaprootUtxo(taprootAddress, 100_000n);
const compiledTargetScript = script.compile([
toXOnly(signer.publicKey),
opcodes.OP_CHECKSIG,
]);
const tx = new CancelTransaction({
signer,
network,
utxos: [utxo],
compiledTargetScript,
feeRate: 1,
mldsaSigner: null,
});
const signed = await tx.signTransaction();
expect(signed.ins.length).toBeGreaterThan(0);
expect(signed.outs.length).toBeGreaterThan(0);
expect(signed.virtualSize()).toBeGreaterThan(0);
});
it('should return all input value minus fee as change', async () => {
const utxoValue = 100_000n;
const utxo = createTaprootUtxo(taprootAddress, utxoValue);
const compiledTargetScript = script.compile([
toXOnly(signer.publicKey),
opcodes.OP_CHECKSIG,
]);
const tx = new CancelTransaction({
signer,
network,
utxos: [utxo],
compiledTargetScript,
feeRate: 1,
mldsaSigner: null,
});
const signed = await tx.signTransaction();
const totalOut = signed.outs.reduce((sum, o) => sum + BigInt(o.value), 0n);
const fee = utxoValue - totalOut;
// Fee should be small relative to total
expect(fee).toBeGreaterThan(0n);
expect(fee).toBeLessThan(utxoValue / 2n);
// overflowFees should equal the change output
expect(tx.overflowFees).toBe(totalOut);
});
it('should succeed with high fee rate (cancel is still valid)', async () => {
const utxo = createTaprootUtxo(taprootAddress, 100_000n);
const compiledTargetScript = script.compile([
toXOnly(signer.publicKey),
opcodes.OP_CHECKSIG,
]);
const tx = new CancelTransaction({
signer,
network,
utxos: [utxo],
compiledTargetScript,
feeRate: 50,
mldsaSigner: null,
});
const signed = await tx.signTransaction();
expect(signed.ins.length).toBeGreaterThan(0);
expect(signed.outs.length).toBeGreaterThan(0);
});
it('should throw when expectRefund is true but fees consume all input', async () => {
// Use a tiny UTXO so that fees eat everything , expectRefund
// (set by CancelTransaction) must reject this since the user
// would get nothing back.
const utxo = createTaprootUtxo(taprootAddress, 500n);
const compiledTargetScript = script.compile([
toXOnly(signer.publicKey),
opcodes.OP_CHECKSIG,
]);
const tx = new CancelTransaction({
signer,
network,
utxos: [utxo],
compiledTargetScript,
feeRate: 100, // very high fee rate on tiny UTXO
mldsaSigner: null,
});
await expect(tx.signTransaction()).rejects.toThrow(/Insufficient funds/);
});
});
// ================================================================
// feeUtxos , separate fee funding
// ================================================================
describe('feeUtxos , separate UTXOs for fees', () => {
it('should use feeUtxos to cover fees while preserving exact send amount', async () => {
const utxoValue = 50_000n;
const feeUtxoValue = 10_000n;
const amount = 50_000n; // exact match with primary UTXO
const feeUtxo = createTaprootUtxo(taprootAddress, feeUtxoValue, 'f'.repeat(64), 0);
const tx = buildFunding({
utxoValue,
amount,
feeUtxos: [feeUtxo],
});
const signed = await tx.signTransaction();
expect(signed.ins.length).toBe(2); // primary + fee UTXO
// The send output should be exactly the requested amount
const outputValues = signed.outs.map((o) => BigInt(o.value));
expect(outputValues).toContain(amount);
// Total outputs + fee = total inputs
const totalOut = signed.outs.reduce((sum, o) => sum + BigInt(o.value), 0n);
const totalIn = utxoValue + feeUtxoValue;
expect(totalOut + tx.transactionFee).toBeLessThanOrEqual(totalIn);
});
it('should produce a change output from feeUtxos leftover', async () => {
const utxoValue = 50_000n;
const feeUtxoValue = 50_000n; // generous fee UTXO
const amount = 50_000n;
const feeUtxo = createTaprootUtxo(taprootAddress, feeUtxoValue, 'f'.repeat(64), 0);
const tx = buildFunding({
utxoValue,
amount,
feeUtxos: [feeUtxo],
});
const signed = await tx.signTransaction();
// Should have change output from generous feeUtxo
expect(signed.outs.length).toBeGreaterThanOrEqual(2);
expect(tx.overflowFees).toBeGreaterThan(0n);
});
});
// ================================================================
// Fee rate variations
// ================================================================
describe('fee rate variations', () => {
it('should produce higher fees with higher fee rates', async () => {
const utxoValue = 200_000n;
const amount = 50_000n;
const txLow = buildFunding({ utxoValue, amount, feeRate: 1 });
const txHigh = buildFunding({ utxoValue, amount, feeRate: 10 });
await txLow.signTransaction();
await txHigh.signTransaction();
expect(txHigh.transactionFee).toBeGreaterThan(txLow.transactionFee);
});
it('should produce fee roughly proportional to fee rate', async () => {
const utxoValue = 200_000n;
const amount = 50_000n;
const tx1 = buildFunding({ utxoValue, amount, feeRate: 2 });
const tx5 = buildFunding({ utxoValue, amount, feeRate: 10 });
await tx1.signTransaction();
await tx5.signTransaction();
// Fee at rate 10 should be roughly 5x fee at rate 2
const ratio = Number(tx5.transactionFee) / Number(tx1.transactionFee);
expect(ratio).toBeGreaterThan(3); // allow some slack
expect(ratio).toBeLessThan(7);
});
});
// ================================================================
// Conservation of value
// ================================================================
describe('conservation of value', () => {
it('total output + fee should equal total input (with change)', async () => {
const utxoValue = 100_000n;
const amount = 30_000n;
const tx = buildFunding({ utxoValue, amount });
const signed = await tx.signTransaction();
const totalOut = signed.outs.reduce((sum, o) => sum + BigInt(o.value), 0n);
const fee = utxoValue - totalOut;
expect(fee).toBeGreaterThan(0n);
// The fee deduced from outputs should match transactionFee
// (when there's a change output, they match exactly)
expect(fee).toBe(tx.transactionFee);
});
it('total output + absorbed fee should equal total input (without change)', async () => {
// Set up so leftover is below dust
const utxoValue = 50_200n;
const amount = 50_000n;
const tx = buildFunding({ utxoValue, amount });
const signed = await tx.signTransaction();
const totalOut = signed.outs.reduce((sum, o) => sum + BigInt(o.value), 0n);
const actualFee = utxoValue - totalOut;
// Actual fee >= estimated fee (extra absorbed by miner)
expect(actualFee).toBeGreaterThanOrEqual(tx.transactionFee);
// Total is conserved
expect(totalOut + actualFee).toBe(utxoValue);
});
});
// ================================================================
// Edge cases around MINIMUM_DUST boundary
// ================================================================
describe('MINIMUM_DUST boundary', () => {
it('should create change output when leftover is exactly MINIMUM_DUST', async () => {
// We need: totalInput - amount - feeWithChange = 330
// This is tricky because fee depends on vsize. We'll use a large
// input and check the boundary condition through the public API.
const utxoValue = 200_000n;
const amount = 50_000n;
const tx = buildFunding({ utxoValue, amount });
const signed = await tx.signTransaction();
// With plenty of headroom, change should exist
expect(tx.overflowFees).toBeGreaterThanOrEqual(TransactionBuilder.MINIMUM_DUST);
expect(signed.outs.length).toBeGreaterThanOrEqual(2);
});
});
// ================================================================
// autoAdjustAmount interaction
// ================================================================
describe('autoAdjustAmount interaction with addRefundOutput', () => {
it('should auto-adjust amount and still compute correct fee', async () => {
const utxoValue = 100_000n;
const tx = buildFunding({
utxoValue,
amount: utxoValue, // exact match triggers auto-adjust
autoAdjustAmount: true,
feeRate: 2,
});
const signed = await tx.signTransaction();
const totalOut = signed.outs.reduce((sum, o) => sum + BigInt(o.value), 0n);
const fee = utxoValue - totalOut;
expect(fee).toBeGreaterThan(0n);
expect(totalOut).toBeLessThan(utxoValue);
expect(totalOut).toBeGreaterThan(0n);
});
it('should not auto-adjust when amount is well below totalInput', async () => {
const utxoValue = 100_000n;
const amount = 30_000n;
const tx = buildFunding({
utxoValue,
amount,
autoAdjustAmount: true,
});
const signed = await tx.signTransaction();
// Primary output should be exactly the requested amount
const outputValues = signed.outs.map((o) => BigInt(o.value));
expect(outputValues).toContain(amount);
// Should still have a change output
expect(signed.outs.length).toBeGreaterThanOrEqual(2);
});
});
});