Files
salvium-rs/test/validation.test.js
T
Matt Hess 6eeadbe8ed ● Add transaction validation module and complete sweepDust
- Create src/validation.js with complete Salvium validation rules:
    - TX type/version validation (validateTxTypeAndVersion)
    - Asset type validation (SAL, SAL1, BURN rules)
    - Output validation (types, sorting, overflow checks)
    - RCT type validation by hard fork version
    - Input validation (ring size 16, key image sorting)
    - Fee validation with dynamic fee calculation
    - Miner/protocol TX validation
    - Yield payout calculation (calculateYieldPayout)
    - Comprehensive validateTransactionFull() combining all checks
  - Implement sweepDust in wallet.js with selective UTXO spending
  - Add 58 tests in test/validation.test.js
  - Export validation module from index.js
2026-01-26 12:24:44 +00:00

705 lines
21 KiB
JavaScript

#!/usr/bin/env bun
/**
* Validation Module Tests
*
* Tests for Salvium transaction and block validation rules.
*
* Reference: ~/github/salvium/src/cryptonote_core/blockchain.cpp
* ~/github/salvium/src/cryptonote_core/tx_verification_utils.cpp
*/
import { describe, test, expect } from 'bun:test';
import {
// Constants
MINIMUM_MIXIN,
VALID_ASSET_TYPES,
ASSET_TYPE_ID,
RCT_TYPE_NAMES,
TXOUT_TYPE,
AUDIT_HARD_FORKS,
// Asset type functions
assetTypeFromId,
assetIdFromType,
validateAssetTypes,
// Transaction type/version validation
validateTxTypeAndVersion,
// Output validation
validateOutputTypes,
validateOutputPubkeySorting,
validateOutputsOverflow,
// RCT validation
validateRctType,
// Input validation
validateInputs,
// Fee validation
getFeeQuantizationMask,
calculateRequiredFee,
validateFee,
// Weight validation
getTransactionWeightLimit,
validateTxWeight,
// Miner transaction
prevalidateMinerTransaction,
// Yield calculation
calculateYieldPayout,
getStakeLockPeriod,
// Comprehensive validation
validateTransactionFull
} from '../src/validation.js';
import { TX_TYPE } from '../src/transaction.js';
import { HF_VERSION, COIN, DEFAULT_RING_SIZE } from '../src/consensus.js';
// =============================================================================
// TEST HELPERS
// =============================================================================
function createMockTransaction(overrides = {}) {
return {
prefix: {
version: 4,
txType: TX_TYPE.TRANSFER,
source_asset_type: 'SAL1',
destination_asset_type: 'SAL1',
vin: [
{
type: 'key',
k_image: '0'.repeat(64),
key_offsets: new Array(16).fill(1)
}
],
vout: [
{
amount: 0,
target: { type: TXOUT_TYPE.to_carrot_v1, key: 'a'.repeat(64) }
}
],
...overrides
},
rct: {
type: RCT_TYPE_NAMES.SalviumOne,
...overrides.rct
}
};
}
// =============================================================================
// CONSTANTS TESTS
// =============================================================================
describe('Validation Constants', () => {
test('MINIMUM_MIXIN is 15', () => {
expect(MINIMUM_MIXIN).toBe(15);
});
test('VALID_ASSET_TYPES includes SAL, SAL1, BURN', () => {
expect(VALID_ASSET_TYPES).toContain('SAL');
expect(VALID_ASSET_TYPES).toContain('SAL1');
expect(VALID_ASSET_TYPES).toContain('BURN');
});
test('ASSET_TYPE_ID has correct values', () => {
expect(ASSET_TYPE_ID.SAL).toBe(0x53414C00);
expect(ASSET_TYPE_ID.SAL1).toBe(0x53414C31);
expect(ASSET_TYPE_ID.BURN).toBe(0x4255524E);
});
test('RCT_TYPE_NAMES has expected values', () => {
expect(RCT_TYPE_NAMES.Null).toBe(0);
expect(RCT_TYPE_NAMES.CLSAG).toBe(5);
expect(RCT_TYPE_NAMES.BulletproofPlus).toBe(6);
expect(RCT_TYPE_NAMES.SalviumZero).toBe(7);
expect(RCT_TYPE_NAMES.SalviumOne).toBe(8);
});
test('AUDIT_HARD_FORKS has audit periods configured', () => {
expect(AUDIT_HARD_FORKS[6]).toBeDefined();
expect(AUDIT_HARD_FORKS[6].name).toBe('AUDIT1');
expect(AUDIT_HARD_FORKS[8]).toBeDefined();
expect(AUDIT_HARD_FORKS[8].name).toBe('AUDIT2');
});
});
// =============================================================================
// ASSET TYPE TESTS
// =============================================================================
describe('Asset Type Functions', () => {
describe('assetTypeFromId', () => {
test('converts SAL ID to string', () => {
expect(assetTypeFromId(0x53414C00)).toBe('SAL');
});
test('converts SAL1 ID to string', () => {
expect(assetTypeFromId(0x53414C31)).toBe('SAL1');
});
test('converts BURN ID to string', () => {
expect(assetTypeFromId(0x4255524E)).toBe('BURN');
});
test('returns null for invalid ID', () => {
expect(assetTypeFromId(0x12345678)).toBeNull();
});
});
describe('assetIdFromType', () => {
test('converts SAL string to ID', () => {
expect(assetIdFromType('SAL')).toBe(0x53414C00);
});
test('converts SAL1 string to ID', () => {
expect(assetIdFromType('SAL1')).toBe(0x53414C31);
});
test('converts BURN string to ID', () => {
expect(assetIdFromType('BURN')).toBe(0x4255524E);
});
test('returns null for invalid type', () => {
expect(assetIdFromType('INVALID')).toBeNull();
});
});
describe('validateAssetTypes', () => {
test('valid TRANSFER with matching assets', () => {
const tx = createMockTransaction({
txType: TX_TYPE.TRANSFER,
source_asset_type: 'SAL1',
destination_asset_type: 'SAL1'
});
const result = validateAssetTypes(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(true);
});
test('invalid source asset type', () => {
const tx = createMockTransaction({
source_asset_type: 'INVALID',
destination_asset_type: 'SAL1'
});
const result = validateAssetTypes(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid source asset type');
});
test('BURN must have destination = BURN', () => {
const tx = createMockTransaction({
txType: TX_TYPE.BURN,
source_asset_type: 'SAL',
destination_asset_type: 'SAL' // Should be BURN
});
const result = validateAssetTypes(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('destination_asset_type = "BURN"');
});
test('BURN with correct destination is valid', () => {
const tx = createMockTransaction({
txType: TX_TYPE.BURN,
source_asset_type: 'SAL',
destination_asset_type: 'BURN'
});
const result = validateAssetTypes(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(true);
});
test('cannot spend BURN coins', () => {
const tx = createMockTransaction({
txType: TX_TYPE.TRANSFER,
source_asset_type: 'BURN',
destination_asset_type: 'BURN'
});
const result = validateAssetTypes(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('Cannot spend BURN');
});
test('non-BURN/CONVERT must have matching source and dest', () => {
const tx = createMockTransaction({
txType: TX_TYPE.TRANSFER,
source_asset_type: 'SAL',
destination_asset_type: 'SAL1'
});
const result = validateAssetTypes(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('must match');
});
});
});
// =============================================================================
// TRANSACTION TYPE/VERSION TESTS
// =============================================================================
describe('Transaction Type and Version Validation', () => {
test('valid TRANSFER transaction', () => {
const tx = createMockTransaction({ txType: TX_TYPE.TRANSFER });
const result = validateTxTypeAndVersion(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(true);
});
test('invalid transaction type 0', () => {
const tx = createMockTransaction({ txType: 0 });
const result = validateTxTypeAndVersion(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('UNSET is invalid');
});
test('invalid transaction type > 8', () => {
const tx = createMockTransaction({ txType: 99 });
const result = validateTxTypeAndVersion(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid transaction type');
});
test('STAKE requires version 4 at Carrot fork', () => {
const tx = createMockTransaction({ txType: TX_TYPE.STAKE, version: 2 });
const result = validateTxTypeAndVersion(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('requires version 4');
});
test('CONVERT not allowed before oracle HF', () => {
const tx = createMockTransaction({ txType: TX_TYPE.CONVERT });
const result = validateTxTypeAndVersion(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('CONVERT transactions not enabled');
});
test('AUDIT only allowed in audit HF periods', () => {
const tx = createMockTransaction({ txType: TX_TYPE.AUDIT });
// HF version 5 is not an audit HF
const result = validateTxTypeAndVersion(tx, 5);
expect(result.valid).toBe(false);
expect(result.error).toContain('only allowed in audit hard fork');
});
test('AUDIT allowed in HF version 6', () => {
const tx = createMockTransaction({ txType: TX_TYPE.AUDIT, version: 4 });
const result = validateTxTypeAndVersion(tx, 6);
expect(result.valid).toBe(true);
});
});
// =============================================================================
// OUTPUT VALIDATION TESTS
// =============================================================================
describe('Output Validation', () => {
describe('validateOutputTypes', () => {
test('AUDIT must have 0 outputs', () => {
const tx = createMockTransaction({
txType: TX_TYPE.AUDIT,
vout: [{ amount: 0, target: { type: TXOUT_TYPE.to_carrot_v1 } }]
});
const result = validateOutputTypes(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('0 outputs');
});
test('AUDIT with 0 outputs is valid', () => {
const tx = createMockTransaction({
txType: TX_TYPE.AUDIT,
vout: []
});
const result = validateOutputTypes(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(true);
});
test('STAKE must have exactly 1 output', () => {
const tx = createMockTransaction({
txType: TX_TYPE.STAKE,
vout: [
{ amount: 0, target: { type: TXOUT_TYPE.to_carrot_v1 } },
{ amount: 0, target: { type: TXOUT_TYPE.to_carrot_v1 } }
]
});
const result = validateOutputTypes(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('exactly 1 output');
});
});
describe('validateOutputPubkeySorting', () => {
test('sorted outputs are valid', () => {
const tx = createMockTransaction({
vout: [
{ amount: 0, target: { key: '1'.repeat(64) } },
{ amount: 0, target: { key: '2'.repeat(64) } }
]
});
const result = validateOutputPubkeySorting(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(true);
});
test('not enforced before Carrot fork', () => {
const tx = createMockTransaction({
vout: [
{ amount: 0, target: { key: '9'.repeat(64) } },
{ amount: 0, target: { key: '1'.repeat(64) } }
]
});
const result = validateOutputPubkeySorting(tx, HF_VERSION.AUDIT1);
expect(result.valid).toBe(true);
});
});
describe('validateOutputsOverflow', () => {
test('normal amounts are valid', () => {
const tx = createMockTransaction({
vout: [
{ amount: 1000000000 },
{ amount: 2000000000 }
]
});
const result = validateOutputsOverflow(tx);
expect(result.valid).toBe(true);
});
test('detects overflow', () => {
const tx = createMockTransaction({
vout: [
{ amount: 18446744073709551615n },
{ amount: 1n }
]
});
const result = validateOutputsOverflow(tx);
expect(result.valid).toBe(false);
expect(result.error).toContain('overflow');
});
});
});
// =============================================================================
// RCT TYPE VALIDATION TESTS
// =============================================================================
describe('RCT Type Validation', () => {
test('SalviumOne required at Carrot fork', () => {
const tx = createMockTransaction({
txType: TX_TYPE.TRANSFER,
rct: { type: RCT_TYPE_NAMES.BulletproofPlus }
});
const result = validateRctType(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('RCTTypeSalviumOne');
});
test('SalviumOne is valid at Carrot fork', () => {
const tx = createMockTransaction({
txType: TX_TYPE.TRANSFER,
rct: { type: RCT_TYPE_NAMES.SalviumOne }
});
const result = validateRctType(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(true);
});
test('MINER must have RCTTypeNull', () => {
const tx = createMockTransaction({
txType: TX_TYPE.MINER,
rct: { type: RCT_TYPE_NAMES.SalviumOne }
});
const result = validateRctType(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('RCTTypeNull');
});
test('MINER with RCTTypeNull is valid', () => {
const tx = createMockTransaction({
txType: TX_TYPE.MINER,
rct: { type: RCT_TYPE_NAMES.Null }
});
const result = validateRctType(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(true);
});
});
// =============================================================================
// INPUT VALIDATION TESTS
// =============================================================================
describe('Input Validation', () => {
test('valid inputs pass', () => {
const tx = createMockTransaction();
const result = validateInputs(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(true);
});
test('no inputs fails', () => {
const tx = createMockTransaction({ vin: [] });
const result = validateInputs(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('at least 1 input');
});
test('wrong ring size fails', () => {
const tx = createMockTransaction({
vin: [{
type: 'key',
k_image: '0'.repeat(64),
key_offsets: new Array(11).fill(1) // Wrong ring size
}]
});
const result = validateInputs(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('ring size must be');
});
test('MINER must have txin_gen', () => {
const tx = createMockTransaction({
txType: TX_TYPE.MINER,
vin: [{ height: 100 }] // txin_gen has height
});
const result = validateInputs(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(true);
});
test('key images must be sorted', () => {
const tx = createMockTransaction({
vin: [
{ type: 'key', k_image: 'f'.repeat(64), key_offsets: new Array(16).fill(1) },
{ type: 'key', k_image: 'a'.repeat(64), key_offsets: new Array(16).fill(1) }
]
});
const result = validateInputs(tx, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('sorted');
});
});
// =============================================================================
// FEE VALIDATION TESTS
// =============================================================================
describe('Fee Validation', () => {
test('getFeeQuantizationMask returns correct mask', () => {
const mask = getFeeQuantizationMask();
expect(mask).toBe(99999999n); // 10^8 - 1
});
test('calculateRequiredFee returns non-zero fee', () => {
const fee = calculateRequiredFee(3000, COIN, 300000, HF_VERSION.CARROT);
expect(fee).toBeGreaterThan(0n);
});
test('sufficient fee passes', () => {
const requiredFee = calculateRequiredFee(3000, COIN, 300000, HF_VERSION.CARROT);
const result = validateFee(requiredFee, 3000, COIN, 300000, HF_VERSION.CARROT);
expect(result.valid).toBe(true);
});
test('insufficient fee fails', () => {
const result = validateFee(1n, 3000, COIN, 300000, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('Insufficient fee');
});
});
// =============================================================================
// WEIGHT VALIDATION TESTS
// =============================================================================
describe('Weight Validation', () => {
test('getTransactionWeightLimit returns reasonable limit', () => {
const limit = getTransactionWeightLimit(HF_VERSION.CARROT);
expect(limit).toBeGreaterThan(100000); // Should be significant
expect(limit).toBeLessThan(300000); // But not too large
});
test('normal weight passes', () => {
const result = validateTxWeight(10000, HF_VERSION.CARROT);
expect(result.valid).toBe(true);
});
test('excessive weight fails', () => {
const result = validateTxWeight(500000, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('exceeds limit');
});
});
// =============================================================================
// MINER TRANSACTION TESTS
// =============================================================================
describe('Miner Transaction Validation', () => {
test('valid miner transaction passes prevalidation', () => {
const minerTx = {
prefix: {
version: 4,
txType: TX_TYPE.MINER,
vin: [{ height: 100 }],
vout: [{ amount: COIN, target: { type: TXOUT_TYPE.to_carrot_v1, key: 'a'.repeat(64) } }]
},
rct: { type: RCT_TYPE_NAMES.Null }
};
const result = prevalidateMinerTransaction(minerTx, 100, HF_VERSION.CARROT);
expect(result.valid).toBe(true);
});
test('miner tx with wrong input count fails', () => {
const minerTx = {
prefix: {
version: 4,
txType: TX_TYPE.MINER,
vin: [],
vout: []
}
};
const result = prevalidateMinerTransaction(minerTx, 100, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('exactly 1 input');
});
test('miner tx with wrong height fails', () => {
const minerTx = {
prefix: {
version: 4,
txType: TX_TYPE.MINER,
vin: [{ height: 50 }], // Wrong height
vout: []
}
};
const result = prevalidateMinerTransaction(minerTx, 100, HF_VERSION.CARROT);
expect(result.valid).toBe(false);
expect(result.error).toContain('height');
});
});
// =============================================================================
// YIELD CALCULATION TESTS
// =============================================================================
describe('Yield Calculation', () => {
test('calculateYieldPayout returns 0 for empty blocks', () => {
const yieldAmount = calculateYieldPayout(1000000000n, []);
expect(yieldAmount).toBe(0n);
});
test('calculateYieldPayout calculates proportional yield', () => {
const yieldBlocks = [
{ slippageTotal: 1000000n, lockedCoinsTally: 10000000000n },
{ slippageTotal: 2000000n, lockedCoinsTally: 10000000000n }
];
const stakedAmount = 5000000000n; // 50% of total
const yieldAmount = calculateYieldPayout(stakedAmount, yieldBlocks);
// Expected: (1M * 5B / 10B) + (2M * 5B / 10B) = 0.5M + 1M = 1.5M
expect(yieldAmount).toBe(1500000n);
});
test('getStakeLockPeriod returns correct period for mainnet', () => {
const period = getStakeLockPeriod('mainnet');
expect(period).toBeGreaterThan(1000); // Should be significant
});
test('getStakeLockPeriod returns shorter period for testnet', () => {
const testnetPeriod = getStakeLockPeriod('testnet');
const mainnetPeriod = getStakeLockPeriod('mainnet');
expect(testnetPeriod).toBeLessThan(mainnetPeriod);
});
});
// =============================================================================
// COMPREHENSIVE VALIDATION TESTS
// =============================================================================
describe('Comprehensive Transaction Validation', () => {
test('valid transaction passes all checks', () => {
const tx = createMockTransaction({
txType: TX_TYPE.TRANSFER,
source_asset_type: 'SAL1',
destination_asset_type: 'SAL1',
version: 4,
vin: [{
type: 'key',
k_image: '0'.repeat(64),
key_offsets: new Array(16).fill(1)
}],
vout: [{ amount: 0, target: { type: TXOUT_TYPE.to_carrot_v1, key: 'a'.repeat(64) } }],
rct: { type: RCT_TYPE_NAMES.SalviumOne }
});
const result = validateTransactionFull(tx, { hfVersion: HF_VERSION.CARROT });
expect(result.valid).toBe(true);
expect(result.errors.length).toBe(0);
});
test('invalid transaction collects all errors', () => {
const tx = {
prefix: {
version: 2,
txType: 0, // Invalid
source_asset_type: 'INVALID',
destination_asset_type: 'BURN',
vin: [], // Invalid - no inputs
vout: []
},
rct: { type: RCT_TYPE_NAMES.BulletproofPlus } // Wrong type
};
const result = validateTransactionFull(tx, { hfVersion: HF_VERSION.CARROT });
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
});
console.log('\n=== Validation Module Tests ===\n');