362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* asset-payload-precision-parity-smoke (cp46 — O-1 closure).
|
|
*
|
|
* RUN-LOCATION REQUIREMENT: must be invoked with apps/web/tsconfig.json
|
|
* as the resolved tsconfig because the imports below traverse into
|
|
* payload.ts which uses `$lib` path aliases. The smoke either runs
|
|
* with cwd=apps/web (default tsconfig discovery) or with
|
|
* TSX_TSCONFIG_PATH=apps/web/tsconfig.json set explicitly.
|
|
*
|
|
* run-smokes.sh sets TSX_TSCONFIG_PATH for this entry.
|
|
*
|
|
* Pins THREE invariants per asset that no other smoke catches:
|
|
*
|
|
* 1. **Decimals ↔ jitter precision parity.** Each canonical
|
|
* asset declares `decimals: N`. The jitter function for
|
|
* that asset's amount-class operates at N-decimal
|
|
* precision. Mismatch (e.g. jitterSolAmount running at
|
|
* 1e8 instead of 1e9) silently produces wrong on-chain
|
|
* amounts. Cp45 SOL addition surfaced this gap: a
|
|
* mutation M-98 changing `1_000_000_000n` to `100_000_000n`
|
|
* was invisible to all 35 existing smokes. This smoke
|
|
* pins the invariant by exercising `jitterAmountForAsset`
|
|
* at runtime and counting fractional digits.
|
|
*
|
|
* 2. **URI scheme parity.** Each asset has a documented URI
|
|
* scheme in its payment-URI builder branch (`bitcoin:`,
|
|
* `monero:`, `litecoin:`, `bitcoincash:`, `dash:`,
|
|
* `dogecoin:`, `zcash:`, `arrr:`, `decred:`, `solana:`,
|
|
* EVM-style for stablecoins, in-app for BLURT). Cp46
|
|
* mutation M-99 changing `solana:` → `bitcoin:` would
|
|
* silently emit wrong URIs and confuse wallets. This
|
|
* smoke pins each asset's expected scheme.
|
|
*
|
|
* 3. **Txid shape parity.** Each asset has a documented
|
|
* txid regex. Cp46 mutation M-97 widening
|
|
* `/^[1-9A-HJ-NP-Za-km-z]{87,88}$/` to `{1,200}` would
|
|
* silently accept malformed txids (BTC 64-hex shape would
|
|
* then test true for SOL). This smoke pins each asset's
|
|
* txid by testing shape-correct input ACCEPTED and
|
|
* shape-wrong input REJECTED.
|
|
*
|
|
* Discipline: this smoke is REQUIRED to PASS at every
|
|
* checkpoint. It runs in ~50ms because it only does string
|
|
* arithmetic, no I/O.
|
|
*/
|
|
|
|
import { ASSETS as CANONICAL, isGoodsAsset } from '@morphit/asset-registry';
|
|
import {
|
|
jitterAmountForAsset,
|
|
buildPaymentUri,
|
|
isValidTxid,
|
|
type ChatAssetTicker
|
|
} from '../src/lib/chat/payload';
|
|
|
|
// Per-asset expected URI scheme + txid shape. Source-of-truth
|
|
// is the cp1-cp45 wiring across the registry; this smoke is the
|
|
// load-bearing cross-check.
|
|
type AssetExpectation = {
|
|
ticker: string;
|
|
chatTicker: ChatAssetTicker;
|
|
expectedJitterDecimals: number; // may differ from canonical.decimals (DAI uses 6-decimal jitter on 18-decimal on-chain by cp31 design)
|
|
expectedUriScheme: string | null; // null = no URI (BLURT) or per-network (USDT/USDC/DAI)
|
|
txidShapeAcceptable: string;
|
|
txidShapeUnacceptable: string;
|
|
};
|
|
|
|
const EXPECTATIONS: AssetExpectation[] = [
|
|
{
|
|
ticker: 'BTC',
|
|
chatTicker: 'btc',
|
|
expectedJitterDecimals: 8,
|
|
expectedUriScheme: 'bitcoin:',
|
|
txidShapeAcceptable: 'a'.repeat(64),
|
|
txidShapeUnacceptable: 'a'.repeat(63)
|
|
},
|
|
{
|
|
ticker: 'XMR',
|
|
chatTicker: 'xmr',
|
|
expectedJitterDecimals: 12,
|
|
expectedUriScheme: 'monero:',
|
|
txidShapeAcceptable: 'a'.repeat(64),
|
|
txidShapeUnacceptable: 'a'.repeat(63)
|
|
},
|
|
{
|
|
ticker: 'BLURT',
|
|
chatTicker: 'blurt',
|
|
expectedJitterDecimals: 3,
|
|
expectedUriScheme: null,
|
|
txidShapeAcceptable: 'a'.repeat(40),
|
|
txidShapeUnacceptable: 'a'.repeat(63)
|
|
},
|
|
{
|
|
ticker: 'USDT',
|
|
chatTicker: 'usdt',
|
|
expectedJitterDecimals: 6,
|
|
expectedUriScheme: null,
|
|
txidShapeAcceptable: '0x' + 'a'.repeat(64),
|
|
txidShapeUnacceptable: 'a'.repeat(63)
|
|
},
|
|
{
|
|
ticker: 'USDC',
|
|
chatTicker: 'usdc',
|
|
expectedJitterDecimals: 6,
|
|
expectedUriScheme: null,
|
|
txidShapeAcceptable: '0x' + 'a'.repeat(64),
|
|
txidShapeUnacceptable: 'a'.repeat(63)
|
|
},
|
|
{
|
|
ticker: 'DAI',
|
|
chatTicker: 'dai',
|
|
// DAI is 18-decimal on-chain but jitter clamps to 6-decimal
|
|
// display precision per cp31 ADR-0029 design: consistent
|
|
// $0.001-magnitude jitter UX across all stablecoins. Comment
|
|
// in packages/asset-registry/src/index.ts DAI entry documents
|
|
// this; the smoke captures the design choice as a fixed
|
|
// invariant.
|
|
expectedJitterDecimals: 6,
|
|
expectedUriScheme: null,
|
|
txidShapeAcceptable: '0x' + 'a'.repeat(64),
|
|
txidShapeUnacceptable: 'a'.repeat(63)
|
|
},
|
|
{
|
|
ticker: 'BCH',
|
|
chatTicker: 'bch',
|
|
expectedJitterDecimals: 8,
|
|
expectedUriScheme: 'bitcoincash:',
|
|
txidShapeAcceptable: 'a'.repeat(64),
|
|
txidShapeUnacceptable: 'a'.repeat(63)
|
|
},
|
|
{
|
|
ticker: 'LTC',
|
|
chatTicker: 'ltc',
|
|
expectedJitterDecimals: 8,
|
|
expectedUriScheme: 'litecoin:',
|
|
txidShapeAcceptable: 'a'.repeat(64),
|
|
txidShapeUnacceptable: 'a'.repeat(63)
|
|
},
|
|
{
|
|
ticker: 'DASH',
|
|
chatTicker: 'dash',
|
|
expectedJitterDecimals: 8,
|
|
expectedUriScheme: 'dash:',
|
|
txidShapeAcceptable: 'a'.repeat(64),
|
|
txidShapeUnacceptable: 'a'.repeat(63)
|
|
},
|
|
{
|
|
ticker: 'DOGE',
|
|
chatTicker: 'doge',
|
|
expectedJitterDecimals: 8,
|
|
expectedUriScheme: 'dogecoin:',
|
|
txidShapeAcceptable: 'a'.repeat(64),
|
|
txidShapeUnacceptable: 'a'.repeat(63)
|
|
},
|
|
{
|
|
ticker: 'ZEC',
|
|
chatTicker: 'zec',
|
|
expectedJitterDecimals: 8,
|
|
expectedUriScheme: 'zcash:',
|
|
txidShapeAcceptable: 'a'.repeat(64),
|
|
txidShapeUnacceptable: 'a'.repeat(63)
|
|
},
|
|
{
|
|
ticker: 'ARRR',
|
|
chatTicker: 'arrr',
|
|
expectedJitterDecimals: 8,
|
|
expectedUriScheme: 'arrr:',
|
|
txidShapeAcceptable: 'a'.repeat(64),
|
|
txidShapeUnacceptable: 'a'.repeat(63)
|
|
},
|
|
{
|
|
ticker: 'DCR',
|
|
chatTicker: 'dcr',
|
|
expectedJitterDecimals: 8,
|
|
expectedUriScheme: 'decred:',
|
|
txidShapeAcceptable: 'a'.repeat(64),
|
|
txidShapeUnacceptable: 'a'.repeat(63)
|
|
},
|
|
{
|
|
ticker: 'SOL',
|
|
chatTicker: 'sol',
|
|
// SOL is the first 9-decimal asset in Morphit. Cp45 added
|
|
// jitterSolAmount for 9-decimal lamport arithmetic. cp46
|
|
// M-98 mutation showed that changing 1_000_000_000n to
|
|
// 100_000_000n (BTC-family precision) was silently invisible
|
|
// to all 35 existing smokes — this smoke is the structural
|
|
// closure.
|
|
expectedJitterDecimals: 9,
|
|
expectedUriScheme: 'solana:',
|
|
txidShapeAcceptable: '1'.repeat(87),
|
|
txidShapeUnacceptable: '1'.repeat(86)
|
|
},
|
|
{
|
|
ticker: 'ETH',
|
|
chatTicker: 'eth',
|
|
// ETH is 18-decimal on-chain (wei) but jitter clamps to
|
|
// 6-decimal display precision per cp47 design (matching
|
|
// DAI's cp31 ADR-0029 rationale). At $2500/ETH a 0-999
|
|
// microether jitter range is ~$0.0025 max — the same
|
|
// $0.001-magnitude jitter UX the stablecoins use.
|
|
expectedJitterDecimals: 6,
|
|
expectedUriScheme: 'ethereum:',
|
|
txidShapeAcceptable: '0x' + 'a'.repeat(64),
|
|
txidShapeUnacceptable: 'a'.repeat(63)
|
|
},
|
|
{
|
|
ticker: 'XRP',
|
|
chatTicker: 'xrp',
|
|
// XRP uses 6 decimals on the XRP Ledger (drops; 1 XRP =
|
|
// 10^6 drops). Same smallest-unit precision as USDT/USDC/
|
|
// DAI/ETH-display, but XRP is the native token of XRPL,
|
|
// not an ERC-20 stablecoin. jitterXrpAmount provides
|
|
// 6-decimal arithmetic with a clear separate function
|
|
// for clarity and future XRP-specific tuning room.
|
|
expectedJitterDecimals: 6,
|
|
expectedUriScheme: 'ripple:',
|
|
txidShapeAcceptable: 'a'.repeat(64),
|
|
txidShapeUnacceptable: 'a'.repeat(63)
|
|
}
|
|
];
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function pass(name: string): void {
|
|
console.log(` ✓ ${name}`);
|
|
passed++;
|
|
}
|
|
function fail(name: string, detail: string): void {
|
|
console.error(` ✗ ${name}`);
|
|
console.error(` ${detail}`);
|
|
failed++;
|
|
}
|
|
|
|
console.log('\n── asset-payload-precision-parity smoke (cp46) ──────\n');
|
|
|
|
// ── 1. canonical asset count matches expectations array ──
|
|
// cp425 — EXPECTATIONS lists txid/URI/precision shapes, which only crypto
|
|
// assets have. Goods assets (BARTER) are orderable but have no on-chain txid,
|
|
// URI scheme, or amount precision (isGoodsAsset gates them out), so they are
|
|
// correctly absent from EXPECTATIONS. Compare the count against the CRYPTO
|
|
// canonical set, not the full registry, or every new goods asset would trip
|
|
// this coverage guard.
|
|
const CRYPTO_CANONICAL = CANONICAL.filter((a) => !isGoodsAsset(a.ticker));
|
|
if (CRYPTO_CANONICAL.length === EXPECTATIONS.length) {
|
|
pass(`crypto asset count (${CRYPTO_CANONICAL.length}) matches expectations`);
|
|
} else {
|
|
fail(
|
|
`crypto asset count matches expectations`,
|
|
`crypto ${CRYPTO_CANONICAL.length} != expectations ${EXPECTATIONS.length} — add a row for the new crypto asset`
|
|
);
|
|
}
|
|
|
|
// ── 2. Decimals ↔ jitter precision parity ──
|
|
for (const exp of EXPECTATIONS) {
|
|
const canonAsset = CANONICAL.find((a) => a.ticker === exp.ticker);
|
|
if (!canonAsset) {
|
|
fail(`${exp.ticker} canonical lookup`, 'not found in registry');
|
|
continue;
|
|
}
|
|
// jitterAmountForAsset on '0' input yields '0.<frac>' where frac
|
|
// has exactly `expectedJitterDecimals` characters (zero-padded).
|
|
// NOTE: expectedJitterDecimals may differ from canonAsset.decimals
|
|
// for DAI (18-decimal on-chain, 6-decimal jitter clamp by cp31
|
|
// design). The smoke author maintains EXPECTATIONS to reflect
|
|
// the documented design choice; mutations to jitter arithmetic
|
|
// surface as mismatches against EXPECTATIONS.
|
|
let jittered: string;
|
|
try {
|
|
jittered = jitterAmountForAsset(exp.chatTicker, '0');
|
|
} catch (e) {
|
|
fail(`${exp.ticker} jitter callable on '0'`, String(e));
|
|
continue;
|
|
}
|
|
const [_, frac = ''] = jittered.split('.');
|
|
if (frac.length === exp.expectedJitterDecimals) {
|
|
pass(`${exp.ticker} jitter precision === ${exp.expectedJitterDecimals} decimals`);
|
|
} else {
|
|
fail(
|
|
`${exp.ticker} jitter precision === ${exp.expectedJitterDecimals} decimals`,
|
|
`expected ${exp.expectedJitterDecimals}-char fraction, got ${frac.length} chars ("${frac}") in "${jittered}"`
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 3. URI scheme parity ──
|
|
for (const exp of EXPECTATIONS) {
|
|
if (exp.expectedUriScheme === null) {
|
|
// Skip URI test for assets with no single scheme (BLURT account
|
|
// names; USDT/USDC/DAI multi-network); covered by per-network
|
|
// smokes elsewhere.
|
|
continue;
|
|
}
|
|
let uri: string;
|
|
try {
|
|
// Synthesize a payload — use a shape-correct address for the
|
|
// asset. We use the canonical addressShape regex to pick a
|
|
// valid example.
|
|
const sampleAddrs: Record<string, string> = {
|
|
BTC: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa',
|
|
XMR: '4'.repeat(95),
|
|
BCH: 'qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a',
|
|
LTC: 'LdP8Qox1VAhCzLJNqrr74YovaWYyNBUWvL',
|
|
DASH: 'XwnLY8qaFu8aGM9XRdcaQ9XgnnQ8AbnXkc',
|
|
DOGE: 'DPHwLrG5Cm5R8KCNNfMcXSEoVbCRBkXxhf',
|
|
ZEC: 't1RKFygRTZxfP7Z3uW4kBJjGNB6cqxQyEmA',
|
|
ARRR: 'zs1' + 'q'.repeat(75),
|
|
DCR: 'Dsmcfb6dGoZBaBdF8u1QFcKsuyaPgxR8N7d',
|
|
SOL: 'So11111111111111111111111111111111111111112',
|
|
ETH: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
|
|
XRP: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh'
|
|
};
|
|
const addr = sampleAddrs[exp.ticker];
|
|
if (!addr) {
|
|
fail(`${exp.ticker} URI test sample address`, 'no sample defined');
|
|
continue;
|
|
}
|
|
uri = buildPaymentUri({ method: exp.chatTicker, address: addr } as any);
|
|
} catch (e) {
|
|
fail(`${exp.ticker} URI callable`, String(e));
|
|
continue;
|
|
}
|
|
if (uri.startsWith(exp.expectedUriScheme)) {
|
|
pass(`${exp.ticker} URI scheme === ${exp.expectedUriScheme}`);
|
|
} else {
|
|
fail(
|
|
`${exp.ticker} URI scheme === ${exp.expectedUriScheme}`,
|
|
`got "${uri.slice(0, 40)}..."`
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 4. Txid shape parity ──
|
|
for (const exp of EXPECTATIONS) {
|
|
const ok = isValidTxid(exp.chatTicker, exp.txidShapeAcceptable);
|
|
if (ok) {
|
|
pass(`${exp.ticker} txid ACCEPTS shape-correct (${exp.txidShapeAcceptable.length} chars)`);
|
|
} else {
|
|
fail(
|
|
`${exp.ticker} txid ACCEPTS shape-correct`,
|
|
`rejected ${exp.txidShapeAcceptable.length}-char synthetic input "${exp.txidShapeAcceptable.slice(0, 20)}..."`
|
|
);
|
|
}
|
|
const bad = isValidTxid(exp.chatTicker, exp.txidShapeUnacceptable);
|
|
if (!bad) {
|
|
pass(`${exp.ticker} txid REJECTS shape-wrong (${exp.txidShapeUnacceptable.length} chars)`);
|
|
} else {
|
|
fail(
|
|
`${exp.ticker} txid REJECTS shape-wrong`,
|
|
`accepted ${exp.txidShapeUnacceptable.length}-char input "${exp.txidShapeUnacceptable.slice(0, 20)}..."`
|
|
);
|
|
}
|
|
}
|
|
|
|
const total = passed + failed;
|
|
console.log(`\n${passed} passed, ${failed} failed (${total} total)`);
|
|
|
|
if (failed > 0) {
|
|
console.error('\nasset-payload-precision-parity smoke FAILED');
|
|
process.exit(1);
|
|
}
|
|
console.log(`✓ all ${passed} asset-payload-precision-parity scenarios passed`);
|