● 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
This commit is contained in:
Matt Hess
2026-01-26 12:24:44 +00:00
parent 43e1ebbef8
commit 6eeadbe8ed
4 changed files with 1771 additions and 7 deletions
+6
View File
@@ -39,6 +39,12 @@ export * from './wallet-sync.js';
export * from './persistent-wallet.js';
export * from './consensus.js';
// Validation exports
export * from './validation.js';
// Validation namespace
export * as validation from './validation.js';
// Oracle exports (selective to avoid COIN conflict with consensus.js)
export {
PRICING_RECORD_VALID_BLOCKS,
+989
View File
@@ -0,0 +1,989 @@
/**
* Salvium Transaction and Block Validation
*
* Complete validation rules for Salvium transactions and blocks.
* Faithfully ported from C++ implementation.
*
* Reference: ~/github/salvium/src/cryptonote_core/blockchain.cpp
* ~/github/salvium/src/cryptonote_core/tx_verification_utils.cpp
* ~/github/salvium/src/cryptonote_basic/cryptonote_format_utils.cpp
*
* @module validation
*/
import {
COIN,
CRYPTONOTE_MAX_TX_SIZE,
MAX_TX_EXTRA_SIZE,
BULLETPROOF_PLUS_MAX_OUTPUTS,
DEFAULT_RING_SIZE,
CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW,
CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE_V5,
HF_VERSION,
MAINNET_CONFIG,
TESTNET_CONFIG,
getMinBlockWeight,
getBlockReward,
FEE_PER_BYTE,
DYNAMIC_FEE_PER_KB_BASE_FEE,
DYNAMIC_FEE_PER_KB_BASE_BLOCK_REWARD,
PER_KB_FEE_QUANTIZATION_DECIMALS,
TRANSACTION_VERSION_2_OUTS,
TRANSACTION_VERSION_N_OUTS,
TRANSACTION_VERSION_CARROT
} from './consensus.js';
import { TX_TYPE, RCT_TYPE } from './transaction.js';
// =============================================================================
// CONSTANTS
// =============================================================================
/**
* Minimum mixin (ring size - 1)
* Salvium requires 15 decoys (ring size 16)
*/
export const MINIMUM_MIXIN = 15;
/**
* Reserved size for coinbase blob
*/
export const CRYPTONOTE_COINBASE_BLOB_RESERVED_SIZE = 600;
/**
* Valid asset types in Salvium
*/
export const VALID_ASSET_TYPES = ['SAL', 'SAL1', 'BURN'];
/**
* Asset type IDs (matching C++ asset_types.h)
*/
export const ASSET_TYPE_ID = {
SAL: 0x53414C00, // "SAL\0"
SAL1: 0x53414C31, // "SAL1"
BURN: 0x4255524E, // "BURN"
};
/**
* RCT type constants (matching C++ rctTypes.h)
*/
export const RCT_TYPE_NAMES = {
Null: 0,
Full: 1,
Simple: 2,
Bulletproof: 3,
Bulletproof2: 4,
CLSAG: 5,
BulletproofPlus: 6,
SalviumZero: 7,
SalviumOne: 8,
FullProofs: 9,
};
/**
* Output types (matching C++ cryptonote_basic.h)
*/
export const TXOUT_TYPE = {
to_key: 0,
to_tagged_key: 1,
to_carrot_v1: 2,
};
/**
* Hardcoded blacklisted transactions
* Reference: blockchain.cpp calculate_yield_payouts and calculate_audit_payouts
*/
export const TX_BLACKLIST = [
'017a79539e69ce16e91d9aa2267c102f336678c41636567c1129e3e72149499a'
];
/**
* Audit hard fork periods configuration
*/
export const AUDIT_HARD_FORKS = {
6: { name: 'AUDIT1', assetType: 'SAL' },
8: { name: 'AUDIT2', assetType: 'SAL1' },
};
// =============================================================================
// TRANSACTION TYPE AND VERSION VALIDATION
// =============================================================================
/**
* Validate transaction type and version against hard fork rules
*
* Reference: ~/github/salvium/src/cryptonote_core/blockchain.cpp:3786-3881
*
* @param {Object} tx - Parsed transaction
* @param {number} hfVersion - Current hard fork version
* @returns {{valid: boolean, error?: string}}
*/
export function validateTxTypeAndVersion(tx, hfVersion) {
const prefix = tx.prefix || tx;
// Don't use fallback - 0 should be invalid
const txType = prefix.txType ?? prefix.type ?? null;
const version = prefix.version;
// Rule 1: UNSET (0) or missing is invalid
if (txType === 0 || txType === null || txType === undefined) {
return { valid: false, error: 'Transaction type UNSET is invalid' };
}
// Rule 2: TX type must be valid (1-8)
if (txType < TX_TYPE.MINER || txType > TX_TYPE.AUDIT) {
return { valid: false, error: `Invalid transaction type: ${txType}` };
}
// Rule 3: N-out transaction version support
if (hfVersion < HF_VERSION.ENABLE_N_OUTS) {
// Before HF v2: Only TX v2 allowed
if (version !== TRANSACTION_VERSION_2_OUTS) {
return { valid: false, error: `TX version ${version} not allowed before HF ${HF_VERSION.ENABLE_N_OUTS}` };
}
}
// Rule 4: Carrot fork requirements
if (hfVersion >= HF_VERSION.CARROT) {
// Non-TRANSFER types require TRANSACTION_VERSION_CARROT
if (txType !== TX_TYPE.TRANSFER && txType !== TX_TYPE.MINER && txType !== TX_TYPE.PROTOCOL) {
if (version !== TRANSACTION_VERSION_CARROT) {
return { valid: false, error: `TX type ${txType} requires version ${TRANSACTION_VERSION_CARROT} at Carrot fork` };
}
}
}
// Rule 5: CONVERT transaction support
if (txType === TX_TYPE.CONVERT) {
if (hfVersion < HF_VERSION.ENABLE_CONVERT) {
return { valid: false, error: 'CONVERT transactions not enabled before oracle HF' };
}
}
// Rule 6: AUDIT transaction support (only in designated audit HFs)
if (txType === TX_TYPE.AUDIT) {
if (!AUDIT_HARD_FORKS[hfVersion]) {
return { valid: false, error: `AUDIT transactions only allowed in audit hard fork periods (HF ${hfVersion} is not an audit fork)` };
}
}
return { valid: true };
}
// =============================================================================
// ASSET TYPE VALIDATION
// =============================================================================
/**
* Convert asset type ID to string
*
* Reference: ~/github/salvium/src/cryptonote_basic/cryptonote_format_utils.cpp:1103-1119
*
* @param {number} assetTypeId - Asset type ID
* @returns {string|null} Asset type string or null if invalid
*/
export function assetTypeFromId(assetTypeId) {
switch (assetTypeId) {
case ASSET_TYPE_ID.SAL: return 'SAL';
case ASSET_TYPE_ID.SAL1: return 'SAL1';
case ASSET_TYPE_ID.BURN: return 'BURN';
default: return null;
}
}
/**
* Convert asset type string to ID
*
* Reference: ~/github/salvium/src/cryptonote_basic/cryptonote_format_utils.cpp:1121-1135
*
* @param {string} assetType - Asset type string
* @returns {number|null} Asset type ID or null if invalid
*/
export function assetIdFromType(assetType) {
switch (assetType) {
case 'SAL': return ASSET_TYPE_ID.SAL;
case 'SAL1': return ASSET_TYPE_ID.SAL1;
case 'BURN': return ASSET_TYPE_ID.BURN;
default: return null;
}
}
/**
* Validate asset types for a transaction
*
* Reference: ~/github/salvium/src/cryptonote_core/blockchain.cpp:3852-3860
*
* @param {Object} tx - Parsed transaction
* @param {number} hfVersion - Current hard fork version
* @returns {{valid: boolean, error?: string}}
*/
export function validateAssetTypes(tx, hfVersion) {
const prefix = tx.prefix || tx;
const txType = prefix.txType || prefix.type || TX_TYPE.TRANSFER;
const sourceAsset = prefix.source_asset_type || 'SAL';
const destAsset = prefix.destination_asset_type || 'SAL';
// Validate asset type strings
if (!VALID_ASSET_TYPES.includes(sourceAsset)) {
return { valid: false, error: `Invalid source asset type: ${sourceAsset}` };
}
if (!VALID_ASSET_TYPES.includes(destAsset)) {
return { valid: false, error: `Invalid destination asset type: ${destAsset}` };
}
// Rule 1: BURN transactions must have destination_asset_type = "BURN"
if (txType === TX_TYPE.BURN) {
if (destAsset !== 'BURN') {
return { valid: false, error: 'BURN transactions must have destination_asset_type = "BURN"' };
}
// Source can be SAL or SAL1
if (sourceAsset !== 'SAL' && sourceAsset !== 'SAL1') {
return { valid: false, error: 'BURN source must be SAL or SAL1' };
}
return { valid: true };
}
// Rule 2: Cannot spend BURN coins
if (sourceAsset === 'BURN') {
return { valid: false, error: 'Cannot spend BURN coins' };
}
// Rule 3: CONVERT transactions allow SAL <-> VSD
if (txType === TX_TYPE.CONVERT) {
// CONVERT allows different source and dest assets
// Currently only SAL<->VSD but that's not enabled yet
return { valid: true };
}
// Rule 4: AUDIT transactions
if (txType === TX_TYPE.AUDIT) {
const auditConfig = AUDIT_HARD_FORKS[hfVersion];
if (auditConfig) {
// Source asset must match audit config
if (sourceAsset !== auditConfig.assetType && sourceAsset !== 'SAL') {
return { valid: false, error: `AUDIT source must be ${auditConfig.assetType} or SAL` };
}
}
return { valid: true };
}
// Rule 5: For non-BURN/CONVERT transactions, source must equal destination
if (sourceAsset !== destAsset) {
return { valid: false, error: `Source asset (${sourceAsset}) must match destination (${destAsset}) for TX type ${txType}` };
}
// Rule 6: After certain HFs, only SAL1 allowed for regular transactions
if (hfVersion >= HF_VERSION.AUDIT1_PAUSE) {
if (txType === TX_TYPE.TRANSFER || txType === TX_TYPE.STAKE) {
if (sourceAsset !== 'SAL1' && sourceAsset !== 'SAL') {
return { valid: false, error: 'Only SAL1 or SAL allowed for TRANSFER/STAKE after AUDIT1_PAUSE' };
}
}
}
return { valid: true };
}
// =============================================================================
// OUTPUT VALIDATION
// =============================================================================
/**
* Validate output types for a transaction
*
* Reference: ~/github/salvium/src/cryptonote_basic/cryptonote_format_utils.cpp:1376-1437
*
* @param {Object} tx - Parsed transaction
* @param {number} hfVersion - Current hard fork version
* @returns {{valid: boolean, error?: string}}
*/
export function validateOutputTypes(tx, hfVersion) {
const prefix = tx.prefix || tx;
const txType = prefix.txType || prefix.type || TX_TYPE.TRANSFER;
const outputs = prefix.vout || prefix.outputs || [];
// Rule 1: AUDIT and STAKE must have exactly 1 output (or 0 for change-is-zero)
if (txType === TX_TYPE.AUDIT) {
// AUDIT has 0 outputs (change-is-zero)
if (outputs.length !== 0) {
return { valid: false, error: 'AUDIT transactions must have 0 outputs (change-is-zero)' };
}
return { valid: true };
}
if (txType === TX_TYPE.STAKE) {
if (outputs.length !== 1) {
return { valid: false, error: 'STAKE transactions must have exactly 1 output' };
}
}
// Rule 2: Carrot fork output type requirements
if (hfVersion >= HF_VERSION.CARROT) {
for (const output of outputs) {
const targetType = output.target?.type;
// Non-PROTOCOL transactions must use txout_to_carrot_v1
if (txType !== TX_TYPE.PROTOCOL && txType !== TX_TYPE.MINER) {
if (targetType !== undefined && targetType !== TXOUT_TYPE.to_carrot_v1) {
return { valid: false, error: 'Non-PROTOCOL transactions must use txout_to_carrot_v1 outputs at Carrot fork' };
}
}
}
}
// Rule 3: All outputs must have same type (consistency check)
if (outputs.length > 1) {
const firstType = outputs[0].target?.type;
for (let i = 1; i < outputs.length; i++) {
if (outputs[i].target?.type !== firstType) {
return { valid: false, error: 'All outputs must have the same target type' };
}
}
}
return { valid: true };
}
/**
* Validate that output public keys are sorted (Carrot fork requirement)
*
* Reference: ~/github/salvium/src/cryptonote_core/tx_verification_utils.cpp:153-168
*
* @param {Object} tx - Parsed transaction
* @param {number} hfVersion - Current hard fork version
* @returns {{valid: boolean, error?: string}}
*/
export function validateOutputPubkeySorting(tx, hfVersion) {
// Only enforced from Carrot fork
if (hfVersion < HF_VERSION.CARROT) {
return { valid: true };
}
const prefix = tx.prefix || tx;
const outputs = prefix.vout || prefix.outputs || [];
if (outputs.length < 2) {
return { valid: true };
}
// Extract public keys and compare lexicographically
let prevKey = null;
for (const output of outputs) {
const key = output.target?.key || output.target?.data?.key;
if (!key) continue;
const keyBytes = typeof key === 'string'
? Buffer.from(key, 'hex')
: key;
if (prevKey !== null) {
// Compare lexicographically
for (let i = 0; i < 32; i++) {
if (keyBytes[i] < prevKey[i]) {
return { valid: false, error: 'Output public keys must be sorted in increasing order' };
}
if (keyBytes[i] > prevKey[i]) {
break; // This key is greater, move on
}
// If equal, continue to next byte
}
}
prevKey = keyBytes;
}
return { valid: true };
}
/**
* Check for output amount overflow
*
* Reference: ~/github/salvium/src/cryptonote_basic/cryptonote_format_utils.cpp:1074-1084
*
* @param {Object} tx - Parsed transaction
* @returns {{valid: boolean, error?: string}}
*/
export function validateOutputsOverflow(tx) {
const prefix = tx.prefix || tx;
const outputs = prefix.vout || prefix.outputs || [];
let totalAmount = 0n;
const MAX_MONEY = 18446744073709551615n; // 2^64 - 1
for (const output of outputs) {
const amount = BigInt(output.amount || 0);
if (totalAmount > MAX_MONEY - amount) {
return { valid: false, error: 'Output amounts overflow' };
}
totalAmount += amount;
}
return { valid: true };
}
// =============================================================================
// RCT TYPE VALIDATION
// =============================================================================
/**
* Validate RCT signature type for hard fork version
*
* Reference: ~/github/salvium/src/cryptonote_core/blockchain.cpp:3729-3765
*
* @param {Object} tx - Parsed transaction
* @param {number} hfVersion - Current hard fork version
* @returns {{valid: boolean, error?: string}}
*/
export function validateRctType(tx, hfVersion) {
const prefix = tx.prefix || tx;
const txType = prefix.txType || prefix.type || TX_TYPE.TRANSFER;
const rctType = tx.rct?.type ?? tx.rctType ?? RCT_TYPE_NAMES.Null;
// MINER and PROTOCOL transactions must have RCTTypeNull
if (txType === TX_TYPE.MINER || txType === TX_TYPE.PROTOCOL) {
if (hfVersion >= HF_VERSION.REJECT_SIGS_IN_COINBASE) {
if (rctType !== RCT_TYPE_NAMES.Null) {
return { valid: false, error: 'MINER/PROTOCOL transactions must have RCTTypeNull' };
}
}
return { valid: true };
}
// User transactions: TRANSFER, STAKE, BURN, CONVERT, AUDIT
if (hfVersion >= HF_VERSION.CARROT) {
// At Carrot fork: Must use RCTTypeSalviumOne
if (rctType !== RCT_TYPE_NAMES.SalviumOne) {
return { valid: false, error: `TX type ${txType} must use RCTTypeSalviumOne at Carrot fork, got ${rctType}` };
}
} else if (hfVersion >= HF_VERSION.SALVIUM_ONE_PROOFS) {
// At SALVIUM_ONE_PROOFS: Must use RCTTypeSalviumZero
if (rctType !== RCT_TYPE_NAMES.SalviumZero) {
return { valid: false, error: `TX type ${txType} must use RCTTypeSalviumZero at HF ${hfVersion}` };
}
} else if (hfVersion >= HF_VERSION.ENFORCE_FULL_PROOFS) {
// At ENFORCE_FULL_PROOFS: Must use RCTTypeFullProofs
if (rctType !== RCT_TYPE_NAMES.FullProofs) {
return { valid: false, error: `TX type ${txType} must use RCTTypeFullProofs at HF ${hfVersion}` };
}
} else if (hfVersion >= HF_VERSION.BULLETPROOF_PLUS) {
// At BULLETPROOF_PLUS: Must use BulletproofPlus or CLSAG
if (rctType !== RCT_TYPE_NAMES.BulletproofPlus && rctType !== RCT_TYPE_NAMES.CLSAG) {
return { valid: false, error: `TX type ${txType} must use BulletproofPlus or CLSAG at HF ${hfVersion}` };
}
}
return { valid: true };
}
// =============================================================================
// INPUT VALIDATION
// =============================================================================
/**
* Validate transaction inputs
*
* Reference: ~/github/salvium/src/cryptonote_core/blockchain.cpp:3998-4200
*
* @param {Object} tx - Parsed transaction
* @param {number} hfVersion - Current hard fork version
* @returns {{valid: boolean, error?: string}}
*/
export function validateInputs(tx, hfVersion) {
const prefix = tx.prefix || tx;
const txType = prefix.txType || prefix.type || TX_TYPE.TRANSFER;
const inputs = prefix.vin || prefix.inputs || [];
// MINER/PROTOCOL use txin_gen (coinbase)
if (txType === TX_TYPE.MINER || txType === TX_TYPE.PROTOCOL) {
// Should have exactly 1 input of type txin_gen
if (inputs.length !== 1) {
return { valid: false, error: 'MINER/PROTOCOL must have exactly 1 input' };
}
const input = inputs[0];
if (!input.gen && !input.height && input.type !== 'gen') {
return { valid: false, error: 'MINER/PROTOCOL input must be txin_gen type' };
}
return { valid: true };
}
// User transactions must have at least 1 input
if (inputs.length === 0) {
return { valid: false, error: 'Transaction must have at least 1 input' };
}
// Validate each input
let prevKeyImage = null;
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i];
// Must be txin_to_key type
if (input.type && input.type !== 'key') {
return { valid: false, error: `Input ${i} must be txin_to_key type` };
}
// Validate ring size
const ringSize = input.key_offsets?.length || input.keyOffsets?.length || 0;
if (ringSize === 0) {
return { valid: false, error: `Input ${i} has no ring members` };
}
if (ringSize !== DEFAULT_RING_SIZE) {
return { valid: false, error: `Input ${i} ring size must be ${DEFAULT_RING_SIZE}, got ${ringSize}` };
}
// Key images must be sorted (from HF v1)
if (hfVersion >= 1) {
const keyImage = input.k_image || input.keyImage;
if (keyImage && prevKeyImage) {
const kiBytes = typeof keyImage === 'string'
? Buffer.from(keyImage, 'hex')
: keyImage;
const prevKiBytes = typeof prevKeyImage === 'string'
? Buffer.from(prevKeyImage, 'hex')
: prevKeyImage;
// Compare lexicographically (must be strictly increasing)
let comparison = 0;
for (let j = 0; j < 32; j++) {
if (kiBytes[j] < prevKiBytes[j]) {
comparison = -1;
break;
}
if (kiBytes[j] > prevKiBytes[j]) {
comparison = 1;
break;
}
}
if (comparison <= 0) {
return { valid: false, error: 'Key images must be sorted in strictly increasing order' };
}
}
prevKeyImage = input.k_image || input.keyImage;
}
}
return { valid: true };
}
// =============================================================================
// FEE VALIDATION
// =============================================================================
/**
* Get fee quantization mask
*
* @returns {bigint} Fee quantization mask
*/
export function getFeeQuantizationMask() {
return (10n ** BigInt(PER_KB_FEE_QUANTIZATION_DECIMALS)) - 1n;
}
/**
* Calculate required fee for a transaction
*
* Reference: ~/github/salvium/src/cryptonote_core/blockchain.cpp:4411-4440
*
* @param {number} txWeight - Transaction weight in bytes
* @param {bigint} baseReward - Current base block reward
* @param {number} medianWeight - Median block weight
* @param {number} hfVersion - Hard fork version
* @returns {bigint} Required fee
*/
export function calculateRequiredFee(txWeight, baseReward, medianWeight, hfVersion = 1) {
// Get minimum block weight for full reward
const minBlockWeight = getMinBlockWeight(hfVersion);
const effectiveMedian = Math.max(medianWeight, minBlockWeight);
// Calculate fee per byte
let feePerByte;
if (hfVersion >= HF_VERSION.SCALING_2021) {
// Dynamic fee calculation
// fee_per_byte = (base_fee * base_reward_reference) / base_reward
const baseFee = DYNAMIC_FEE_PER_KB_BASE_FEE / 1024n;
const baseRewardRef = DYNAMIC_FEE_PER_KB_BASE_BLOCK_REWARD;
if (baseReward > 0n) {
feePerByte = (baseFee * baseRewardRef) / baseReward;
// Minimum fee per byte
if (feePerByte < FEE_PER_BYTE) {
feePerByte = FEE_PER_BYTE;
}
} else {
feePerByte = FEE_PER_BYTE;
}
} else {
feePerByte = FEE_PER_BYTE;
}
// Calculate needed fee
let neededFee = BigInt(txWeight) * feePerByte;
// Quantize fee
const mask = getFeeQuantizationMask();
neededFee = ((neededFee + mask) / (mask + 1n)) * (mask + 1n);
return neededFee;
}
/**
* Validate transaction fee
*
* Reference: ~/github/salvium/src/cryptonote_core/blockchain.cpp:4411-4440
*
* @param {bigint} fee - Transaction fee
* @param {number} txWeight - Transaction weight
* @param {bigint} baseReward - Current base reward
* @param {number} medianWeight - Median block weight
* @param {number} hfVersion - Hard fork version
* @returns {{valid: boolean, error?: string, required?: bigint}}
*/
export function validateFee(fee, txWeight, baseReward, medianWeight, hfVersion = 1) {
const neededFee = calculateRequiredFee(txWeight, baseReward, medianWeight, hfVersion);
// Allow 2% tolerance
const minFee = neededFee - (neededFee / 50n);
if (fee < minFee) {
return {
valid: false,
error: `Insufficient fee: ${fee} < ${neededFee} (min ${minFee})`,
required: neededFee
};
}
return { valid: true, required: neededFee };
}
// =============================================================================
// TRANSACTION WEIGHT VALIDATION
// =============================================================================
/**
* Get maximum transaction weight limit
*
* Reference: ~/github/salvium/src/cryptonote_core/tx_verification_utils.cpp:144-151
*
* @param {number} hfVersion - Hard fork version
* @returns {number} Maximum transaction weight
*/
export function getTransactionWeightLimit(hfVersion) {
const minBlockWeight = getMinBlockWeight(hfVersion);
if (hfVersion >= 2) {
// From HF v2+: Limit to 50% of minimum block weight minus reserved size
return Math.floor(minBlockWeight / 2) - CRYPTONOTE_COINBASE_BLOB_RESERVED_SIZE;
}
// Prior to HF v2: Full minimum block weight minus reserved size
return minBlockWeight - CRYPTONOTE_COINBASE_BLOB_RESERVED_SIZE;
}
/**
* Validate transaction weight
*
* @param {number} txWeight - Transaction weight in bytes
* @param {number} hfVersion - Hard fork version
* @returns {{valid: boolean, error?: string}}
*/
export function validateTxWeight(txWeight, hfVersion) {
const maxWeight = getTransactionWeightLimit(hfVersion);
if (txWeight > maxWeight) {
return {
valid: false,
error: `Transaction weight ${txWeight} exceeds limit ${maxWeight}`
};
}
return { valid: true };
}
// =============================================================================
// MINER TRANSACTION VALIDATION
// =============================================================================
/**
* Prevalidate miner transaction (coinbase)
*
* Reference: ~/github/salvium/src/cryptonote_core/blockchain.cpp:1344-1386
*
* @param {Object} minerTx - Miner transaction
* @param {number} height - Block height
* @param {number} hfVersion - Hard fork version
* @returns {{valid: boolean, error?: string}}
*/
export function prevalidateMinerTransaction(minerTx, height, hfVersion) {
const prefix = minerTx.prefix || minerTx;
const inputs = prefix.vin || prefix.inputs || [];
// Must have exactly 1 input
if (inputs.length !== 1) {
return { valid: false, error: 'Miner transaction must have exactly 1 input' };
}
// Input must be txin_gen (coinbase)
const input = inputs[0];
const inputHeight = input.height ?? input.gen?.height;
if (inputHeight === undefined) {
return { valid: false, error: 'Miner transaction input must be txin_gen type' };
}
// Input height must match block height
if (inputHeight !== height) {
return { valid: false, error: `Miner TX input height ${inputHeight} != block height ${height}` };
}
// Version requirements
const version = prefix.version;
if (version <= 1) {
return { valid: false, error: 'Miner transaction version must be > 1' };
}
if (hfVersion >= HF_VERSION.CARROT) {
if (version !== TRANSACTION_VERSION_CARROT) {
return { valid: false, error: `Miner TX version must be ${TRANSACTION_VERSION_CARROT} at Carrot fork` };
}
// Type must be MINER
const txType = prefix.txType || prefix.type;
if (txType !== TX_TYPE.MINER) {
return { valid: false, error: 'Miner TX type must be MINER at Carrot fork' };
}
}
// Check output overflow
const overflowResult = validateOutputsOverflow(minerTx);
if (!overflowResult.valid) {
return overflowResult;
}
// Check output types
const outputTypesResult = validateOutputTypes(minerTx, hfVersion);
if (!outputTypesResult.valid) {
return outputTypesResult;
}
// Check output sorting at Carrot fork
const sortingResult = validateOutputPubkeySorting(minerTx, hfVersion);
if (!sortingResult.valid) {
return sortingResult;
}
return { valid: true };
}
/**
* Validate miner transaction reward
*
* Reference: ~/github/salvium/src/cryptonote_core/blockchain.cpp:1486-1557
*
* @param {Object} minerTx - Miner transaction
* @param {number} blockWeight - Block weight
* @param {bigint} fee - Total fees in block
* @param {bigint} alreadyGeneratedCoins - Total coins generated before this block
* @param {number} hfVersion - Hard fork version
* @returns {{valid: boolean, error?: string, baseReward?: bigint}}
*/
export function validateMinerTransactionReward(minerTx, blockWeight, fee, alreadyGeneratedCoins, hfVersion) {
const prefix = minerTx.prefix || minerTx;
const outputs = prefix.vout || prefix.outputs || [];
// Calculate total money in outputs
let moneyInUse = 0n;
for (const output of outputs) {
moneyInUse += BigInt(output.amount || 0);
}
// Get base reward
const medianWeight = blockWeight; // Simplified - should use actual median
const rewardResult = getBlockReward(medianWeight, blockWeight, alreadyGeneratedCoins, hfVersion);
if (!rewardResult.success) {
return { valid: false, error: 'Block too large for reward' };
}
const baseReward = rewardResult.reward;
// Validate: base_reward + fee >= money_in_use
if (baseReward + fee < moneyInUse) {
return {
valid: false,
error: `Miner TX reward too high: ${moneyInUse} > ${baseReward + fee} (base + fee)`
};
}
// Note: Salvium has additional validation for amount_burnt = money_in_use / 5
// and treasury SAL1 minting at specific heights
return { valid: true, baseReward };
}
// =============================================================================
// YIELD CALCULATION
// =============================================================================
/**
* Yield block info structure
*
* @typedef {Object} YieldBlockInfo
* @property {bigint} slippageTotal - Total slippage for this block
* @property {bigint} lockedCoinsTally - Running tally of locked coins
*/
/**
* Calculate yield payout for a STAKE transaction
*
* Reference: ~/github/salvium/src/cryptonote_core/blockchain.cpp:4714-4777
*
* @param {bigint} stakedAmount - Amount staked
* @param {YieldBlockInfo[]} yieldBlocks - Yield info for each block in lock period
* @returns {bigint} Yield payout amount
*/
export function calculateYieldPayout(stakedAmount, yieldBlocks) {
let totalYield = 0n;
for (const blockInfo of yieldBlocks) {
if (blockInfo.lockedCoinsTally === 0n) continue;
// yield = (slippage_total * locked_coins) / locked_coins_tally
// Using 128-bit arithmetic simulation with BigInt
const yieldForBlock = (blockInfo.slippageTotal * stakedAmount) / blockInfo.lockedCoinsTally;
totalYield += yieldForBlock;
}
return totalYield;
}
/**
* Get stake lock period for a network
*
* @param {'mainnet'|'testnet'|'stagenet'} network - Network type
* @returns {number} Lock period in blocks
*/
export function getStakeLockPeriod(network = 'mainnet') {
switch (network) {
case 'testnet':
case 'stagenet':
return TESTNET_CONFIG.STAKE_LOCK_PERIOD;
default:
return MAINNET_CONFIG.STAKE_LOCK_PERIOD;
}
}
// =============================================================================
// COMPREHENSIVE TRANSACTION VALIDATION
// =============================================================================
/**
* Perform comprehensive transaction validation
*
* @param {Object} tx - Parsed transaction
* @param {Object} context - Validation context
* @param {number} context.hfVersion - Hard fork version
* @param {number} context.height - Current block height
* @param {bigint} context.baseReward - Current base reward
* @param {number} context.medianWeight - Median block weight
* @returns {{valid: boolean, errors: string[]}}
*/
export function validateTransactionFull(tx, context) {
const { hfVersion = 1, height = 0, baseReward = COIN, medianWeight = 300000 } = context;
const errors = [];
// 1. Validate TX type and version
const typeResult = validateTxTypeAndVersion(tx, hfVersion);
if (!typeResult.valid) {
errors.push(typeResult.error);
}
// 2. Validate asset types
const assetResult = validateAssetTypes(tx, hfVersion);
if (!assetResult.valid) {
errors.push(assetResult.error);
}
// 3. Validate output types
const outputTypeResult = validateOutputTypes(tx, hfVersion);
if (!outputTypeResult.valid) {
errors.push(outputTypeResult.error);
}
// 4. Validate output sorting
const sortingResult = validateOutputPubkeySorting(tx, hfVersion);
if (!sortingResult.valid) {
errors.push(sortingResult.error);
}
// 5. Validate output overflow
const overflowResult = validateOutputsOverflow(tx);
if (!overflowResult.valid) {
errors.push(overflowResult.error);
}
// 6. Validate RCT type
const rctResult = validateRctType(tx, hfVersion);
if (!rctResult.valid) {
errors.push(rctResult.error);
}
// 7. Validate inputs
const inputResult = validateInputs(tx, hfVersion);
if (!inputResult.valid) {
errors.push(inputResult.error);
}
return {
valid: errors.length === 0,
errors
};
}
// =============================================================================
// EXPORTS
// =============================================================================
export default {
// Constants
MINIMUM_MIXIN,
CRYPTONOTE_COINBASE_BLOB_RESERVED_SIZE,
VALID_ASSET_TYPES,
ASSET_TYPE_ID,
RCT_TYPE_NAMES,
TXOUT_TYPE,
TX_BLACKLIST,
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,
validateMinerTransactionReward,
// Yield calculation
calculateYieldPayout,
getStakeLockPeriod,
// Comprehensive validation
validateTransactionFull
};
+72 -7
View File
@@ -1822,8 +1822,19 @@ export class Wallet {
* @returns {Promise<Object>} Sweep transaction
*/
async sweepDust(address, dustThreshold = 100000000n, options = {}) {
const { accountIndex = 0, assetType = 'SAL' } = options;
if (!this.canSign()) {
throw new Error('Full wallet required to sweep dust');
}
const {
accountIndex = 0,
assetType = 'SAL',
ringSize = 16,
priority = 'low',
rpcClient = null
} = options;
// Get all unlocked UTXOs for account
const utxos = this.getUTXOs({ unlockedOnly: true, accountIndex, assetType });
const dustOutputs = utxos.filter(u => BigInt(u.amount) < dustThreshold);
@@ -1831,16 +1842,70 @@ export class Wallet {
throw new Error('No dust outputs to sweep');
}
// Calculate total amount from dust
const totalAmount = dustOutputs.reduce((sum, u) => sum + BigInt(u.amount), 0n);
const fee = estimateTransactionFee(dustOutputs.length, 1, { priority: 'low' });
if (totalAmount <= fee) {
throw new Error('Dust amount insufficient to cover fee');
// Estimate fee for sweeping all dust outputs
const estimatedFee = estimateTransactionFee(
dustOutputs.length,
1, // single output (sweep to destination)
{ priority, ringSize }
);
if (totalAmount <= estimatedFee) {
throw new Error(`Dust amount (${totalAmount}) insufficient to cover fee (${estimatedFee})`);
}
// Build transaction with only dust outputs
// TODO: Implement selective UTXO transaction building
throw new Error('Sweep dust not yet fully implemented');
// Prepare inputs with ring members (decoys)
const preparedInputs = await prepareInputs(dustOutputs, rpcClient, { ringSize });
// Recalculate fee with actual input count
const actualFee = estimateTransactionFee(
preparedInputs.length,
1,
{ priority, ringSize }
);
// Amount to sweep = total - fee
const sweepAmount = totalAmount - actualFee;
if (sweepAmount <= 0n) {
throw new Error(`Dust amount insufficient after fee calculation`);
}
// Parse destination address
const parsedDest = parseAddress(address);
if (!parsedDest.valid) {
throw new Error(`Invalid destination address: ${address}`);
}
// Build the sweep transaction
const tx = buildTransaction(
{
inputs: preparedInputs,
destinations: [{
address: parsedDest,
amount: sweepAmount
}],
fee: actualFee,
// No change output - we're sweeping everything
changeAddress: null
},
{
txType: TX_TYPE.TRANSFER,
sourceAssetType: assetType,
destinationAssetType: assetType,
useCarrot: parsedDest.format === ADDRESS_FORMAT.CARROT
}
);
// Validate
const validation = validateTransaction(tx);
if (!validation.valid) {
throw new Error(`Sweep transaction validation failed: ${validation.errors.join(', ')}`);
}
return tx;
}
// ===========================================================================
+704
View File
@@ -0,0 +1,704 @@
#!/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');