● 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:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user