diff --git a/src/chain/chainProfile.js b/src/chain/chainProfile.js new file mode 100644 index 0000000..9eadf99 --- /dev/null +++ b/src/chain/chainProfile.js @@ -0,0 +1,76 @@ +/** + * ChainProfile registry — the 10× foundation stone for multi-chain (Pillar 3). + * + * A single, data-driven source for everything chain-specific that ISN'T an RPC call: money units + * (decimals / atomic divisor), the block-timing model (mean block time — which difficulty must + * scale to), the adapter family (which wallet/daemon adapter implements it), and the payment-URI + * scheme. The adapters (daemon/wallet clients, address validators) plug in per family and read + * their params from here. + * + * The set is exactly what the Smirk wallet supports — btc, ltc, xmr, wow, grin (see + * smirk-monorepo packages/core/src/types.ts AssetType) — all proof-of-work / high-variance, so the + * "a random block ends your run" mechanic is fair on every one. Adding a chain = one entry here + * plus a family adapter, never a fork. + */ + +const DEFAULT_DECIMALS = 12; + +// family: 'monero' (subaddress + transfer_split), 'utxo' (BTC/LTC-style), 'mimblewimble' (Grin). +const PROFILES = Object.freeze({ + WOW: Object.freeze({ id: 'WOW', symbol: 'WOW', decimals: 11, meanBlockTimeMs: 120000, family: 'monero', uriScheme: 'wownero' }), + XMR: Object.freeze({ id: 'XMR', symbol: 'XMR', decimals: 12, meanBlockTimeMs: 120000, family: 'monero', uriScheme: 'monero' }), + BTC: Object.freeze({ id: 'BTC', symbol: 'BTC', decimals: 8, meanBlockTimeMs: 600000, family: 'utxo', uriScheme: 'bitcoin' }), + LTC: Object.freeze({ id: 'LTC', symbol: 'LTC', decimals: 8, meanBlockTimeMs: 150000, family: 'utxo', uriScheme: 'litecoin' }), + GRIN: Object.freeze({ id: 'GRIN', symbol: 'GRIN', decimals: 9, meanBlockTimeMs: 60000, family: 'mimblewimble', uriScheme: 'grin' }) +}); + +function normalizeId(cryptoType) { + return String(cryptoType || '').trim().toUpperCase(); +} + +/** + * Resolve a chain profile. Unknown/unset falls back to a WOW-shaped default (11 decimals, ~2 min) + * so callers never crash on a mis-set CRYPTO_TYPE. Returns a frozen profile object. + */ +function getProfile(cryptoType) { + const id = normalizeId(cryptoType); + if (PROFILES[id]) return PROFILES[id]; + return Object.freeze({ id: id || 'WOW', symbol: id || 'WOW', decimals: DEFAULT_DECIMALS, meanBlockTimeMs: 120000, family: 'monero', uriScheme: 'monero' }); +} + +function decimalsFor(cryptoType) { + return getProfile(cryptoType).decimals; +} + +function meanBlockTimeMsFor(cryptoType) { + return getProfile(cryptoType).meanBlockTimeMs; +} + +function familyFor(cryptoType) { + return getProfile(cryptoType).family; +} + +function uriSchemeFor(cryptoType) { + return getProfile(cryptoType).uriScheme; +} + +/** Atomic divisor as a BigInt (10 ** decimals) — safe for ETH-18-scale amounts (none here, but correct). */ +function atomicDivisor(cryptoType) { + return 10n ** BigInt(decimalsFor(cryptoType)); +} + +function isSupported(cryptoType) { + return !!PROFILES[normalizeId(cryptoType)]; +} + +module.exports = { + DEFAULT_DECIMALS, + PROFILES, + getProfile, + decimalsFor, + meanBlockTimeMsFor, + familyFor, + uriSchemeFor, + atomicDivisor, + isSupported +}; diff --git a/src/game/helpers/gameModeUtils.js b/src/game/helpers/gameModeUtils.js index 5618d56..4357953 100644 --- a/src/game/helpers/gameModeUtils.js +++ b/src/game/helpers/gameModeUtils.js @@ -1,3 +1,5 @@ +const ChainProfile = require('../../chain/chainProfile'); + const DEFAULT_DECIMALS = 12; const parseAtomicEnvValue = (val, fallback) => { @@ -17,13 +19,9 @@ const parseAtomicEnvValue = (val, fallback) => { return Math.trunc(num); }; -const inferCurrencyDecimals = (symbol) => { - if (!symbol) return DEFAULT_DECIMALS; - const normalized = symbol.toUpperCase(); - if (normalized === 'WOW') return 11; - if (normalized === 'XMR') return 12; - return DEFAULT_DECIMALS; -}; +// Delegates to the ChainProfile registry — one source for all chains (WOW 11, XMR 12, BTC/LTC 8, +// GRIN 9; unknown -> 12). Previously WOW/XMR were hardcoded and everything else silently got 12. +const inferCurrencyDecimals = (symbol) => ChainProfile.decimalsFor(symbol); const getDecimalDivisor = (decimals = DEFAULT_DECIMALS) => { const normalized = Number.isFinite(decimals) ? decimals : DEFAULT_DECIMALS; diff --git a/test/chainProfile_ultra.test.js b/test/chainProfile_ultra.test.js new file mode 100644 index 0000000..bfd72cf --- /dev/null +++ b/test/chainProfile_ultra.test.js @@ -0,0 +1,49 @@ +/** + * ChainProfile registry (Pillar 3 foundation) — one source for chain params across Smirk's five + * PoW chains, and the fix for decimals silently defaulting to 12 for anything but WOW/XMR. + */ +const ChainProfile = require('../src/chain/chainProfile'); +const { inferCurrencyDecimals } = require('../src/game/helpers/gameModeUtils'); + +describe('ChainProfile', () => { + test('decimals are correct for every Smirk chain', () => { + expect(ChainProfile.decimalsFor('WOW')).toBe(11); + expect(ChainProfile.decimalsFor('XMR')).toBe(12); + expect(ChainProfile.decimalsFor('BTC')).toBe(8); + expect(ChainProfile.decimalsFor('LTC')).toBe(8); + expect(ChainProfile.decimalsFor('GRIN')).toBe(9); + }); + + test('mean block time + adapter family per chain', () => { + expect(ChainProfile.meanBlockTimeMsFor('GRIN')).toBe(60000); // ~1 min, fast floor + expect(ChainProfile.meanBlockTimeMsFor('BTC')).toBe(600000); // ~10 min, slow end + expect(ChainProfile.familyFor('WOW')).toBe('monero'); + expect(ChainProfile.familyFor('BTC')).toBe('utxo'); + expect(ChainProfile.familyFor('LTC')).toBe('utxo'); + expect(ChainProfile.familyFor('GRIN')).toBe('mimblewimble'); + }); + + test('atomic divisor is a BigInt of the right scale', () => { + expect(ChainProfile.atomicDivisor('WOW')).toBe(100000000000n); // 1e11 + expect(ChainProfile.atomicDivisor('BTC')).toBe(100000000n); // 1e8 + expect(ChainProfile.atomicDivisor('GRIN')).toBe(1000000000n); // 1e9 + }); + + test('case-insensitive; unknown falls back safely', () => { + expect(ChainProfile.getProfile('grin').decimals).toBe(9); + expect(ChainProfile.isSupported('DOGE')).toBe(false); + const fb = ChainProfile.getProfile('DOGE'); + expect(fb.decimals).toBe(12); + expect(fb.family).toBe('monero'); + }); +}); + +describe('inferCurrencyDecimals now delegates to ChainProfile', () => { + test('WOW/XMR unchanged; BTC/GRIN fixed (were silently 12)', () => { + expect(inferCurrencyDecimals('WOW')).toBe(11); + expect(inferCurrencyDecimals('XMR')).toBe(12); + expect(inferCurrencyDecimals('BTC')).toBe(8); + expect(inferCurrencyDecimals('GRIN')).toBe(9); + expect(inferCurrencyDecimals('')).toBe(12); + }); +});