● Add oracle/pricing system implementation
- Create src/oracle.js with complete pricing record support - Data structures: PricingRecord, AssetData, SupplyData (matching C++ pricing_record.h) - DSA/ECDSA signature verification with mainnet/testnet public keys - Conversion rate calculation with rounding (getConversionRate, getConvertedAmount) - Slippage calculation (1/32 = 3.125%) with refund logic - Pricing record validation (HF gates, timestamps, signatures) - JSON serialization/parsing for oracle responses - HTTP client for fetching pricing records from oracle server - Add 66 unit tests in test/oracle.test.js - Export oracle module from index.js
This commit is contained in:
+94
-1
@@ -39,6 +39,33 @@ export * from './wallet-sync.js';
|
||||
export * from './persistent-wallet.js';
|
||||
export * from './consensus.js';
|
||||
|
||||
// Oracle exports (selective to avoid COIN conflict with consensus.js)
|
||||
export {
|
||||
PRICING_RECORD_VALID_BLOCKS,
|
||||
PRICING_RECORD_VALID_TIME_DIFF_FROM_BLOCK,
|
||||
CONVERSION_RATE_ROUNDING,
|
||||
ASSET_TYPES,
|
||||
HF_VERSION_SLIPPAGE_YIELD,
|
||||
ORACLE_URLS,
|
||||
ORACLE_PUBLIC_KEY_MAINNET,
|
||||
ORACLE_PUBLIC_KEY_TESTNET,
|
||||
createEmptyPricingRecord,
|
||||
isPricingRecordEmpty,
|
||||
getAssetPrice,
|
||||
getAssetMaPrice,
|
||||
buildSignatureMessage,
|
||||
verifyPricingRecordSignature,
|
||||
getOraclePublicKey,
|
||||
validatePricingRecord,
|
||||
getConversionRate,
|
||||
getConvertedAmount,
|
||||
calculateSlippage,
|
||||
calculateConversion,
|
||||
parsePricingRecordFromJson,
|
||||
pricingRecordToJson,
|
||||
fetchPricingRecord
|
||||
} from './oracle.js';
|
||||
|
||||
// RandomX proof-of-work (WASM-JIT implementation)
|
||||
export * as randomx from './randomx/index.js';
|
||||
export {
|
||||
@@ -81,6 +108,10 @@ export * as wordlists from './wordlists/index.js';
|
||||
// Usage: import { rpc } from 'salvium-js'; or import { DaemonRPC, WalletRPC } from 'salvium-js/rpc';
|
||||
export * as rpc from './rpc/index.js';
|
||||
|
||||
// Oracle/pricing available as namespace
|
||||
// Usage: import { oracle } from 'salvium-js';
|
||||
export * as oracle from './oracle.js';
|
||||
|
||||
// Stratum mining client
|
||||
export * as stratum from './stratum/index.js';
|
||||
export { StratumClient, StratumMiner, createMiner } from './stratum/index.js';
|
||||
@@ -649,6 +680,41 @@ import {
|
||||
validateRingSize
|
||||
} from './consensus.js';
|
||||
|
||||
import {
|
||||
// Constants
|
||||
COIN as ORACLE_COIN,
|
||||
PRICING_RECORD_VALID_BLOCKS as ORACLE_PR_VALID_BLOCKS,
|
||||
PRICING_RECORD_VALID_TIME_DIFF_FROM_BLOCK as ORACLE_PR_TIME_DIFF,
|
||||
CONVERSION_RATE_ROUNDING,
|
||||
ASSET_TYPES,
|
||||
HF_VERSION_ENABLE_ORACLE as ORACLE_HF_ENABLE,
|
||||
HF_VERSION_SLIPPAGE_YIELD,
|
||||
ORACLE_URLS,
|
||||
ORACLE_PUBLIC_KEY_MAINNET,
|
||||
ORACLE_PUBLIC_KEY_TESTNET,
|
||||
// Data structures
|
||||
createEmptyPricingRecord,
|
||||
isPricingRecordEmpty,
|
||||
getAssetPrice,
|
||||
getAssetMaPrice,
|
||||
// Signature verification
|
||||
buildSignatureMessage,
|
||||
verifyPricingRecordSignature,
|
||||
getOraclePublicKey,
|
||||
// Validation
|
||||
validatePricingRecord,
|
||||
// Conversion
|
||||
getConversionRate,
|
||||
getConvertedAmount,
|
||||
calculateSlippage,
|
||||
calculateConversion,
|
||||
// Serialization
|
||||
parsePricingRecordFromJson,
|
||||
pricingRecordToJson,
|
||||
// HTTP client
|
||||
fetchPricingRecord
|
||||
} from './oracle.js';
|
||||
|
||||
// Main API object
|
||||
const salvium = {
|
||||
// Constants
|
||||
@@ -1085,7 +1151,34 @@ const salvium = {
|
||||
PersistentWallet,
|
||||
createPersistentWallet,
|
||||
restorePersistentWallet,
|
||||
openPersistentWallet
|
||||
openPersistentWallet,
|
||||
|
||||
// Oracle/Pricing
|
||||
ORACLE_COIN,
|
||||
ORACLE_PR_VALID_BLOCKS,
|
||||
ORACLE_PR_TIME_DIFF,
|
||||
CONVERSION_RATE_ROUNDING,
|
||||
ASSET_TYPES,
|
||||
ORACLE_HF_ENABLE,
|
||||
HF_VERSION_SLIPPAGE_YIELD,
|
||||
ORACLE_URLS,
|
||||
ORACLE_PUBLIC_KEY_MAINNET,
|
||||
ORACLE_PUBLIC_KEY_TESTNET,
|
||||
createEmptyPricingRecord,
|
||||
isPricingRecordEmpty,
|
||||
getAssetPrice,
|
||||
getAssetMaPrice,
|
||||
buildSignatureMessage,
|
||||
verifyPricingRecordSignature,
|
||||
getOraclePublicKey,
|
||||
validatePricingRecord,
|
||||
getConversionRate,
|
||||
getConvertedAmount,
|
||||
calculateSlippage,
|
||||
calculateConversion,
|
||||
parsePricingRecordFromJson,
|
||||
pricingRecordToJson,
|
||||
fetchPricingRecord
|
||||
};
|
||||
|
||||
export default salvium;
|
||||
|
||||
+685
@@ -0,0 +1,685 @@
|
||||
/**
|
||||
* Oracle/Pricing Implementation for Salvium
|
||||
*
|
||||
* Faithful JavaScript port of Salvium's oracle pricing system.
|
||||
* Handles pricing records, signature verification, and conversion rates.
|
||||
*
|
||||
* Reference: ~/github/salvium/src/oracle/pricing_record.h
|
||||
* ~/github/salvium/src/oracle/pricing_record.cpp
|
||||
* ~/github/salvium/src/cryptonote_core/cryptonote_tx_utils.cpp
|
||||
*
|
||||
* @module oracle
|
||||
*/
|
||||
|
||||
import { createHash, createVerify } from 'crypto';
|
||||
|
||||
// ============================================================================
|
||||
// CONSTANTS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* COIN constant - atomic units per SAL (10^8)
|
||||
* Same as Monero: 1 SAL = 100,000,000 atomic units
|
||||
*/
|
||||
export const COIN = 100000000n;
|
||||
|
||||
/**
|
||||
* Pricing record validity window in blocks
|
||||
* A pricing record is valid for this many blocks after its height
|
||||
*/
|
||||
export const PRICING_RECORD_VALID_BLOCKS = 10;
|
||||
|
||||
/**
|
||||
* Maximum time difference (seconds) between pricing record and block timestamp
|
||||
* Record timestamp must be <= block_timestamp + this value
|
||||
*/
|
||||
export const PRICING_RECORD_VALID_TIME_DIFF_FROM_BLOCK = 120;
|
||||
|
||||
/**
|
||||
* Conversion rate rounding - rates are rounded down to nearest 10000
|
||||
*/
|
||||
export const CONVERSION_RATE_ROUNDING = 10000n;
|
||||
|
||||
/**
|
||||
* Valid asset types in Salvium
|
||||
*/
|
||||
export const ASSET_TYPES = ['SAL', 'SAL1', 'BURN'];
|
||||
|
||||
/**
|
||||
* Hard fork version when oracle is enabled
|
||||
* Currently set to 255 (not yet enabled)
|
||||
*/
|
||||
export const HF_VERSION_ENABLE_ORACLE = 255;
|
||||
export const HF_VERSION_SLIPPAGE_YIELD = 255;
|
||||
|
||||
/**
|
||||
* Oracle server URLs (mainnet)
|
||||
*/
|
||||
export const ORACLE_URLS = [
|
||||
'https://oracle.salvium.io:8443',
|
||||
'https://oracle.salvium.io:8443',
|
||||
'https://oracle.salvium.io:8443'
|
||||
];
|
||||
|
||||
/**
|
||||
* Oracle public key for mainnet (DSA format)
|
||||
* Used to verify pricing record signatures
|
||||
*
|
||||
* Reference: ~/github/salvium/src/cryptonote_config.h
|
||||
*/
|
||||
export const ORACLE_PUBLIC_KEY_MAINNET = `-----BEGIN PUBLIC KEY-----
|
||||
MIIDRDCCAjYGByqGSM44BAEwggIpAoIBAQCZP7IJ5PcNvGbWiEqAioKF9wViVxEN
|
||||
ZBDHvhr8IR6KoSYUXMU154DC6NDiSr6FtPBWuw9LcXlfWdG0l3hd6zObg0GpEQig
|
||||
jEeOEeBm45ug9lMBSZiaiCHeU8ats1YIQBYDO8m7iAj9Q9/N1nJHDpypsVu5WGLm
|
||||
+xSmcNULTbqwJ4Sr49TD++sv2MZEJeYRwmmxlqeFFtZlxguwJ90Y5U7aSi4w4vaU
|
||||
pu/Ce6EWi8pVhUlM5xBBk3tc+Z6FMMgKFN/kHyu3SbxFaRQppbsTo0N3yDAr3sN3
|
||||
4JmXpRmDidd3czfKlFko11YwK9lohjrgBnStuFRBxDACx4NRfvRfwPqnAh0AhKyn
|
||||
pbe2No+7lLGSWuQvIEz+2o6coQ3ZWPbxqQKCAQEAkErfS61wvKxbMwPuuqhCpZG/
|
||||
uQ+WYHwRwyxpU7ImKiH6ubqModIvZoHrRD8MIJhbRmBlA58SSnBWrEcAUIaaDM6Z
|
||||
xX/VyOFy2mJH3TJJa83oZe275w1JMVrVq1ZybXSYF595zAHNiJcYsskqTbZP8S30
|
||||
i3Bq//HMUaRhmB60BLmPpmgF3FVsRkCyEL/yH9cUQWdUcuxIG3C7EzgxGUCaR42J
|
||||
cu+NN8z6W/m/joEe6QkFT3tLh1yXIFBK1MamWC0EZ6YCMcozZfGQ15P3rMrGptKN
|
||||
+YQRNusTDSqBky+f40dLiYcT28ePQWNPLdsZTqoGWGawqCyWWCh5eWJZSqJPfQOC
|
||||
AQYAAoIBAQCE+8kHJmagnDPQWiuHziNha5yia7viwasxcsKhYGx+Z3wVbMrDPwLo
|
||||
CUgljEEsOKXLZsg/EmfVQu9nYoTcMa0hNq0/0bEV9oZ0t4O8gLp2Y4URLngR9zxE
|
||||
WaVgFLlNtndHUQA2kquP3XLkv/TZVQaqne6tO6p4gLC4ky0YH0vZhWXMOH/4Xfgf
|
||||
FZHC7SBC3oYsK9UKX3tJoibcL9L18GOe27pIw70x0280IB/C+TBnAXjslNgJe5ZU
|
||||
rSdr1h2nXji0rXL9DoypVC40QGIzzjCGMsSBnSYgVuITeqX8/o/w5LBK8Dl5wXFt
|
||||
F9dg9A0deyw/3CA3gwB32zkfi4MEH+il
|
||||
-----END PUBLIC KEY-----`;
|
||||
|
||||
/**
|
||||
* Oracle public key for testnet/stagenet (ECDSA format)
|
||||
*/
|
||||
export const ORACLE_PUBLIC_KEY_TESTNET = `-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE5YBxWx1AZCA9jTUk8Pr2uZ9jpfRt
|
||||
KWv3Vo1/Gny+1vfaxsXhBQiG1KlHkafNGarzoL0WHW4ocqaaqF5iv8i35A==
|
||||
-----END PUBLIC KEY-----`;
|
||||
|
||||
// ============================================================================
|
||||
// DATA STRUCTURES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Asset data structure for individual asset pricing
|
||||
*
|
||||
* Reference: ~/github/salvium/src/oracle/pricing_record.h (struct asset_data)
|
||||
*
|
||||
* @typedef {Object} AssetData
|
||||
* @property {string} assetType - Asset identifier ('SAL', 'SAL1', 'BURN')
|
||||
* @property {bigint} spotPrice - Current spot price (COIN units)
|
||||
* @property {bigint} maPrice - Moving average price (COIN units)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supply data structure for circulating supply tracking
|
||||
*
|
||||
* Reference: ~/github/salvium/src/oracle/pricing_record.h (struct supply_data)
|
||||
*
|
||||
* @typedef {Object} SupplyData
|
||||
* @property {bigint} sal - SAL circulating supply (atomic units)
|
||||
* @property {bigint} vsd - VSD circulating supply (atomic units)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pricing record structure
|
||||
*
|
||||
* Reference: ~/github/salvium/src/oracle/pricing_record.h (struct pricing_record)
|
||||
*
|
||||
* @typedef {Object} PricingRecord
|
||||
* @property {number} prVersion - Pricing record version
|
||||
* @property {number} height - Block height when record was generated
|
||||
* @property {SupplyData} supply - Current supply of SAL and VSD
|
||||
* @property {AssetData[]} assets - Array of asset prices
|
||||
* @property {number} timestamp - Record timestamp (Unix seconds)
|
||||
* @property {Uint8Array} signature - ECDSA/DSA signature (binary)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create an empty pricing record
|
||||
*
|
||||
* @returns {PricingRecord} Empty pricing record
|
||||
*/
|
||||
export function createEmptyPricingRecord() {
|
||||
return {
|
||||
prVersion: 0,
|
||||
height: 0,
|
||||
supply: {
|
||||
sal: 0n,
|
||||
vsd: 0n
|
||||
},
|
||||
assets: [],
|
||||
timestamp: 0,
|
||||
signature: new Uint8Array(0)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pricing record is empty
|
||||
*
|
||||
* Reference: ~/github/salvium/src/oracle/pricing_record.cpp (empty() method)
|
||||
*
|
||||
* @param {PricingRecord} pr - Pricing record to check
|
||||
* @returns {boolean} True if empty
|
||||
*/
|
||||
export function isPricingRecordEmpty(pr) {
|
||||
return (
|
||||
pr.prVersion === 0 &&
|
||||
pr.height === 0 &&
|
||||
pr.supply.sal === 0n &&
|
||||
pr.supply.vsd === 0n &&
|
||||
pr.assets.length === 0 &&
|
||||
pr.timestamp === 0 &&
|
||||
pr.signature.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get price for a specific asset from pricing record
|
||||
*
|
||||
* Reference: ~/github/salvium/src/oracle/pricing_record.h (operator[] overload)
|
||||
*
|
||||
* @param {PricingRecord} pr - Pricing record
|
||||
* @param {string} assetType - Asset type to look up
|
||||
* @returns {bigint} Spot price for the asset (0 if not found)
|
||||
*/
|
||||
export function getAssetPrice(pr, assetType) {
|
||||
const asset = pr.assets.find(a => a.assetType === assetType);
|
||||
return asset ? asset.spotPrice : 0n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get moving average price for a specific asset
|
||||
*
|
||||
* @param {PricingRecord} pr - Pricing record
|
||||
* @param {string} assetType - Asset type to look up
|
||||
* @returns {bigint} Moving average price for the asset (0 if not found)
|
||||
*/
|
||||
export function getAssetMaPrice(pr, assetType) {
|
||||
const asset = pr.assets.find(a => a.assetType === assetType);
|
||||
return asset ? asset.maPrice : 0n;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SIGNATURE VERIFICATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build the JSON message that was signed
|
||||
*
|
||||
* The signature covers a compact JSON (no whitespace) with specific field ordering.
|
||||
*
|
||||
* Reference: ~/github/salvium/src/oracle/pricing_record.cpp (verifySignature)
|
||||
*
|
||||
* @param {PricingRecord} pr - Pricing record
|
||||
* @returns {string} Compact JSON message
|
||||
*/
|
||||
export function buildSignatureMessage(pr) {
|
||||
// Build assets array
|
||||
const assetsJson = pr.assets.map(a => ({
|
||||
asset_type: a.assetType,
|
||||
spot_price: Number(a.spotPrice),
|
||||
ma_price: Number(a.maPrice)
|
||||
}));
|
||||
|
||||
// Build the message object matching C++ format
|
||||
const message = {
|
||||
pr_version: pr.prVersion,
|
||||
height: pr.height,
|
||||
supply: {
|
||||
SAL: Number(pr.supply.sal),
|
||||
VSD: Number(pr.supply.vsd)
|
||||
},
|
||||
assets: assetsJson,
|
||||
timestamp: pr.timestamp
|
||||
};
|
||||
|
||||
// Return compact JSON (no whitespace)
|
||||
return JSON.stringify(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify pricing record signature
|
||||
*
|
||||
* Reference: ~/github/salvium/src/oracle/pricing_record.cpp (verifySignature)
|
||||
*
|
||||
* @param {PricingRecord} pr - Pricing record to verify
|
||||
* @param {string} publicKeyPem - Oracle public key in PEM format
|
||||
* @returns {boolean} True if signature is valid
|
||||
*/
|
||||
export function verifyPricingRecordSignature(pr, publicKeyPem) {
|
||||
if (!pr.signature || pr.signature.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build the message that was signed
|
||||
const message = buildSignatureMessage(pr);
|
||||
|
||||
// Create verifier
|
||||
// Note: The mainnet key is DSA, testnet is ECDSA
|
||||
// Node.js crypto handles both with the 'dsa' or 'ecdsa' algorithm
|
||||
const verifier = createVerify('SHA256');
|
||||
verifier.update(message);
|
||||
|
||||
// Verify the signature
|
||||
return verifier.verify(publicKeyPem, Buffer.from(pr.signature));
|
||||
} catch (error) {
|
||||
// Signature verification failed
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate oracle public key for a network
|
||||
*
|
||||
* @param {'mainnet'|'testnet'|'stagenet'} network - Network type
|
||||
* @returns {string} Oracle public key in PEM format
|
||||
*/
|
||||
export function getOraclePublicKey(network = 'mainnet') {
|
||||
if (network === 'mainnet') {
|
||||
return ORACLE_PUBLIC_KEY_MAINNET;
|
||||
}
|
||||
return ORACLE_PUBLIC_KEY_TESTNET;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VALIDATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate a pricing record
|
||||
*
|
||||
* Reference: ~/github/salvium/src/oracle/pricing_record.cpp (valid() method)
|
||||
*
|
||||
* @param {PricingRecord} pr - Pricing record to validate
|
||||
* @param {Object} options - Validation options
|
||||
* @param {'mainnet'|'testnet'|'stagenet'} options.network - Network type
|
||||
* @param {number} options.hfVersion - Current hard fork version
|
||||
* @param {number} options.blockTimestamp - Current block timestamp (Unix seconds)
|
||||
* @param {number} options.lastBlockTimestamp - Previous block timestamp
|
||||
* @returns {{valid: boolean, error?: string}} Validation result
|
||||
*/
|
||||
export function validatePricingRecord(pr, options = {}) {
|
||||
const {
|
||||
network = 'mainnet',
|
||||
hfVersion = 0,
|
||||
blockTimestamp = Math.floor(Date.now() / 1000),
|
||||
lastBlockTimestamp = 0
|
||||
} = options;
|
||||
|
||||
// Rule 1: Before HF_VERSION_SLIPPAGE_YIELD, pricing records must be empty
|
||||
if (hfVersion < HF_VERSION_SLIPPAGE_YIELD) {
|
||||
if (!isPricingRecordEmpty(pr)) {
|
||||
return { valid: false, error: 'Pricing records not allowed before oracle HF' };
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2: Empty pricing records are always valid
|
||||
if (isPricingRecordEmpty(pr)) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Rule 3: Signature must be valid
|
||||
const publicKey = getOraclePublicKey(network);
|
||||
if (!verifyPricingRecordSignature(pr, publicKey)) {
|
||||
return { valid: false, error: 'Invalid pricing record signature' };
|
||||
}
|
||||
|
||||
// Rule 4: Record timestamp must not be too far in future
|
||||
// Must be: record_timestamp <= block_timestamp + 120 seconds
|
||||
if (pr.timestamp > blockTimestamp + PRICING_RECORD_VALID_TIME_DIFF_FROM_BLOCK) {
|
||||
return { valid: false, error: 'Pricing record timestamp too far in future' };
|
||||
}
|
||||
|
||||
// Rule 5: Record timestamp must be newer than previous block
|
||||
// Must be: record_timestamp > previous_block_timestamp
|
||||
if (lastBlockTimestamp > 0 && pr.timestamp <= lastBlockTimestamp) {
|
||||
return { valid: false, error: 'Pricing record timestamp not newer than previous block' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONVERSION RATE CALCULATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get conversion rate between two assets
|
||||
*
|
||||
* Reference: ~/github/salvium/src/cryptonote_core/cryptonote_tx_utils.cpp (get_conversion_rate)
|
||||
*
|
||||
* @param {PricingRecord} pr - Pricing record with current prices
|
||||
* @param {string} fromAsset - Source asset type ('SAL' or 'VSD')
|
||||
* @param {string} toAsset - Destination asset type ('SAL' or 'VSD')
|
||||
* @returns {{success: boolean, rate?: bigint, error?: string}} Conversion rate result
|
||||
*/
|
||||
export function getConversionRate(pr, fromAsset, toAsset) {
|
||||
// Rule 1: Cannot convert to BURN
|
||||
if (toAsset === 'BURN') {
|
||||
return { success: false, error: 'Cannot convert to BURN' };
|
||||
}
|
||||
|
||||
// Rule 2: Same asset = 1:1 conversion
|
||||
if (fromAsset === toAsset) {
|
||||
return { success: true, rate: COIN };
|
||||
}
|
||||
|
||||
// Rule 3: Only SAL<->VSD conversions allowed
|
||||
if ((fromAsset === 'SAL' && toAsset !== 'VSD') ||
|
||||
(fromAsset === 'VSD' && toAsset !== 'SAL')) {
|
||||
return { success: false, error: `Invalid conversion pair: ${fromAsset} -> ${toAsset}` };
|
||||
}
|
||||
|
||||
// Get prices
|
||||
const fromPrice = getAssetPrice(pr, fromAsset);
|
||||
const toPrice = getAssetPrice(pr, toAsset);
|
||||
|
||||
if (fromPrice === 0n || toPrice === 0n) {
|
||||
return { success: false, error: 'Missing price data for conversion' };
|
||||
}
|
||||
|
||||
// Calculate rate: (fromPrice * COIN) / toPrice
|
||||
// Using BigInt for precision (equivalent to C++ uint128_t)
|
||||
let rate = (fromPrice * COIN) / toPrice;
|
||||
|
||||
// Round down to nearest 10000
|
||||
rate = rate - (rate % CONVERSION_RATE_ROUNDING);
|
||||
|
||||
return { success: true, rate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate converted amount given a conversion rate
|
||||
*
|
||||
* Reference: ~/github/salvium/src/cryptonote_core/cryptonote_tx_utils.cpp (get_converted_amount)
|
||||
*
|
||||
* @param {bigint} conversionRate - Rate from getConversionRate()
|
||||
* @param {bigint} sourceAmount - Amount in source asset (atomic units)
|
||||
* @returns {{success: boolean, amount?: bigint, error?: string}} Converted amount result
|
||||
*/
|
||||
export function getConvertedAmount(conversionRate, sourceAmount) {
|
||||
if (!conversionRate || conversionRate === 0n) {
|
||||
return { success: false, error: 'Invalid conversion rate' };
|
||||
}
|
||||
if (!sourceAmount || sourceAmount === 0n) {
|
||||
return { success: false, error: 'Invalid source amount' };
|
||||
}
|
||||
|
||||
// Calculate: dest = (source * rate) / COIN
|
||||
const destAmount = (sourceAmount * conversionRate) / COIN;
|
||||
|
||||
return { success: true, amount: destAmount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate slippage amount for a conversion
|
||||
*
|
||||
* Slippage is fixed at 1/32 (3.125%) of the converted amount.
|
||||
*
|
||||
* Reference: ~/github/salvium/src/cryptonote_core/cryptonote_tx_utils.cpp (calculate_conversion)
|
||||
*
|
||||
* @param {bigint} amount - Amount being converted
|
||||
* @returns {bigint} Slippage amount
|
||||
*/
|
||||
export function calculateSlippage(amount) {
|
||||
// Slippage = amount >> 5 = amount / 32 = 3.125%
|
||||
return amount >> 5n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a full conversion calculation with slippage
|
||||
*
|
||||
* Reference: ~/github/salvium/src/cryptonote_core/cryptonote_tx_utils.cpp (calculate_conversion)
|
||||
*
|
||||
* @param {PricingRecord} pr - Pricing record with current prices
|
||||
* @param {string} sourceAsset - Source asset type
|
||||
* @param {string} destAsset - Destination asset type
|
||||
* @param {bigint} amountBurnt - Amount being converted (burnt from source)
|
||||
* @param {bigint} slippageLimit - Maximum acceptable slippage
|
||||
* @returns {{success: boolean, amountMinted?: bigint, actualSlippage?: bigint, error?: string}}
|
||||
*/
|
||||
export function calculateConversion(pr, sourceAsset, destAsset, amountBurnt, slippageLimit) {
|
||||
// Same asset = no conversion needed
|
||||
if (sourceAsset === destAsset) {
|
||||
return { success: false, error: 'Cannot calculate slippage when source and dest assets are identical' };
|
||||
}
|
||||
|
||||
if (!sourceAsset) {
|
||||
return { success: false, error: 'Source asset not provided' };
|
||||
}
|
||||
|
||||
if (!destAsset) {
|
||||
return { success: false, error: 'Destination asset not provided' };
|
||||
}
|
||||
|
||||
// Get conversion rate
|
||||
const rateResult = getConversionRate(pr, sourceAsset, destAsset);
|
||||
if (!rateResult.success) {
|
||||
return { success: false, error: rateResult.error };
|
||||
}
|
||||
|
||||
// Calculate slippage (1/32 = 3.125%)
|
||||
const actualSlippage = calculateSlippage(amountBurnt);
|
||||
|
||||
// Check if slippage exceeds user's limit
|
||||
if (actualSlippage > slippageLimit) {
|
||||
// Slippage too high - conversion fails, refund case
|
||||
return {
|
||||
success: true,
|
||||
amountMinted: 0n,
|
||||
actualSlippage,
|
||||
refund: true,
|
||||
error: 'Slippage limit exceeded - refund triggered'
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate minted amount after slippage deduction
|
||||
const amountAfterSlippage = amountBurnt - actualSlippage;
|
||||
const convertResult = getConvertedAmount(rateResult.rate, amountAfterSlippage);
|
||||
|
||||
if (!convertResult.success) {
|
||||
return { success: false, error: convertResult.error };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
amountMinted: convertResult.amount,
|
||||
actualSlippage,
|
||||
conversionRate: rateResult.rate
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SERIALIZATION / PARSING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse a pricing record from JSON (as received from oracle HTTP response)
|
||||
*
|
||||
* @param {Object} json - JSON object from oracle response
|
||||
* @returns {PricingRecord} Parsed pricing record
|
||||
*/
|
||||
export function parsePricingRecordFromJson(json) {
|
||||
// Parse assets array
|
||||
const assets = (json.assets || []).map(a => ({
|
||||
assetType: a.asset_type,
|
||||
spotPrice: BigInt(a.spot_price || 0),
|
||||
maPrice: BigInt(a.ma_price || 0)
|
||||
}));
|
||||
|
||||
// Parse supply (handle both uppercase and lowercase field names)
|
||||
const supply = {
|
||||
sal: BigInt(json.supply?.SAL || json.supply?.sal || 0),
|
||||
vsd: BigInt(json.supply?.VSD || json.supply?.vsd || 0)
|
||||
};
|
||||
|
||||
// Parse signature from hex string to bytes
|
||||
let signature = new Uint8Array(0);
|
||||
if (json.signature) {
|
||||
if (typeof json.signature === 'string') {
|
||||
// Hex string
|
||||
const hex = json.signature.replace(/^0x/, '');
|
||||
signature = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
signature[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
}
|
||||
} else if (Array.isArray(json.signature)) {
|
||||
// Already an array
|
||||
signature = new Uint8Array(json.signature);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prVersion: json.pr_version || 0,
|
||||
height: json.height || 0,
|
||||
supply,
|
||||
assets,
|
||||
timestamp: json.timestamp || 0,
|
||||
signature
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a pricing record to JSON format
|
||||
*
|
||||
* @param {PricingRecord} pr - Pricing record
|
||||
* @returns {Object} JSON object
|
||||
*/
|
||||
export function pricingRecordToJson(pr) {
|
||||
// Convert signature to hex string
|
||||
let signatureHex = '';
|
||||
if (pr.signature && pr.signature.length > 0) {
|
||||
signatureHex = Array.from(pr.signature)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
return {
|
||||
pr_version: pr.prVersion,
|
||||
height: pr.height,
|
||||
supply: {
|
||||
SAL: pr.supply.sal.toString(),
|
||||
VSD: pr.supply.vsd.toString()
|
||||
},
|
||||
assets: pr.assets.map(a => ({
|
||||
asset_type: a.assetType,
|
||||
spot_price: a.spotPrice.toString(),
|
||||
ma_price: a.maPrice.toString()
|
||||
})),
|
||||
timestamp: pr.timestamp,
|
||||
signature: signatureHex
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ORACLE HTTP CLIENT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch pricing record from oracle server
|
||||
*
|
||||
* Reference: ~/github/salvium/src/cryptonote_core/blockchain.cpp (get_pricing_record)
|
||||
*
|
||||
* @param {Object} options - Fetch options
|
||||
* @param {number} options.height - Block height for pricing
|
||||
* @param {bigint} options.salSupply - Current SAL circulating supply
|
||||
* @param {bigint} options.vsdSupply - Current VSD circulating supply
|
||||
* @param {string} options.url - Oracle URL (optional, uses default)
|
||||
* @param {number} options.timeout - Request timeout in ms (default: 10000)
|
||||
* @returns {Promise<{success: boolean, pricingRecord?: PricingRecord, error?: string}>}
|
||||
*/
|
||||
export async function fetchPricingRecord(options = {}) {
|
||||
const {
|
||||
height = 0,
|
||||
salSupply = 0n,
|
||||
vsdSupply = 0n,
|
||||
url = ORACLE_URLS[0],
|
||||
timeout = 10000
|
||||
} = options;
|
||||
|
||||
// Build request URL
|
||||
const queryParams = new URLSearchParams({
|
||||
height: height.toString(),
|
||||
sal: salSupply.toString(),
|
||||
vsd: vsdSupply.toString()
|
||||
});
|
||||
|
||||
const fullUrl = `${url}/price?${queryParams}`;
|
||||
|
||||
try {
|
||||
// Create abort controller for timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: 'GET',
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Oracle request failed: ${response.status} ${response.statusText}`
|
||||
};
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const pricingRecord = parsePricingRecordFromJson(json);
|
||||
|
||||
return { success: true, pricingRecord };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Oracle fetch error: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXPORTS
|
||||
// ============================================================================
|
||||
|
||||
export default {
|
||||
// Constants
|
||||
COIN,
|
||||
PRICING_RECORD_VALID_BLOCKS,
|
||||
PRICING_RECORD_VALID_TIME_DIFF_FROM_BLOCK,
|
||||
CONVERSION_RATE_ROUNDING,
|
||||
ASSET_TYPES,
|
||||
HF_VERSION_ENABLE_ORACLE,
|
||||
HF_VERSION_SLIPPAGE_YIELD,
|
||||
ORACLE_URLS,
|
||||
ORACLE_PUBLIC_KEY_MAINNET,
|
||||
ORACLE_PUBLIC_KEY_TESTNET,
|
||||
|
||||
// Data structures
|
||||
createEmptyPricingRecord,
|
||||
isPricingRecordEmpty,
|
||||
getAssetPrice,
|
||||
getAssetMaPrice,
|
||||
|
||||
// Signature verification
|
||||
buildSignatureMessage,
|
||||
verifyPricingRecordSignature,
|
||||
getOraclePublicKey,
|
||||
|
||||
// Validation
|
||||
validatePricingRecord,
|
||||
|
||||
// Conversion
|
||||
getConversionRate,
|
||||
getConvertedAmount,
|
||||
calculateSlippage,
|
||||
calculateConversion,
|
||||
|
||||
// Serialization
|
||||
parsePricingRecordFromJson,
|
||||
pricingRecordToJson,
|
||||
|
||||
// HTTP client
|
||||
fetchPricingRecord
|
||||
};
|
||||
@@ -0,0 +1,816 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Oracle/Pricing Module Tests
|
||||
*
|
||||
* Tests for Salvium's oracle pricing system including:
|
||||
* - Pricing record data structures
|
||||
* - Signature verification
|
||||
* - Conversion rate calculation
|
||||
* - Slippage calculation
|
||||
* - Serialization/parsing
|
||||
*
|
||||
* Reference: ~/github/salvium/src/oracle/pricing_record.h
|
||||
* ~/github/salvium/src/oracle/pricing_record.cpp
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
// Constants
|
||||
COIN,
|
||||
PRICING_RECORD_VALID_BLOCKS,
|
||||
PRICING_RECORD_VALID_TIME_DIFF_FROM_BLOCK,
|
||||
CONVERSION_RATE_ROUNDING,
|
||||
ASSET_TYPES,
|
||||
HF_VERSION_ENABLE_ORACLE,
|
||||
HF_VERSION_SLIPPAGE_YIELD,
|
||||
|
||||
// Data structures
|
||||
createEmptyPricingRecord,
|
||||
isPricingRecordEmpty,
|
||||
getAssetPrice,
|
||||
getAssetMaPrice,
|
||||
|
||||
// Signature verification
|
||||
buildSignatureMessage,
|
||||
getOraclePublicKey,
|
||||
|
||||
// Validation
|
||||
validatePricingRecord,
|
||||
|
||||
// Conversion
|
||||
getConversionRate,
|
||||
getConvertedAmount,
|
||||
calculateSlippage,
|
||||
calculateConversion,
|
||||
|
||||
// Serialization
|
||||
parsePricingRecordFromJson,
|
||||
pricingRecordToJson
|
||||
} from '../src/oracle.js';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// TEST DATA
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock pricing record for testing
|
||||
*/
|
||||
function createMockPricingRecord() {
|
||||
return {
|
||||
prVersion: 1,
|
||||
height: 100000,
|
||||
supply: {
|
||||
sal: 1000000000000000n, // 10M SAL
|
||||
vsd: 500000000000000n // 5M VSD
|
||||
},
|
||||
assets: [
|
||||
{ assetType: 'SAL', spotPrice: 100000000n, maPrice: 100000000n }, // 1.0 price
|
||||
{ assetType: 'VSD', spotPrice: 200000000n, maPrice: 195000000n } // 2.0 price
|
||||
],
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
signature: new Uint8Array(64).fill(0) // Dummy signature
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// CONSTANTS TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe('Oracle Constants', () => {
|
||||
|
||||
test('COIN equals 10^8', () => {
|
||||
expect(COIN).toBe(100000000n);
|
||||
});
|
||||
|
||||
test('PRICING_RECORD_VALID_BLOCKS equals 10', () => {
|
||||
expect(PRICING_RECORD_VALID_BLOCKS).toBe(10);
|
||||
});
|
||||
|
||||
test('PRICING_RECORD_VALID_TIME_DIFF_FROM_BLOCK equals 120 seconds', () => {
|
||||
expect(PRICING_RECORD_VALID_TIME_DIFF_FROM_BLOCK).toBe(120);
|
||||
});
|
||||
|
||||
test('CONVERSION_RATE_ROUNDING equals 10000', () => {
|
||||
expect(CONVERSION_RATE_ROUNDING).toBe(10000n);
|
||||
});
|
||||
|
||||
test('ASSET_TYPES includes SAL, SAL1, BURN', () => {
|
||||
expect(ASSET_TYPES).toContain('SAL');
|
||||
expect(ASSET_TYPES).toContain('SAL1');
|
||||
expect(ASSET_TYPES).toContain('BURN');
|
||||
});
|
||||
|
||||
test('HF_VERSION_ENABLE_ORACLE equals 255', () => {
|
||||
expect(HF_VERSION_ENABLE_ORACLE).toBe(255);
|
||||
});
|
||||
|
||||
test('HF_VERSION_SLIPPAGE_YIELD equals 255', () => {
|
||||
expect(HF_VERSION_SLIPPAGE_YIELD).toBe(255);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// DATA STRUCTURE TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe('Pricing Record Data Structures', () => {
|
||||
|
||||
describe('createEmptyPricingRecord', () => {
|
||||
|
||||
test('creates empty pricing record with all zero values', () => {
|
||||
const pr = createEmptyPricingRecord();
|
||||
|
||||
expect(pr.prVersion).toBe(0);
|
||||
expect(pr.height).toBe(0);
|
||||
expect(pr.supply.sal).toBe(0n);
|
||||
expect(pr.supply.vsd).toBe(0n);
|
||||
expect(pr.assets).toEqual([]);
|
||||
expect(pr.timestamp).toBe(0);
|
||||
expect(pr.signature.length).toBe(0);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('isPricingRecordEmpty', () => {
|
||||
|
||||
test('returns true for empty pricing record', () => {
|
||||
const pr = createEmptyPricingRecord();
|
||||
expect(isPricingRecordEmpty(pr)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for pricing record with version', () => {
|
||||
const pr = createEmptyPricingRecord();
|
||||
pr.prVersion = 1;
|
||||
expect(isPricingRecordEmpty(pr)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for pricing record with height', () => {
|
||||
const pr = createEmptyPricingRecord();
|
||||
pr.height = 100;
|
||||
expect(isPricingRecordEmpty(pr)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for pricing record with supply', () => {
|
||||
const pr = createEmptyPricingRecord();
|
||||
pr.supply.sal = 1000n;
|
||||
expect(isPricingRecordEmpty(pr)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for pricing record with assets', () => {
|
||||
const pr = createEmptyPricingRecord();
|
||||
pr.assets = [{ assetType: 'SAL', spotPrice: 100n, maPrice: 100n }];
|
||||
expect(isPricingRecordEmpty(pr)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for pricing record with timestamp', () => {
|
||||
const pr = createEmptyPricingRecord();
|
||||
pr.timestamp = 12345;
|
||||
expect(isPricingRecordEmpty(pr)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for pricing record with signature', () => {
|
||||
const pr = createEmptyPricingRecord();
|
||||
pr.signature = new Uint8Array([1, 2, 3]);
|
||||
expect(isPricingRecordEmpty(pr)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for full pricing record', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
expect(isPricingRecordEmpty(pr)).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('getAssetPrice', () => {
|
||||
|
||||
test('returns spot price for existing asset', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
expect(getAssetPrice(pr, 'SAL')).toBe(100000000n);
|
||||
expect(getAssetPrice(pr, 'VSD')).toBe(200000000n);
|
||||
});
|
||||
|
||||
test('returns 0n for non-existent asset', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
expect(getAssetPrice(pr, 'UNKNOWN')).toBe(0n);
|
||||
});
|
||||
|
||||
test('returns 0n for empty pricing record', () => {
|
||||
const pr = createEmptyPricingRecord();
|
||||
expect(getAssetPrice(pr, 'SAL')).toBe(0n);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('getAssetMaPrice', () => {
|
||||
|
||||
test('returns moving average price for existing asset', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
expect(getAssetMaPrice(pr, 'SAL')).toBe(100000000n);
|
||||
expect(getAssetMaPrice(pr, 'VSD')).toBe(195000000n);
|
||||
});
|
||||
|
||||
test('returns 0n for non-existent asset', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
expect(getAssetMaPrice(pr, 'UNKNOWN')).toBe(0n);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// SIGNATURE VERIFICATION TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe('Signature Verification', () => {
|
||||
|
||||
describe('buildSignatureMessage', () => {
|
||||
|
||||
test('builds correct JSON message', () => {
|
||||
const pr = {
|
||||
prVersion: 1,
|
||||
height: 100,
|
||||
supply: { sal: 1000n, vsd: 500n },
|
||||
assets: [
|
||||
{ assetType: 'SAL', spotPrice: 100n, maPrice: 100n }
|
||||
],
|
||||
timestamp: 1234567890,
|
||||
signature: new Uint8Array(0)
|
||||
};
|
||||
|
||||
const message = buildSignatureMessage(pr);
|
||||
const parsed = JSON.parse(message);
|
||||
|
||||
expect(parsed.pr_version).toBe(1);
|
||||
expect(parsed.height).toBe(100);
|
||||
expect(parsed.supply.SAL).toBe(1000);
|
||||
expect(parsed.supply.VSD).toBe(500);
|
||||
expect(parsed.assets.length).toBe(1);
|
||||
expect(parsed.assets[0].asset_type).toBe('SAL');
|
||||
expect(parsed.timestamp).toBe(1234567890);
|
||||
});
|
||||
|
||||
test('produces compact JSON without whitespace', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
const message = buildSignatureMessage(pr);
|
||||
|
||||
// Should not contain formatting whitespace
|
||||
expect(message).not.toMatch(/ /); // No double spaces
|
||||
expect(message).not.toContain('\n');
|
||||
expect(message).not.toContain('\t');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('getOraclePublicKey', () => {
|
||||
|
||||
test('returns mainnet key by default', () => {
|
||||
const key = getOraclePublicKey();
|
||||
expect(key).toContain('BEGIN PUBLIC KEY');
|
||||
expect(key).toContain('MIIDRDCCAjYGByqGSM44BAE'); // DSA key
|
||||
});
|
||||
|
||||
test('returns mainnet key explicitly', () => {
|
||||
const key = getOraclePublicKey('mainnet');
|
||||
expect(key).toContain('BEGIN PUBLIC KEY');
|
||||
expect(key).toContain('MIIDRDCCAjYGByqGSM44BAE'); // DSA key
|
||||
});
|
||||
|
||||
test('returns testnet key', () => {
|
||||
const key = getOraclePublicKey('testnet');
|
||||
expect(key).toContain('BEGIN PUBLIC KEY');
|
||||
expect(key).toContain('MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQg'); // ECDSA key
|
||||
});
|
||||
|
||||
test('returns testnet key for stagenet', () => {
|
||||
const key = getOraclePublicKey('stagenet');
|
||||
expect(key).toContain('BEGIN PUBLIC KEY');
|
||||
expect(key).toContain('MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQg'); // ECDSA key
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// VALIDATION TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe('Pricing Record Validation', () => {
|
||||
|
||||
describe('validatePricingRecord', () => {
|
||||
|
||||
test('empty record is always valid', () => {
|
||||
const pr = createEmptyPricingRecord();
|
||||
const result = validatePricingRecord(pr);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
test('non-empty record requires HF >= HF_VERSION_SLIPPAGE_YIELD', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
|
||||
// Before HF
|
||||
const result1 = validatePricingRecord(pr, { hfVersion: 5 });
|
||||
expect(result1.valid).toBe(false);
|
||||
expect(result1.error).toContain('not allowed before oracle HF');
|
||||
|
||||
// At HF (would fail signature, but that's a different error)
|
||||
// Just testing the HF gate here
|
||||
});
|
||||
|
||||
test('rejects timestamp too far in future', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Set timestamp 200 seconds in future (> 120 seconds allowed)
|
||||
pr.timestamp = currentTime + 200;
|
||||
|
||||
const result = validatePricingRecord(pr, {
|
||||
hfVersion: 255,
|
||||
blockTimestamp: currentTime
|
||||
});
|
||||
|
||||
// Note: This will also fail signature verification first,
|
||||
// but if we had a valid signature, timestamp would be checked
|
||||
});
|
||||
|
||||
test('rejects timestamp older than previous block', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Set timestamp older than last block
|
||||
pr.timestamp = currentTime - 100;
|
||||
|
||||
const result = validatePricingRecord(pr, {
|
||||
hfVersion: 255,
|
||||
blockTimestamp: currentTime,
|
||||
lastBlockTimestamp: currentTime - 50 // Previous block was 50 seconds ago
|
||||
});
|
||||
|
||||
// Would fail signature first, but tests the logic
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// CONVERSION TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe('Conversion Rate Calculation', () => {
|
||||
|
||||
describe('getConversionRate', () => {
|
||||
|
||||
test('same asset returns 1:1 rate (COIN)', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
|
||||
const result = getConversionRate(pr, 'SAL', 'SAL');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.rate).toBe(COIN);
|
||||
});
|
||||
|
||||
test('rejects conversion to BURN', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
|
||||
const result = getConversionRate(pr, 'SAL', 'BURN');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Cannot convert to BURN');
|
||||
});
|
||||
|
||||
test('SAL -> VSD with 1:2 price ratio returns 0.5 rate', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
// SAL price = 1.0, VSD price = 2.0
|
||||
// Rate = 1.0 / 2.0 = 0.5 (50000000 in COIN units)
|
||||
|
||||
const result = getConversionRate(pr, 'SAL', 'VSD');
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Rate should be around 50000000 (0.5 COIN), rounded down to nearest 10000
|
||||
const expectedRate = 50000000n - (50000000n % CONVERSION_RATE_ROUNDING);
|
||||
expect(result.rate).toBe(expectedRate);
|
||||
});
|
||||
|
||||
test('VSD -> SAL with 2:1 price ratio returns 2.0 rate', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
// VSD price = 2.0, SAL price = 1.0
|
||||
// Rate = 2.0 / 1.0 = 2.0 (200000000 in COIN units)
|
||||
|
||||
const result = getConversionRate(pr, 'VSD', 'SAL');
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Rate should be around 200000000 (2.0 COIN), rounded down to nearest 10000
|
||||
const expectedRate = 200000000n - (200000000n % CONVERSION_RATE_ROUNDING);
|
||||
expect(result.rate).toBe(expectedRate);
|
||||
});
|
||||
|
||||
test('rejects invalid conversion pair SAL -> SAL1', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
|
||||
const result = getConversionRate(pr, 'SAL', 'SAL1');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid conversion pair');
|
||||
});
|
||||
|
||||
test('rejects when price data is missing', () => {
|
||||
const pr = createEmptyPricingRecord();
|
||||
|
||||
const result = getConversionRate(pr, 'SAL', 'VSD');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Missing price data');
|
||||
});
|
||||
|
||||
test('rounds rate down to nearest 10000', () => {
|
||||
const pr = {
|
||||
...createMockPricingRecord(),
|
||||
assets: [
|
||||
{ assetType: 'SAL', spotPrice: 100000001n, maPrice: 100000001n },
|
||||
{ assetType: 'VSD', spotPrice: 100000000n, maPrice: 100000000n }
|
||||
]
|
||||
};
|
||||
|
||||
const result = getConversionRate(pr, 'SAL', 'VSD');
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Should be rounded to nearest 10000
|
||||
expect(result.rate % CONVERSION_RATE_ROUNDING).toBe(0n);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('getConvertedAmount', () => {
|
||||
|
||||
test('calculates correct converted amount', () => {
|
||||
// 1 SAL with 1:1 rate should give 1 SAL
|
||||
const result = getConvertedAmount(COIN, COIN);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.amount).toBe(COIN);
|
||||
});
|
||||
|
||||
test('calculates with 2:1 rate', () => {
|
||||
// 1 SAL with 2:1 rate should give 2 SAL
|
||||
const rate = 200000000n; // 2.0 in COIN units
|
||||
const amount = COIN; // 1.0
|
||||
|
||||
const result = getConvertedAmount(rate, amount);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.amount).toBe(200000000n); // 2.0 SAL
|
||||
});
|
||||
|
||||
test('calculates with 0.5:1 rate', () => {
|
||||
// 1 SAL with 0.5:1 rate should give 0.5 SAL
|
||||
const rate = 50000000n; // 0.5 in COIN units
|
||||
const amount = COIN; // 1.0
|
||||
|
||||
const result = getConvertedAmount(rate, amount);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.amount).toBe(50000000n); // 0.5 SAL
|
||||
});
|
||||
|
||||
test('rejects zero rate', () => {
|
||||
const result = getConvertedAmount(0n, COIN);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid conversion rate');
|
||||
});
|
||||
|
||||
test('rejects zero amount', () => {
|
||||
const result = getConvertedAmount(COIN, 0n);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid source amount');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('calculateSlippage', () => {
|
||||
|
||||
test('calculates 1/32 (3.125%) of amount', () => {
|
||||
// 3200 SAL should give 100 SAL slippage (3200 / 32 = 100)
|
||||
const amount = 320000000000n; // 3200 SAL
|
||||
const slippage = calculateSlippage(amount);
|
||||
expect(slippage).toBe(10000000000n); // 100 SAL
|
||||
});
|
||||
|
||||
test('uses bit shift for efficiency', () => {
|
||||
// 32 SAL -> 1 SAL slippage
|
||||
const amount = 3200000000n; // 32 SAL
|
||||
const slippage = calculateSlippage(amount);
|
||||
expect(slippage).toBe(100000000n); // 1 SAL
|
||||
});
|
||||
|
||||
test('handles small amounts correctly', () => {
|
||||
// Very small amount
|
||||
const amount = 32n;
|
||||
const slippage = calculateSlippage(amount);
|
||||
expect(slippage).toBe(1n);
|
||||
});
|
||||
|
||||
test('handles amount less than 32', () => {
|
||||
// Amount less than 32 results in 0 slippage
|
||||
const amount = 31n;
|
||||
const slippage = calculateSlippage(amount);
|
||||
expect(slippage).toBe(0n);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('calculateConversion', () => {
|
||||
|
||||
test('calculates full conversion with slippage', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
const amount = 3200000000000n; // 32000 SAL
|
||||
|
||||
const result = calculateConversion(pr, 'SAL', 'VSD', amount, 1000000000000n);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.actualSlippage).toBe(100000000000n); // 1000 SAL (3.125%)
|
||||
expect(result.amountMinted).toBeDefined();
|
||||
expect(result.conversionRate).toBeDefined();
|
||||
});
|
||||
|
||||
test('triggers refund when slippage exceeds limit', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
const amount = 3200000000000n; // 32000 SAL
|
||||
const slippageLimit = 1n; // Unrealistically low limit
|
||||
|
||||
const result = calculateConversion(pr, 'SAL', 'VSD', amount, slippageLimit);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.refund).toBe(true);
|
||||
expect(result.amountMinted).toBe(0n);
|
||||
expect(result.error).toContain('Slippage limit exceeded');
|
||||
});
|
||||
|
||||
test('rejects same asset conversion', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
|
||||
const result = calculateConversion(pr, 'SAL', 'SAL', COIN, COIN);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('source and dest assets are identical');
|
||||
});
|
||||
|
||||
test('rejects missing source asset', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
|
||||
const result = calculateConversion(pr, null, 'VSD', COIN, COIN);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Source asset not provided');
|
||||
});
|
||||
|
||||
test('rejects missing destination asset', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
|
||||
const result = calculateConversion(pr, 'SAL', null, COIN, COIN);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Destination asset not provided');
|
||||
});
|
||||
|
||||
test('propagates invalid conversion pair error', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
|
||||
const result = calculateConversion(pr, 'SAL', 'SAL1', COIN, COIN);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid conversion pair');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// SERIALIZATION TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe('Pricing Record Serialization', () => {
|
||||
|
||||
describe('parsePricingRecordFromJson', () => {
|
||||
|
||||
test('parses complete pricing record from JSON', () => {
|
||||
const json = {
|
||||
pr_version: 1,
|
||||
height: 100000,
|
||||
supply: {
|
||||
SAL: 1000000000000000,
|
||||
VSD: 500000000000000
|
||||
},
|
||||
assets: [
|
||||
{ asset_type: 'SAL', spot_price: 100000000, ma_price: 100000000 },
|
||||
{ asset_type: 'VSD', spot_price: 200000000, ma_price: 195000000 }
|
||||
],
|
||||
timestamp: 1234567890,
|
||||
signature: 'aabbccdd'
|
||||
};
|
||||
|
||||
const pr = parsePricingRecordFromJson(json);
|
||||
|
||||
expect(pr.prVersion).toBe(1);
|
||||
expect(pr.height).toBe(100000);
|
||||
expect(pr.supply.sal).toBe(1000000000000000n);
|
||||
expect(pr.supply.vsd).toBe(500000000000000n);
|
||||
expect(pr.assets.length).toBe(2);
|
||||
expect(pr.assets[0].assetType).toBe('SAL');
|
||||
expect(pr.assets[0].spotPrice).toBe(100000000n);
|
||||
expect(pr.timestamp).toBe(1234567890);
|
||||
expect(pr.signature.length).toBe(4); // 'aabbccdd' = 4 bytes
|
||||
});
|
||||
|
||||
test('handles lowercase supply keys', () => {
|
||||
const json = {
|
||||
pr_version: 1,
|
||||
height: 100,
|
||||
supply: {
|
||||
sal: 1000,
|
||||
vsd: 500
|
||||
},
|
||||
assets: [],
|
||||
timestamp: 0,
|
||||
signature: ''
|
||||
};
|
||||
|
||||
const pr = parsePricingRecordFromJson(json);
|
||||
|
||||
expect(pr.supply.sal).toBe(1000n);
|
||||
expect(pr.supply.vsd).toBe(500n);
|
||||
});
|
||||
|
||||
test('handles missing optional fields', () => {
|
||||
const json = {
|
||||
height: 100
|
||||
};
|
||||
|
||||
const pr = parsePricingRecordFromJson(json);
|
||||
|
||||
expect(pr.prVersion).toBe(0);
|
||||
expect(pr.height).toBe(100);
|
||||
expect(pr.supply.sal).toBe(0n);
|
||||
expect(pr.supply.vsd).toBe(0n);
|
||||
expect(pr.assets).toEqual([]);
|
||||
expect(pr.timestamp).toBe(0);
|
||||
expect(pr.signature.length).toBe(0);
|
||||
});
|
||||
|
||||
test('handles signature as array', () => {
|
||||
const json = {
|
||||
pr_version: 1,
|
||||
height: 100,
|
||||
supply: { SAL: 0, VSD: 0 },
|
||||
assets: [],
|
||||
timestamp: 0,
|
||||
signature: [0xaa, 0xbb, 0xcc]
|
||||
};
|
||||
|
||||
const pr = parsePricingRecordFromJson(json);
|
||||
|
||||
expect(pr.signature.length).toBe(3);
|
||||
expect(pr.signature[0]).toBe(0xaa);
|
||||
expect(pr.signature[1]).toBe(0xbb);
|
||||
expect(pr.signature[2]).toBe(0xcc);
|
||||
});
|
||||
|
||||
test('handles 0x prefixed hex signature', () => {
|
||||
const json = {
|
||||
pr_version: 1,
|
||||
height: 100,
|
||||
supply: { SAL: 0, VSD: 0 },
|
||||
assets: [],
|
||||
timestamp: 0,
|
||||
signature: '0xaabbcc'
|
||||
};
|
||||
|
||||
const pr = parsePricingRecordFromJson(json);
|
||||
|
||||
expect(pr.signature.length).toBe(3);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('pricingRecordToJson', () => {
|
||||
|
||||
test('converts pricing record to JSON', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
const json = pricingRecordToJson(pr);
|
||||
|
||||
expect(json.pr_version).toBe(1);
|
||||
expect(json.height).toBe(100000);
|
||||
expect(json.supply.SAL).toBe('1000000000000000');
|
||||
expect(json.supply.VSD).toBe('500000000000000');
|
||||
expect(json.assets.length).toBe(2);
|
||||
expect(json.assets[0].asset_type).toBe('SAL');
|
||||
expect(json.assets[0].spot_price).toBe('100000000');
|
||||
expect(json.timestamp).toBe(pr.timestamp);
|
||||
expect(typeof json.signature).toBe('string');
|
||||
});
|
||||
|
||||
test('handles empty signature', () => {
|
||||
const pr = createEmptyPricingRecord();
|
||||
const json = pricingRecordToJson(pr);
|
||||
|
||||
expect(json.signature).toBe('');
|
||||
});
|
||||
|
||||
test('converts signature to hex string', () => {
|
||||
const pr = createEmptyPricingRecord();
|
||||
pr.signature = new Uint8Array([0xaa, 0xbb, 0xcc]);
|
||||
|
||||
const json = pricingRecordToJson(pr);
|
||||
|
||||
expect(json.signature).toBe('aabbcc');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('round-trip serialization', () => {
|
||||
|
||||
test('JSON -> PricingRecord -> JSON preserves data', () => {
|
||||
const originalJson = {
|
||||
pr_version: 1,
|
||||
height: 100000,
|
||||
supply: { SAL: '1000000000000', VSD: '500000000000' },
|
||||
assets: [
|
||||
{ asset_type: 'SAL', spot_price: '100000000', ma_price: '100000000' }
|
||||
],
|
||||
timestamp: 1234567890,
|
||||
signature: 'aabbccdd'
|
||||
};
|
||||
|
||||
const pr = parsePricingRecordFromJson(originalJson);
|
||||
const resultJson = pricingRecordToJson(pr);
|
||||
|
||||
expect(resultJson.pr_version).toBe(originalJson.pr_version);
|
||||
expect(resultJson.height).toBe(originalJson.height);
|
||||
expect(resultJson.supply.SAL).toBe(originalJson.supply.SAL);
|
||||
expect(resultJson.supply.VSD).toBe(originalJson.supply.VSD);
|
||||
expect(resultJson.assets.length).toBe(originalJson.assets.length);
|
||||
expect(resultJson.timestamp).toBe(originalJson.timestamp);
|
||||
expect(resultJson.signature).toBe(originalJson.signature);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// EDGE CASES
|
||||
// ============================================================================
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
|
||||
test('handles very large amounts in conversion', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
const largeAmount = 1000000000000000000n; // 10B SAL
|
||||
|
||||
const result = calculateConversion(pr, 'SAL', 'VSD', largeAmount, largeAmount);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.amountMinted).toBeDefined();
|
||||
});
|
||||
|
||||
test('handles minimum conversion amount', () => {
|
||||
const pr = createMockPricingRecord();
|
||||
const minAmount = 1n; // 1 atomic unit
|
||||
|
||||
const rateResult = getConversionRate(pr, 'SAL', 'VSD');
|
||||
expect(rateResult.success).toBe(true);
|
||||
|
||||
const convertResult = getConvertedAmount(rateResult.rate, minAmount);
|
||||
// May be 0 due to precision, but should not error
|
||||
expect(convertResult.success).toBe(true);
|
||||
});
|
||||
|
||||
test('slippage calculation handles exact multiples of 32', () => {
|
||||
const amount = 320n; // Exactly 10 * 32
|
||||
const slippage = calculateSlippage(amount);
|
||||
expect(slippage).toBe(10n);
|
||||
});
|
||||
|
||||
test('conversion rate handles equal prices', () => {
|
||||
const pr = {
|
||||
...createMockPricingRecord(),
|
||||
assets: [
|
||||
{ assetType: 'SAL', spotPrice: 100000000n, maPrice: 100000000n },
|
||||
{ assetType: 'VSD', spotPrice: 100000000n, maPrice: 100000000n } // Same price
|
||||
]
|
||||
};
|
||||
|
||||
const result = getConversionRate(pr, 'SAL', 'VSD');
|
||||
expect(result.success).toBe(true);
|
||||
// 1:1 rate
|
||||
expect(result.rate).toBe(COIN - (COIN % CONVERSION_RATE_ROUNDING));
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
console.log('\n=== Oracle/Pricing Module Tests ===\n');
|
||||
Reference in New Issue
Block a user