@btc-vision/transaction
Version:
OPNet transaction library allows you to create and sign transactions for the OPNet network.
737 lines (643 loc) • 26.9 kB
text/typescript
import { beforeAll, describe, expect, it } from 'vitest';
import {
hash256,
networks,
opcodes,
payments,
PaymentType,
script,
toHex,
toXOnly,
type Taptree,
type XOnlyPublicKey,
} 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,
InteractionTransaction,
MLDSASecurityLevel,
Mnemonic,
MultiSignTransaction,
P2MR_MS,
P2TR_MS,
TimeLockGenerator,
} 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,
},
};
}
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('P2MR Support', () => {
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('P2MR Address Generation', () => {
it('should generate a P2MR multisig address via P2MR_MS', () => {
const signer2 = EcKeyPair.generateRandomKeyPair(network);
const signer3 = EcKeyPair.generateRandomKeyPair(network);
const pubkeys = [signer.publicKey, signer2.publicKey, signer3.publicKey];
const addr = P2MR_MS.generateMultiSigAddress(pubkeys, 2, network);
expect(addr).toBeTruthy();
expect(typeof addr).toBe('string');
// P2MR uses segwit v2, regtest prefix is bcrt1z
expect(addr).toMatch(/^bcrt1z/);
});
it('should generate different addresses for P2MR_MS vs P2TR_MS with same keys', () => {
const signer2 = EcKeyPair.generateRandomKeyPair(network);
const signer3 = EcKeyPair.generateRandomKeyPair(network);
const pubkeys = [signer.publicKey, signer2.publicKey, signer3.publicKey];
const p2mrAddr = P2MR_MS.generateMultiSigAddress(pubkeys, 2, network);
const p2trAddr = P2TR_MS.generateMultiSigAddress(pubkeys, 2, network);
expect(p2mrAddr).toBeTruthy();
expect(p2trAddr).toBeTruthy();
expect(p2mrAddr).not.toBe(p2trAddr);
expect(p2mrAddr).toMatch(/^bcrt1z/);
expect(p2trAddr).toMatch(/^bcrt1p/);
});
it('should throw on invalid public keys', () => {
expect(() =>
P2MR_MS.generateMultiSigAddress([new Uint8Array(33)], 1, network),
).toThrow();
});
});
describe('P2MR DeploymentTransaction', () => {
it('should produce a bc1z (P2MR) script address when useP2MR is true', () => {
const utxo = createTaprootUtxo(taprootAddress, 200_000n);
const challenge = createMockChallenge(walletAddress);
const bytecode = new Uint8Array(100).fill(0xab);
const tx = new DeploymentTransaction({
signer,
network,
utxos: [utxo],
bytecode,
challenge,
feeRate: 1,
priorityFee: 330n,
gasSatFee: 330n,
mldsaSigner: quantumRoot,
useP2MR: true,
});
const scriptAddr = tx.getScriptAddress();
expect(scriptAddr).toMatch(/^bcrt1z/);
});
it('should produce a bc1p (P2TR) script address by default', () => {
const utxo = createTaprootUtxo(taprootAddress, 200_000n);
const challenge = createMockChallenge(walletAddress);
const bytecode = new Uint8Array(100).fill(0xab);
const tx = new DeploymentTransaction({
signer,
network,
utxos: [utxo],
bytecode,
challenge,
feeRate: 1,
priorityFee: 330n,
gasSatFee: 330n,
mldsaSigner: quantumRoot,
});
const scriptAddr = tx.getScriptAddress();
expect(scriptAddr).toMatch(/^bcrt1p/);
});
it('should build and sign a P2MR deployment transaction', async () => {
const utxo = createTaprootUtxo(taprootAddress, 200_000n);
const challenge = createMockChallenge(walletAddress);
const bytecode = new Uint8Array(100).fill(0xab);
const tx = new DeploymentTransaction({
signer,
network,
utxos: [utxo],
bytecode,
challenge,
feeRate: 1,
priorityFee: 330n,
gasSatFee: 330n,
mldsaSigner: quantumRoot,
useP2MR: true,
});
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);
expect(tx.contractAddress).toBeDefined();
expect(tx.contractPubKey).toBeDefined();
});
it('should produce different script addresses for P2MR vs P2TR with same params', () => {
const utxo = createTaprootUtxo(taprootAddress, 200_000n);
const challenge = createMockChallenge(walletAddress);
const bytecode = new Uint8Array(100).fill(0xab);
const randomBytes = new Uint8Array(32).fill(0x42);
const txP2MR = new DeploymentTransaction({
signer,
network,
utxos: [utxo],
bytecode,
challenge,
feeRate: 1,
priorityFee: 330n,
gasSatFee: 330n,
mldsaSigner: quantumRoot,
useP2MR: true,
randomBytes,
});
const txP2TR = new DeploymentTransaction({
signer,
network,
utxos: [utxo],
bytecode,
challenge,
feeRate: 1,
priorityFee: 330n,
gasSatFee: 330n,
mldsaSigner: quantumRoot,
randomBytes,
});
expect(txP2MR.getScriptAddress()).not.toBe(txP2TR.getScriptAddress());
});
});
describe('P2MR InteractionTransaction', () => {
it('should produce a bc1z script address when useP2MR is true', () => {
const utxo = createTaprootUtxo(taprootAddress, 100_000n);
const challenge = createMockChallenge(walletAddress);
const contract = '0x' + '00'.repeat(32);
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,
useP2MR: true,
});
const scriptAddr = tx.getScriptAddress();
expect(scriptAddr).toMatch(/^bcrt1z/);
});
it('should build and sign a P2MR interaction transaction', async () => {
const utxo = createTaprootUtxo(taprootAddress, 100_000n);
const challenge = createMockChallenge(walletAddress);
const contract = '0x' + '00'.repeat(32);
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,
useP2MR: true,
});
const signed = await tx.signTransaction();
expect(signed.ins.length).toBeGreaterThan(0);
expect(signed.outs.length).toBeGreaterThan(0);
expect(signed.toHex()).toBeTruthy();
});
});
describe('P2MR MultiSignTransaction', () => {
it('should produce a bc1z script address when useP2MR is true', () => {
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 tx = new MultiSignTransaction({
network,
utxos: [createTaprootUtxo(taprootAddress, 100_000n)],
pubkeys,
minimumSignatures: 2,
receiver,
requestedAmount: 30_000n,
refundVault,
feeRate: 1,
mldsaSigner: null,
useP2MR: true,
});
const scriptAddr = tx.getScriptAddress();
expect(scriptAddr).toMatch(/^bcrt1z/);
});
it('should default to P2TR (bc1p) when useP2MR is not set', () => {
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 tx = new MultiSignTransaction({
network,
utxos: [createTaprootUtxo(taprootAddress, 100_000n)],
pubkeys,
minimumSignatures: 2,
receiver,
requestedAmount: 30_000n,
refundVault,
feeRate: 1,
mldsaSigner: null,
});
const scriptAddr = tx.getScriptAddress();
expect(scriptAddr).toMatch(/^bcrt1p/);
});
it('should build a P2MR multisig 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 tx = new MultiSignTransaction({
network,
utxos: [createTaprootUtxo(taprootAddress, 100_000n)],
pubkeys,
minimumSignatures: 2,
receiver,
requestedAmount: 30_000n,
refundVault,
feeRate: 1,
mldsaSigner: null,
useP2MR: true,
});
const psbt = await tx.signPSBT();
expect(psbt).toBeDefined();
expect(psbt.data.inputs.length).toBeGreaterThan(0);
expect(psbt.data.outputs.length).toBeGreaterThanOrEqual(2);
});
});
describe('P2MR CancelTransaction', () => {
it('should produce a bc1z script address when useP2MR is true', () => {
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,
useP2MR: true,
});
const scriptAddr = tx.getScriptAddress();
expect(scriptAddr).toMatch(/^bcrt1z/);
});
it('should build and sign a P2MR cancel transaction', 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,
useP2MR: true,
});
const signed = await tx.signTransaction();
expect(signed.ins.length).toBeGreaterThan(0);
expect(signed.outs.length).toBeGreaterThan(0);
expect(signed.toHex()).toBeTruthy();
});
});
describe('P2MR CustomScriptTransaction', () => {
it('should produce a bc1z script address when useP2MR is true', () => {
const utxo = createTaprootUtxo(taprootAddress, 100_000n);
const fixedRandomBytes = new Uint8Array(32).fill(0x42);
const contractSeed = hash256(fixedRandomBytes);
const contractSigner = EcKeyPair.fromSeedKeyPair(contractSeed, network);
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,
useP2MR: true,
});
const scriptAddr = tx.getScriptAddress();
expect(scriptAddr).toMatch(/^bcrt1z/);
});
it('should build and sign a P2MR custom script transaction', async () => {
const utxo = createTaprootUtxo(taprootAddress, 100_000n);
const { crypto: bitCrypto } = await import('@btc-vision/bitcoin');
const fixedRandomBytes = new Uint8Array(32).fill(0x42);
const contractSeed = bitCrypto.hash256(fixedRandomBytes);
const contractSigner = EcKeyPair.fromSeedKeyPair(contractSeed, network);
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,
useP2MR: true,
});
const signed = await tx.signTransaction();
expect(signed.ins.length).toBeGreaterThan(0);
expect(signed.outs.length).toBeGreaterThan(0);
expect(signed.toHex()).toBeTruthy();
});
});
describe('P2MR TimeLockGenerator', () => {
it('should generate a P2MR address with CSV timelock', () => {
const xOnlyPubKey = toXOnly(signer.publicKey);
const addr = TimeLockGenerator.generateTimeLockAddressP2MR(xOnlyPubKey, network, 10);
expect(addr).toBeTruthy();
expect(addr).toMatch(/^bcrt1z/);
});
it('should generate different addresses for P2MR vs P2TR CSV', () => {
const xOnlyPubKey = toXOnly(signer.publicKey);
const p2mrAddr = TimeLockGenerator.generateTimeLockAddressP2MR(
xOnlyPubKey,
network,
10,
);
const p2trAddr = TimeLockGenerator.generateTimeLockAddressP2TR(
xOnlyPubKey,
network,
10,
);
expect(p2mrAddr).not.toBe(p2trAddr);
expect(p2mrAddr).toMatch(/^bcrt1z/);
expect(p2trAddr).toMatch(/^bcrt1p/);
});
it('should throw if public key is not 32 bytes', () => {
expect(() =>
TimeLockGenerator.generateTimeLockAddressP2MR(
new Uint8Array(33) as XOnlyPublicKey, // 33 bytes, not x-only — intentionally invalid
network,
10,
),
).toThrow('Public key must be 32 bytes for P2MR');
});
it('should generate different addresses for different CSV block counts', () => {
const xOnlyPubKey = toXOnly(signer.publicKey);
const addr10 = TimeLockGenerator.generateTimeLockAddressP2MR(xOnlyPubKey, network, 10);
const addr100 = TimeLockGenerator.generateTimeLockAddressP2MR(
xOnlyPubKey,
network,
100,
);
expect(addr10).not.toBe(addr100);
});
});
describe('Address.toCSVP2MR', () => {
it('should generate a P2MR CSV address', () => {
const addr = walletAddress.toCSVP2MR(10, network);
expect(addr).toBeTruthy();
expect(typeof addr).toBe('string');
expect(addr).toMatch(/^bcrt1z/);
});
it('should differ from toCSVTweaked (P2TR)', () => {
const p2mrAddr = walletAddress.toCSVP2MR(10, network);
const p2trAddr = walletAddress.toCSVTweaked(10, network);
expect(p2mrAddr).not.toBe(p2trAddr);
});
it('should throw for CSV blocks < 1', () => {
expect(() => walletAddress.toCSVP2MR(0, network)).toThrow(
'CSV block number must be between 1 and 65535',
);
});
it('should throw for CSV blocks > 65535', () => {
expect(() => walletAddress.toCSVP2MR(70000, network)).toThrow(
'CSV block number must be between 1 and 65535',
);
});
});
describe('P2MR backward compatibility', () => {
it('useP2MR defaults to false - all builders produce P2TR by default', () => {
const utxo = createTaprootUtxo(taprootAddress, 200_000n);
const challenge = createMockChallenge(walletAddress);
const bytecode = new Uint8Array(100).fill(0xab);
// DeploymentTransaction default
const deployTx = new DeploymentTransaction({
signer,
network,
utxos: [utxo],
bytecode,
challenge,
feeRate: 1,
priorityFee: 330n,
gasSatFee: 330n,
mldsaSigner: quantumRoot,
});
expect(deployTx.getScriptAddress()).toMatch(/^bcrt1p/);
// CancelTransaction default
const cancelTx = new CancelTransaction({
signer,
network,
utxos: [utxo],
compiledTargetScript: script.compile([
toXOnly(signer.publicKey),
opcodes.OP_CHECKSIG,
]),
feeRate: 1,
mldsaSigner: null,
});
expect(cancelTx.getScriptAddress()).toMatch(/^bcrt1p/);
});
it('existing P2TR transactions still build and sign correctly', async () => {
const utxo = createTaprootUtxo(taprootAddress, 200_000n);
const challenge = createMockChallenge(walletAddress);
const bytecode = new Uint8Array(100).fill(0xab);
const tx = new DeploymentTransaction({
signer,
network,
utxos: [utxo],
bytecode,
challenge,
feeRate: 1,
priorityFee: 330n,
gasSatFee: 330n,
mldsaSigner: quantumRoot,
// no useP2MR - defaults to P2TR
});
const signed = await tx.signTransaction();
expect(signed.ins.length).toBeGreaterThan(0);
expect(signed.outs.length).toBeGreaterThan(0);
expect(signed.toHex()).toBeTruthy();
});
it('useP2MR: false explicitly still produces P2TR', () => {
const utxo = createTaprootUtxo(taprootAddress, 200_000n);
const challenge = createMockChallenge(walletAddress);
const bytecode = new Uint8Array(100).fill(0xab);
const tx = new DeploymentTransaction({
signer,
network,
utxos: [utxo],
bytecode,
challenge,
feeRate: 1,
priorityFee: 330n,
gasSatFee: 330n,
mldsaSigner: quantumRoot,
useP2MR: false,
});
expect(tx.getScriptAddress()).toMatch(/^bcrt1p/);
});
});
describe('P2MR payment object construction', () => {
it('payments.p2mr() produces valid output from script tree', () => {
const witnessScript = script.compile([
toXOnly(signer.publicKey),
opcodes.OP_CHECKSIG,
]);
const scriptTree = { output: witnessScript, version: 192 };
const p2mr = payments.p2mr({ scriptTree, network });
expect(p2mr.address).toBeTruthy();
expect(p2mr.address).toMatch(/^bcrt1z/);
expect(p2mr.output).toBeDefined();
expect(p2mr.name).toBe(PaymentType.P2MR);
// P2MR output: OP_2 <32-byte merkle_root> = 34 bytes
const p2mrOutput = p2mr.output;
expect(p2mrOutput).toBeDefined();
expect(p2mrOutput?.length).toBe(34);
expect(p2mrOutput?.[0]).toBe(opcodes.OP_2);
// 0x20 = 32 (push 32 bytes)
expect(p2mrOutput?.[1]).toBe(0x20);
});
it('P2MR has no internalPubkey in payment', () => {
const witnessScript = script.compile([
toXOnly(signer.publicKey),
opcodes.OP_CHECKSIG,
]);
const scriptTree = { output: witnessScript, version: 192 };
const p2mr = payments.p2mr({ scriptTree, network });
// P2MR should NOT have internalPubkey
expect('internalPubkey' in p2mr && p2mr.internalPubkey).toBeFalsy();
// P2MR should have hash (merkle root)
expect(p2mr.hash).toBeDefined();
expect(p2mr.hash?.length).toBe(32);
});
it('P2MR witness is smaller than P2TR witness (no internal pubkey in control block)', () => {
const witnessScript = script.compile([
toXOnly(signer.publicKey),
opcodes.OP_CHECKSIG,
]);
const scriptTree: Taptree = [
{ output: witnessScript, version: 192 },
{
output: script.compile([opcodes.OP_XOR, opcodes.OP_NOP, opcodes.OP_CODESEPARATOR]),
version: 192,
},
];
const p2mr = payments.p2mr({
scriptTree,
network,
redeem: { output: witnessScript, redeemVersion: 192 },
});
const p2tr = payments.p2tr({
internalPubkey: toXOnly(signer.publicKey),
scriptTree,
network,
redeem: { output: witnessScript, redeemVersion: 192 },
});
// Both should have witness arrays
const p2mrWitness = p2mr.witness;
const p2trWitness = p2tr.witness;
expect(p2mrWitness).toBeDefined();
expect(p2trWitness).toBeDefined();
expect(p2mrWitness?.length).toBeGreaterThan(0);
expect(p2trWitness?.length).toBeGreaterThan(0);
// P2MR control block should be 32 bytes smaller (no internal pubkey)
const p2mrControlBlock = p2mrWitness?.[p2mrWitness.length - 1];
const p2trControlBlock = p2trWitness?.[p2trWitness.length - 1];
expect(p2mrControlBlock).toBeDefined();
expect(p2trControlBlock).toBeDefined();
expect((p2trControlBlock?.length ?? 0) - (p2mrControlBlock?.length ?? 0)).toBe(32);
});
});
});