feat(chain): ChainProfile registry for Smirk's 5 chains + centralize decimals

New src/chain/chainProfile.js — one data-driven source for chain params (decimals, mean block time, adapter family, URI scheme) across WOW/XMR/BTC/LTC/GRIN, all PoW/high-variance. inferCurrencyDecimals now delegates to it: WOW=11/XMR=12 unchanged, and BTC=8/LTC=8/GRIN=9 are fixed (were silently defaulting to 12). This is the seam the daemon/wallet/address adapters and the block-time difficulty-scaling plug into. +6 tests, suite 50/350 green.

P2 foundation on branch feat/credits-catalog-foundation.
This commit is contained in:
jwinterm
2026-07-10 16:38:25 -04:00
parent 6c7a677057
commit f9d49f34ee
3 changed files with 130 additions and 7 deletions
+76
View File
@@ -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
};
+5 -7
View File
@@ -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;
+49
View File
@@ -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);
});
});