Files
salvium-rs/test/transaction-builder.test.js
T
Matt Hess 1066ca2110 Add full wallet implementation with storage, sync, and transaction support
New Features:
  - Wallet class with multi-account and subaddress support
  - PersistentWallet with blockchain sync and storage (Memory/IndexedDB)
  - Connection manager for daemon/wallet RPC failover
  - Query system for filtering outputs and transactions
  - Offline transaction signing (cold wallet support)
  - Multisig wallet support (N-of-M threshold signing)

  Daemon RPC:
  - Salvium-specific: getSupplyInfo(), getYieldInfo()
  - Mining control: startMining(), stopMining(), miningStatus()
  - Bandwidth/peer management, admin controls

  Transaction:
  - Full transaction building with UTXO selection
  - Transaction parsing and summarization
  - Fee estimation with priority levels
  - Decoy selection with gamma distribution

  Tests:
  - Wallet class tests (accounts, subaddresses, recovery)
  - Transaction builder and parser tests
  - UTXO selection tests
2026-01-18 21:39:05 +00:00

471 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* Transaction Builder Tests
*
* Tests for transaction construction functions:
* - buildTransaction: Full transaction assembly
* - signTransaction: Offline signing
* - validateTransaction: Pre-broadcast validation
* - estimateTransactionFee: Fee estimation
*/
import {
buildTransaction,
signTransaction,
validateTransaction,
estimateTransactionFee,
serializeTransaction,
scRandom,
commit,
TX_VERSION,
RCT_TYPE,
TXIN_TYPE,
TXOUT_TYPE
} from '../src/transaction.js';
import { scalarMultBase } from '../src/ed25519.js';
import { bytesToHex, hexToBytes } from '../src/address.js';
import { generateKeyImage } from '../src/keyimage.js';
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed++;
} catch (e) {
console.log(`${name}: ${e.message}`);
failed++;
}
}
function assert(condition, message) {
if (!condition) throw new Error(message || 'Assertion failed');
}
function assertEqual(a, b, message) {
const aStr = typeof a === 'bigint' ? a.toString() : a;
const bStr = typeof b === 'bigint' ? b.toString() : b;
if (aStr !== bStr) throw new Error(message || `Expected ${bStr}, got ${aStr}`);
}
function assertTrue(condition, message) {
if (!condition) throw new Error(message || 'Expected true');
}
// Helper to create mock input
function createMockInput(amount = 100000000n) {
const secretKey = scRandom();
const publicKey = scalarMultBase(secretKey);
const mask = scRandom();
const commitment = commit(amount, mask);
// Create mock ring (3 members)
const ring = [
scalarMultBase(scRandom()),
publicKey,
scalarMultBase(scRandom())
];
const ringCommitments = [
commit(amount, scRandom()),
commitment,
commit(amount, scRandom())
];
return {
secretKey,
publicKey,
amount,
mask,
commitment,
globalIndex: Math.floor(Math.random() * 10000),
ring,
ringCommitments,
ringIndices: [100, 200, 300],
realIndex: 1
};
}
// Helper to create mock destination
function createMockDestination(amount = 50000000n) {
return {
viewPublicKey: scalarMultBase(scRandom()),
spendPublicKey: scalarMultBase(scRandom()),
amount,
isSubaddress: false
};
}
// Helper to create mock change address
function createMockChangeAddress() {
return {
viewPublicKey: scalarMultBase(scRandom()),
spendPublicKey: scalarMultBase(scRandom()),
isSubaddress: false
};
}
console.log('\n=== Transaction Builder Tests ===\n');
// Fee estimation
console.log('--- Fee Estimation ---');
test('estimateTransactionFee returns bigint', () => {
const fee = estimateTransactionFee(2, 2);
assertEqual(typeof fee, 'bigint');
});
test('estimateTransactionFee increases with inputs', () => {
const fee1 = estimateTransactionFee(1, 2);
const fee3 = estimateTransactionFee(3, 2);
assertTrue(fee3 > fee1, 'More inputs should mean higher fee');
});
test('estimateTransactionFee increases with outputs', () => {
// Use larger output counts to ensure KB boundary is crossed
const fee2 = estimateTransactionFee(2, 2);
const fee16 = estimateTransactionFee(2, 16);
assertTrue(fee16 > fee2, 'More outputs should mean higher fee');
});
test('estimateTransactionFee respects priority', () => {
const feeLow = estimateTransactionFee(2, 2, { priority: 'low' });
const feeDefault = estimateTransactionFee(2, 2, { priority: 'default' });
const feeHigh = estimateTransactionFee(2, 2, { priority: 'high' });
assertTrue(feeDefault >= feeLow, 'Default >= low priority');
assertTrue(feeHigh > feeDefault, 'High > default priority');
});
test('estimateTransactionFee respects ring size', () => {
// Use multiple inputs so ring size difference is significant
const fee16 = estimateTransactionFee(3, 2, { ringSize: 16 });
const fee32 = estimateTransactionFee(3, 2, { ringSize: 32 });
assertTrue(fee32 > fee16, 'Larger ring should mean higher fee');
});
// Transaction validation
console.log('\n--- Transaction Validation ---');
test('validateTransaction detects missing prefix', () => {
const result = validateTransaction({});
assertTrue(!result.valid);
assertTrue(result.errors.includes('Missing transaction prefix'));
});
test('validateTransaction detects missing RCT', () => {
const result = validateTransaction({
prefix: { version: 2, vin: [{}], vout: [{}] }
});
assertTrue(!result.valid);
assertTrue(result.errors.includes('Missing RingCT signature data'));
});
test('validateTransaction detects no inputs', () => {
const result = validateTransaction({
prefix: { version: 2, vin: [], vout: [{}] },
rct: { CLSAGs: [], outPk: ['a'.repeat(64)], fee: 1000n }
});
assertTrue(!result.valid);
assertTrue(result.errors.some(e => e.includes('no inputs')));
});
test('validateTransaction detects no outputs', () => {
const result = validateTransaction({
prefix: { version: 2, vin: [{}], vout: [] },
rct: { CLSAGs: [{}], outPk: [], fee: 1000n }
});
assertTrue(!result.valid);
assertTrue(result.errors.some(e => e.includes('no outputs')));
});
test('validateTransaction detects missing CLSAG', () => {
const result = validateTransaction({
prefix: { version: 2, vin: [{}], vout: [{}] },
rct: { CLSAGs: [], outPk: ['a'.repeat(64)], fee: 1000n }
});
assertTrue(!result.valid);
assertTrue(result.errors.some(e => e.includes('CLSAG')));
});
test('validateTransaction detects CLSAG count mismatch', () => {
const result = validateTransaction({
prefix: { version: 2, vin: [{}, {}], vout: [{}] },
rct: { CLSAGs: [{}], outPk: ['a'.repeat(64)], fee: 1000n }
});
assertTrue(!result.valid);
assertTrue(result.errors.some(e => e.includes('count')));
});
test('validateTransaction detects duplicate key images', () => {
const keyImage = new Uint8Array(32).fill(0xaa);
const result = validateTransaction({
prefix: {
version: 2,
vin: [{ keyImage }, { keyImage }],
vout: [{}]
},
rct: { CLSAGs: [{}, {}], outPk: ['a'.repeat(64)], fee: 1000n }
});
assertTrue(!result.valid);
assertTrue(result.errors.some(e => e.includes('Duplicate key image')));
});
test('validateTransaction passes valid transaction', () => {
const keyImage1 = new Uint8Array(32).fill(0xaa);
const keyImage2 = new Uint8Array(32).fill(0xbb);
const result = validateTransaction({
prefix: {
version: 2,
vin: [{ keyImage: keyImage1 }, { keyImage: keyImage2 }],
vout: [{}, {}]
},
rct: {
CLSAGs: [{}, {}],
outPk: ['a'.repeat(64), 'b'.repeat(64)],
fee: 1000n
}
});
assertTrue(result.valid, `Should be valid: ${result.errors.join(', ')}`);
});
// buildTransaction
console.log('\n--- buildTransaction ---');
test('buildTransaction requires inputs', () => {
let threw = false;
try {
buildTransaction({
inputs: [],
destinations: [createMockDestination()],
changeAddress: createMockChangeAddress(),
fee: 1000000n
});
} catch (e) {
threw = true;
assertTrue(e.message.includes('input'));
}
assertTrue(threw, 'Should throw on no inputs');
});
test('buildTransaction requires destinations', () => {
let threw = false;
try {
buildTransaction({
inputs: [createMockInput()],
destinations: [],
changeAddress: createMockChangeAddress(),
fee: 1000000n
});
} catch (e) {
threw = true;
assertTrue(e.message.includes('destination'));
}
assertTrue(threw, 'Should throw on no destinations');
});
test('buildTransaction detects insufficient funds', () => {
let threw = false;
try {
buildTransaction({
inputs: [createMockInput(10000000n)], // 10M
destinations: [createMockDestination(50000000n)], // 50M
changeAddress: createMockChangeAddress(),
fee: 1000000n
});
} catch (e) {
threw = true;
assertTrue(e.message.includes('Insufficient'));
}
assertTrue(threw, 'Should throw on insufficient funds');
});
test('buildTransaction creates valid transaction structure', () => {
const input = createMockInput(100000000n);
const destination = createMockDestination(40000000n);
const changeAddress = createMockChangeAddress();
const fee = 10000000n;
const tx = buildTransaction({
inputs: [input],
destinations: [destination],
changeAddress,
fee
});
// Check structure
assert(tx.prefix, 'Should have prefix');
assert(tx.rct, 'Should have rct');
assert(tx._meta, 'Should have metadata');
// Check prefix
assertEqual(tx.prefix.version, TX_VERSION.RCT_2);
assertEqual(tx.prefix.vin.length, 1, 'Should have 1 input');
assertEqual(tx.prefix.vout.length, 2, 'Should have 2 outputs (dest + change)');
// Check RCT
assertEqual(tx.rct.type, RCT_TYPE.BulletproofPlus);
assertEqual(tx.rct.fee, fee);
assertEqual(tx.rct.CLSAGs.length, 1, 'Should have 1 CLSAG');
assertEqual(tx.rct.outPk.length, 2, 'Should have 2 output commitments');
assertEqual(tx.rct.ecdhInfo.length, 2, 'Should have 2 encrypted amounts');
});
test('buildTransaction creates key images', () => {
const input = createMockInput();
const tx = buildTransaction({
inputs: [input],
destinations: [createMockDestination(40000000n)],
changeAddress: createMockChangeAddress(),
fee: 10000000n
});
assert(tx.prefix.vin[0].keyImage, 'Input should have key image');
assertEqual(tx.prefix.vin[0].keyImage.length, 32, 'Key image should be 32 bytes');
});
test('buildTransaction stores metadata', () => {
const tx = buildTransaction({
inputs: [createMockInput()],
destinations: [createMockDestination(40000000n)],
changeAddress: createMockChangeAddress(),
fee: 10000000n
});
assert(tx._meta.txSecretKey, 'Should store tx secret key');
assert(tx._meta.keyImages, 'Should store key images');
assert(tx._meta.outputMasks, 'Should store output masks');
assertTrue(tx._meta.changeIndex >= 0, 'Should store change index');
});
test('buildTransaction with no change (exact amount)', () => {
const inputAmount = 100000000n;
const outputAmount = 90000000n;
const fee = 10000000n;
const tx = buildTransaction({
inputs: [createMockInput(inputAmount)],
destinations: [createMockDestination(outputAmount)],
changeAddress: createMockChangeAddress(),
fee
});
// No change when amounts match exactly
assertEqual(tx.prefix.vout.length, 1, 'Should have only 1 output when no change');
assertEqual(tx._meta.changeIndex, -1, 'Change index should be -1');
});
test('buildTransaction with multiple inputs', () => {
const tx = buildTransaction({
inputs: [
createMockInput(50000000n),
createMockInput(50000000n)
],
destinations: [createMockDestination(80000000n)],
changeAddress: createMockChangeAddress(),
fee: 10000000n
});
assertEqual(tx.prefix.vin.length, 2, 'Should have 2 inputs');
assertEqual(tx.rct.CLSAGs.length, 2, 'Should have 2 CLSAG signatures');
});
test('buildTransaction with multiple destinations', () => {
const tx = buildTransaction({
inputs: [createMockInput(200000000n)],
destinations: [
createMockDestination(50000000n),
createMockDestination(50000000n),
createMockDestination(50000000n)
],
changeAddress: createMockChangeAddress(),
fee: 10000000n
});
assertEqual(tx.prefix.vout.length, 4, 'Should have 4 outputs (3 dest + 1 change)');
assertEqual(tx.rct.outPk.length, 4, 'Should have 4 commitments');
});
test('buildTransaction uses provided tx secret key', () => {
const txSecretKey = scRandom();
const tx = buildTransaction({
inputs: [createMockInput()],
destinations: [createMockDestination(40000000n)],
changeAddress: createMockChangeAddress(),
fee: 10000000n
}, { txSecretKey });
assertEqual(tx._meta.txSecretKey, bytesToHex(txSecretKey));
});
test('buildTransaction handles unlock time', () => {
const tx = buildTransaction({
inputs: [createMockInput()],
destinations: [createMockDestination(40000000n)],
changeAddress: createMockChangeAddress(),
fee: 10000000n
}, { unlockTime: 100 });
assertEqual(tx.prefix.unlockTime, 100);
});
test('built transaction passes validation', () => {
const tx = buildTransaction({
inputs: [createMockInput()],
destinations: [createMockDestination(40000000n)],
changeAddress: createMockChangeAddress(),
fee: 10000000n
});
const result = validateTransaction(tx);
assertTrue(result.valid, `Should be valid: ${result.errors.join(', ')}`);
});
// serializeTransaction
console.log('\n--- Serialization ---');
test('serializeTransaction produces Uint8Array', () => {
const tx = buildTransaction({
inputs: [createMockInput()],
destinations: [createMockDestination(40000000n)],
changeAddress: createMockChangeAddress(),
fee: 10000000n
});
const serialized = serializeTransaction(tx);
assertTrue(serialized instanceof Uint8Array, 'Should return Uint8Array');
assertTrue(serialized.length > 0, 'Should have content');
});
test('serialized transaction has reasonable size', () => {
const tx = buildTransaction({
inputs: [createMockInput()],
destinations: [createMockDestination(40000000n)],
changeAddress: createMockChangeAddress(),
fee: 10000000n
});
const serialized = serializeTransaction(tx);
// Note: Without full Bulletproof+ proof generation, serialized size is smaller
// than a complete on-chain transaction. Core structure should still be ~200-500 bytes.
assertTrue(serialized.length > 200, 'Should be at least 200 bytes');
assertTrue(serialized.length < 10000, 'Should be less than 10KB');
});
// Summary
console.log(`\n--- Summary ---`);
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
if (failed === 0) {
console.log('\n✓ All transaction builder tests passed!');
process.exit(0);
} else {
console.log('\n✗ Some tests failed');
process.exit(1);
}