@btc-vision/transaction
Version:
OPNet transaction library allows you to create and sign transactions for the OPNet network.
483 lines (411 loc) • 17.9 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 { QuantumBIP32Interface } from '@btc-vision/bip32';
import type { IChallengeSolution, IChallengeVerification, UTXO } from '../build/opnet.js';
import {
Address,
CancelTransaction,
CustomScriptTransaction,
DeploymentTransaction,
EcKeyPair,
FundingTransaction,
InteractionTransaction,
MLDSASecurityLevel,
Mnemonic,
MultiSignTransaction,
} from '../build/opnet.js';
const network = networks.regtest;
const testMnemonic =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
/**
* Helper: create a taproot UTXO for an address
*/
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,
},
};
}
/**
* Helper: create a minimal mock IChallengeSolution
*/
function createMockChallenge(publicKey: Address): IChallengeSolution {
const verification: IChallengeVerification = {
epochHash: new Uint8Array(32),
epochRoot: new Uint8Array(32),
targetHash: new Uint8Array(32),
targetChecksum: new Uint8Array(32),
startBlock: 0n,
endBlock: 100n,
proofs: [],
};
return {
epochNumber: 1n,
publicKey,
solution: new Uint8Array(32),
salt: new Uint8Array(32),
graffiti: new Uint8Array(32),
difficulty: 1,
verification,
verifySubmissionSignature: () => true,
getSubmission: () => undefined,
toRaw: () => {
throw new Error('Not implemented in mock');
},
verify: () => true,
toBuffer: () => new Uint8Array(0),
toHex: () => '',
calculateSolution: () => new Uint8Array(32),
checkDifficulty: () => ({ valid: true, difficulty: 1 }),
getMiningTargetBlock: () => null,
};
}
describe('Transaction Builders - End-to-End', () => {
let signer: UniversalSigner;
let taprootAddress: string;
let walletAddress: Address;
let quantumRoot: QuantumBIP32Interface;
beforeAll(() => {
const mnemonic = new Mnemonic(testMnemonic, '', network, MLDSASecurityLevel.LEVEL2);
const wallet = mnemonic.derive(0);
signer = wallet.keypair;
walletAddress = wallet.address;
taprootAddress = wallet.p2tr;
quantumRoot = mnemonic.getQuantumRoot();
});
describe('FundingTransaction', () => {
it('should build and sign a basic funding transaction', async () => {
const utxo = createTaprootUtxo(taprootAddress, 100_000n);
const tx = new FundingTransaction({
signer,
network,
utxos: [utxo],
to: taprootAddress,
amount: 50_000n,
feeRate: 1,
priorityFee: 0n,
gasSatFee: 0n,
mldsaSigner: null,
});
const signed = await tx.signTransaction();
expect(signed.ins.length).toBeGreaterThan(0);
expect(signed.outs.length).toBeGreaterThan(0);
expect(signed.toHex()).toBeTruthy();
expect(signed.virtualSize()).toBeGreaterThan(0);
});
it('should produce multiple outputs when splitInputsInto > 1', async () => {
const utxo = createTaprootUtxo(taprootAddress, 200_000n);
const tx = new FundingTransaction({
signer,
network,
utxos: [utxo],
to: taprootAddress,
amount: 100_000n,
splitInputsInto: 3,
feeRate: 1,
priorityFee: 0n,
gasSatFee: 0n,
mldsaSigner: null,
});
const signed = await tx.signTransaction();
// 3 split outputs + possible change output
expect(signed.outs.length).toBeGreaterThanOrEqual(3);
expect(signed.ins.length).toBeGreaterThan(0);
expect(signed.toHex()).toBeTruthy();
});
it('should throw insufficient funds when amount equals total UTXO value (no autoAdjust)', async () => {
const utxoValue = 100_000n;
const utxo = createTaprootUtxo(taprootAddress, utxoValue);
const tx = new FundingTransaction({
signer,
network,
utxos: [utxo],
to: taprootAddress,
amount: utxoValue, // exact match , no room for fees
feeRate: 2,
priorityFee: 0n,
gasSatFee: 0n,
mldsaSigner: null,
});
await expect(tx.signTransaction()).rejects.toThrow(/Insufficient funds/);
});
it('should throw insufficient funds when amount exceeds total UTXO value', async () => {
const utxo = createTaprootUtxo(taprootAddress, 50_000n);
const tx = new FundingTransaction({
signer,
network,
utxos: [utxo],
to: taprootAddress,
amount: 60_000n, // more than available
feeRate: 1,
priorityFee: 0n,
gasSatFee: 0n,
mldsaSigner: null,
});
await expect(tx.signTransaction()).rejects.toThrow(/Insufficient funds/);
});
it('should tolerate small fee estimation shortfalls when effective fee is still positive', async () => {
// When amount is close to totalInputAmount but leaves some sats for
// fees, the estimated fee may exceed what's available. As long as the
// effective fee (totalInputAmount - amountSpent) is positive, the
// transaction should succeed , the fee is just lower than estimated.
const utxoValue = 100_000n;
const utxo = createTaprootUtxo(taprootAddress, utxoValue);
// Leave 200 sats for fees , less than the estimated ~77 sats/vB * ~77 vB
// but still a positive effective fee
const amount = utxoValue - 200n;
const tx = new FundingTransaction({
signer,
network,
utxos: [utxo],
to: taprootAddress,
amount,
feeRate: 10, // high fee rate means estimated fee > 200, but effective fee = 200
priorityFee: 0n,
gasSatFee: 0n,
mldsaSigner: null,
});
// Should NOT throw , effective fee is 200 sats (positive), even though
// it's less than the estimated fee at feeRate=10
const signed = await tx.signTransaction();
expect(signed.ins.length).toBeGreaterThan(0);
expect(signed.outs.length).toBeGreaterThan(0);
// Verify fee is exactly what was left over
const totalOutputValue = signed.outs.reduce((sum, out) => sum + BigInt(out.value), 0n);
expect(utxoValue - totalOutputValue).toBe(200n);
});
it('should auto-adjust amount when amount equals total UTXO value with autoAdjustAmount', async () => {
const utxoValue = 100_000n;
const utxo = createTaprootUtxo(taprootAddress, utxoValue);
const feeRate = 2;
const tx = new FundingTransaction({
signer,
network,
utxos: [utxo],
to: taprootAddress,
amount: utxoValue,
autoAdjustAmount: true,
feeRate,
priorityFee: 0n,
gasSatFee: 0n,
mldsaSigner: null,
});
const signed = await tx.signTransaction();
expect(signed.ins.length).toBeGreaterThan(0);
expect(signed.outs.length).toBeGreaterThan(0);
expect(signed.toHex()).toBeTruthy();
// The total output value should be less than utxoValue (fees deducted)
const totalOutputValue = signed.outs.reduce((sum, out) => sum + BigInt(out.value), 0n);
expect(totalOutputValue).toBeLessThan(utxoValue);
// The fee should be roughly feeRate * virtualSize
const fee = utxoValue - totalOutputValue;
const expectedFee = BigInt(Math.ceil(feeRate * signed.virtualSize()));
// Allow small variance due to fee estimation iterations
expect(fee).toBeGreaterThan(0n);
expect(fee).toBeLessThanOrEqual(expectedFee + 10n);
});
it('should not adjust amount when autoAdjustAmount is true but amount < totalInputAmount', async () => {
const utxoValue = 100_000n;
const sendAmount = 50_000n;
const utxo = createTaprootUtxo(taprootAddress, utxoValue);
const tx = new FundingTransaction({
signer,
network,
utxos: [utxo],
to: taprootAddress,
amount: sendAmount,
autoAdjustAmount: true,
feeRate: 1,
priorityFee: 0n,
gasSatFee: 0n,
mldsaSigner: null,
});
const signed = await tx.signTransaction();
// Should behave like a normal transaction , one output at sendAmount + change
expect(signed.ins.length).toBeGreaterThan(0);
expect(signed.outs.length).toBeGreaterThanOrEqual(2); // send + change
// Find the primary output (should be exactly sendAmount)
const outputValues = signed.outs.map((o) => BigInt(o.value));
expect(outputValues).toContain(sendAmount);
});
it('should produce a transaction with proper fee rate when using autoAdjustAmount', async () => {
const utxoValue = 200_000n;
const utxo = createTaprootUtxo(taprootAddress, utxoValue);
const feeRate = 5;
const tx = new FundingTransaction({
signer,
network,
utxos: [utxo],
to: taprootAddress,
amount: utxoValue,
autoAdjustAmount: true,
feeRate,
priorityFee: 0n,
gasSatFee: 0n,
mldsaSigner: null,
});
const signed = await tx.signTransaction();
const totalOutputValue = signed.outs.reduce((sum, out) => sum + BigInt(out.value), 0n);
const actualFee = utxoValue - totalOutputValue;
const vsize = signed.virtualSize();
// Actual fee rate should be at least the requested feeRate
const actualFeeRate = Number(actualFee) / vsize;
expect(actualFeeRate).toBeGreaterThanOrEqual(feeRate * 0.95);
});
});
describe('CancelTransaction', () => {
it('should build and sign a cancel transaction with compiledTargetScript', async () => {
const utxo = createTaprootUtxo(taprootAddress, 100_000n);
// Use script.compile to produce a valid compiled target script
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.toHex()).toBeTruthy();
expect(signed.virtualSize()).toBeGreaterThan(0);
});
});
describe('CustomScriptTransaction', () => {
it('should build and sign a custom script transaction', async () => {
const utxo = createTaprootUtxo(taprootAddress, 100_000n);
// Use fixed random bytes so we can compute the contract signer's pubkey
// and build a script that both signers can sign against.
const { crypto: bitCrypto } = await import('@btc-vision/bitcoin');
const fixedRandomBytes = new Uint8Array(32);
fixedRandomBytes.fill(0x42);
const contractSeed = bitCrypto.hash256(fixedRandomBytes);
const contractSigner = EcKeyPair.fromSeedKeyPair(contractSeed, network);
// Build a script that includes both pubkeys: [contractPubKey OP_CHECKSIGVERIFY signerXOnly OP_CHECKSIG]
const contractXOnly = toXOnly(contractSigner.publicKey);
const signerXOnly = toXOnly(signer.publicKey);
const tx = new CustomScriptTransaction({
signer,
network,
utxos: [utxo],
to: taprootAddress,
script: [
contractXOnly,
new Uint8Array([opcodes.OP_CHECKSIGVERIFY]),
signerXOnly,
new Uint8Array([opcodes.OP_CHECKSIG]),
],
witnesses: [],
randomBytes: fixedRandomBytes,
feeRate: 1,
priorityFee: 330n,
gasSatFee: 330n,
mldsaSigner: null,
});
const signed = await tx.signTransaction();
expect(signed.ins.length).toBeGreaterThan(0);
expect(signed.outs.length).toBeGreaterThan(0);
expect(signed.toHex()).toBeTruthy();
expect(signed.virtualSize()).toBeGreaterThan(0);
});
});
describe('MultiSignTransaction', () => {
it('should build a multisig transaction and produce valid PSBT', async () => {
const signer2 = EcKeyPair.generateRandomKeyPair(network);
const signer3 = EcKeyPair.generateRandomKeyPair(network);
const receiver = EcKeyPair.getTaprootAddress(signer2, network);
const refundVault = EcKeyPair.getTaprootAddress(signer3, network);
const pubkeys = [signer.publicKey, signer2.publicKey, signer3.publicKey];
const multiSigTx = new MultiSignTransaction({
network,
utxos: [createTaprootUtxo(taprootAddress, 100_000n)],
pubkeys,
minimumSignatures: 2,
receiver,
requestedAmount: 30_000n,
refundVault,
feeRate: 1,
mldsaSigner: null,
});
// signPSBT returns a Psbt (not fully signed since it's multisig)
const psbt = await multiSigTx.signPSBT();
expect(psbt).toBeDefined();
expect(psbt.data.inputs.length).toBeGreaterThan(0);
// There should be outputs (refund + receiver)
expect(psbt.data.outputs.length).toBeGreaterThanOrEqual(2);
});
});
describe('InteractionTransaction', () => {
it('should build and sign an interaction transaction', async () => {
const utxo = createTaprootUtxo(taprootAddress, 100_000n);
const challenge = createMockChallenge(walletAddress);
// contract is a 32-byte hex string
const contract = '0x' + '00'.repeat(32);
// Let the builder generate the compiled target script from calldata+challenge
const tx = new InteractionTransaction({
signer,
network,
utxos: [utxo],
to: taprootAddress,
calldata: new Uint8Array([0x01, 0x02, 0x03, 0x04]),
challenge,
contract,
feeRate: 1,
priorityFee: 330n,
gasSatFee: 330n,
mldsaSigner: null,
});
const signed = await tx.signTransaction();
expect(signed.ins.length).toBeGreaterThan(0);
expect(signed.outs.length).toBeGreaterThan(0);
expect(signed.toHex()).toBeTruthy();
expect(signed.virtualSize()).toBeGreaterThan(0);
});
});
describe('DeploymentTransaction', () => {
it('should build and sign a deployment transaction', async () => {
const utxo = createTaprootUtxo(taprootAddress, 200_000n);
const challenge = createMockChallenge(walletAddress);
// Minimal bytecode
const bytecode = new Uint8Array(100);
bytecode.fill(0xab);
// Let the builder generate the compiled target script from bytecode+challenge
const tx = new DeploymentTransaction({
signer,
network,
utxos: [utxo],
bytecode,
challenge,
feeRate: 1,
priorityFee: 330n,
gasSatFee: 330n,
mldsaSigner: quantumRoot,
});
const signed = await tx.signTransaction();
expect(signed.ins.length).toBeGreaterThan(0);
expect(signed.outs.length).toBeGreaterThan(0);
expect(signed.toHex()).toBeTruthy();
expect(signed.virtualSize()).toBeGreaterThan(0);
// Deployment should expose contract address
expect(tx.contractAddress).toBeDefined();
expect(tx.contractPubKey).toBeDefined();
});
});
});