// risc.mjs: the RISK step of a payment for a person or a company (roadmap master point 25, pista B, 2026-10-02), as a PURE module: a // declared policy (per-payment and per-window limits in USD per payer, assets allowed, counterparties refused with their source) and // the inputs of one payment (payer, payee, asset and amount, the USD rate DECLARED as an input with its source, the payer's history as // this issuer knows it, the time) give one verdict, reproducible by anyone from the same policy and the same inputs: the verdict's // hash covers the policy's hash, the inputs' hash, the decision, the reasons, what was measured and what was NOT. Signals this module // does not have are listed as unmeasured in every verdict, never simulated: there is no sanctions screening (no live source), no // identity tier, no device or location, no chargeback history, and the payer's history is only what the issuer itself recorded. // Only node:crypto. Amounts are integer strings in the asset's smallest unit; USD is a string with six decimals; arithmetic is exact // (BigInt, floor), so two readers never disagree by a rounding. import crypto from 'node:crypto'; export const KIND_POLICY = 'aere-payment-risk-policy'; export const KIND_VERDICT = 'aere-payment-risk-verdict'; const sha256 = (s) => '0x' + crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex'); const ADDR = /^0x[0-9a-fA-F]{40}$/, INT = /^[0-9]+$/, DEC = /^[0-9]+(\.[0-9]{1,6})?$/; /** Canonical JSON: keys sorted at every depth, no whitespace. */ export function canonical(x) { if (x === null) return 'null'; if (typeof x === 'string' || typeof x === 'boolean') return JSON.stringify(x); if (typeof x === 'number') { if (!Number.isFinite(x)) throw new Error('canonical: a number must be finite'); return JSON.stringify(x); } if (Array.isArray(x)) return '[' + x.map(canonical).join(',') + ']'; if (typeof x === 'object') return '{' + Object.keys(x).sort().map((k) => { if (x[k] === undefined) throw new Error('canonical: undefined under ' + k); return JSON.stringify(k) + ':' + canonical(x[k]); }).join(',') + '}'; throw new Error('canonical: unsupported ' + typeof x); } export const hashOf = (x) => sha256(canonical(x)); /** A USD decimal string -> micro-USD (BigInt); at most six decimals, exact. */ export function usdMicro(s) { if (typeof s !== 'string' || !DEC.test(s)) throw new Error('USD amount must be a decimal string with at most six decimals: ' + s); const [i, f = ''] = s.split('.'); return BigInt(i) * 1000000n + BigInt((f + '000000').slice(0, 6)); } export const usdText = (m) => { const v = BigInt(m); return (v / 1000000n).toString() + '.' + (v % 1000000n).toString().padStart(6, '0'); }; /** amount (smallest units) x rate (USD per whole unit) -> micro-USD, floored. */ export function usdOf(amount, decimals, usdPerUnit) { if (!INT.test(String(amount))) throw new Error('amount must be an integer string'); if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) throw new Error('decimals out of range'); return (BigInt(amount) * usdMicro(usdPerUnit)) / (10n ** BigInt(decimals)); } const low = (a) => String(a).toLowerCase(); /** The policy in its normal form (what is judged) and its hash; refuses unknown fields, so a reader cannot be shown a policy the engine ignores. */ export function definePolicy(p) { if (!p || p.kind !== KIND_POLICY || p.v !== 1) throw new Error('risc: not an ' + KIND_POLICY + ' v1'); const chei = ['v', 'kind', 'id', 'currency', 'perPaymentMaxUsd', 'window', 'allowedAssets', 'refusedCounterparties', 'requireRate']; const str = Object.keys(p).filter((k) => !chei.includes(k)); if (str.length) throw new Error('risc: unknown policy field(s): ' + str.join(', ')); if (p.currency !== 'USD') throw new Error('risc: currency must be USD (the asset is only settlement)'); if (!p.id || typeof p.id !== 'string') throw new Error('risc: policy id required'); const w = p.window || {}; if (!Number.isInteger(w.seconds) || w.seconds < 1 || !Number.isInteger(w.maxCount) || w.maxCount < 1) throw new Error('risc: window.seconds and window.maxCount must be positive integers'); const assets = (p.allowedAssets || []).map((a) => { if (!Number.isInteger(a.chainId) || typeof a.asset !== 'string' || !Number.isInteger(a.decimals)) throw new Error('risc: allowedAssets entries need chainId, asset, decimals'); return { chainId: a.chainId, asset: low(a.asset), decimals: a.decimals }; }); if (!assets.length) throw new Error('risc: at least one allowed asset'); const refuzate = (p.refusedCounterparties || []).map((r) => { if (!ADDR.test(r.address) || !r.reason || !r.source) throw new Error('risc: a refused counterparty needs address, reason and source'); return { address: low(r.address), reason: String(r.reason), source: String(r.source) }; }).sort((a, b) => (a.address < b.address ? -1 : 1)); const policy = { v: 1, kind: KIND_POLICY, id: p.id, currency: 'USD', perPaymentMaxUsd: usdText(usdMicro(p.perPaymentMaxUsd)), window: { seconds: w.seconds, maxUsd: usdText(usdMicro(w.maxUsd)), maxCount: w.maxCount }, allowedAssets: assets, refusedCounterparties: refuzate, requireRate: p.requireRate !== false }; return { policy, policyHash: hashOf(policy) }; } /** The signals this module does not have: named in every verdict, never simulated. */ export const NEMASURATE = ['sanctions screening: no source consulted', 'identity or verification tier of the payer: none', 'device, session or location of the payer: none', 'chargeback or fraud history: none', 'the payer\'s payments outside this issuer\'s own receipts: none']; /** One payment judged under the policy. Pure: the same policy and inputs give the same verdict and the same verdictHash. */ export function judeca(policyIn, inputs) { const { policy, policyHash } = definePolicy(policyIn); const reasons = [], unmeasured = [...NEMASURATE]; const i = inputs || {}; const cere = (c, m) => { if (!c) reasons.push(m); return !!c; }; cere(ADDR.test(i.payer) && ADDR.test(i.payee), 'payer and payee must be addresses'); cere(Number.isInteger(i.chainId) && typeof i.asset === 'string' && INT.test(String(i.amount)) && BigInt(i.amount || 0) > 0n, 'chainId, asset and a positive integer amount are required'); cere(Number.isInteger(i.now) && i.now > 0, 'now (unix seconds) is required'); if (reasons.length) return verdict('REFUSE'); const asset = policy.allowedAssets.find((a) => a.chainId === i.chainId && a.asset === low(i.asset)); cere(!!asset, `asset ${i.asset} on chain ${i.chainId} is not allowed by the policy`); const self = low(i.payer) === low(i.payee); const refuzat = policy.refusedCounterparties.find((r) => r.address === low(i.payee)); const okContrapartida = !self && !refuzat /*PAZA:contrapartida*/; cere(okContrapartida, self ? 'the payee is the payer' : refuzat ? `refused counterparty: ${refuzat.reason} (source: ${refuzat.source})` : ''); const rate = i.rate && typeof i.rate === 'object' ? i.rate : null; const okCurs = !!(rate && DEC.test(String(rate.usdPerUnit)) && rate.source && rate.at) || !policy.requireRate /*PAZA:curs*/; if (!okCurs) { reasons.push('the USD rate is not declared (usdPerUnit, source, at): the amount in USD is unmeasured and the policy requires it'); unmeasured.push('USD value of the payment: no declared rate'); } if (i.identity && i.identity.complianceRecordHash) unmeasured.splice(1, 1, 'identity: a compliance record is named (' + String(i.identity.complianceRecordHash).slice(0, 18) + '..), not judged here'); let amountUsd = null, windowUsd = 0n, windowCount = 0; const windowStart = i.now - policy.window.seconds; if (okCurs && asset && rate && rate.usdPerUnit !== undefined) { // with no rate nothing is computed: the USD value stays unmeasured amountUsd = usdOf(i.amount, asset.decimals, rate.usdPerUnit); for (const h of Array.isArray(i.history) ? i.history : []) { if (Number.isInteger(h.at) && h.at > windowStart && h.at <= i.now && low(h.payer || i.payer) === low(i.payer)) { windowUsd += usdMicro(h.amountUsd); windowCount++; } } const okLimita = amountUsd <= usdMicro(policy.perPaymentMaxUsd) /*PAZA:limita*/; cere(okLimita, `above the per-payment limit: ${usdText(amountUsd)} USD > ${policy.perPaymentMaxUsd} USD`); const okFereastra = windowUsd + amountUsd <= usdMicro(policy.window.maxUsd) && windowCount + 1 <= policy.window.maxCount /*PAZA:fereastra*/; cere(okFereastra, windowCount + 1 > policy.window.maxCount ? `above the window count: ${windowCount + 1} payments in ${policy.window.seconds} s > ${policy.window.maxCount}` : `above the window limit: ${usdText(windowUsd + amountUsd)} USD in ${policy.window.seconds} s > ${policy.window.maxUsd} USD`); } return verdict(reasons.length ? 'REFUSE' : 'ALLOW'); function verdict(decision) { const measured = { amountUsd: amountUsd === null ? null : usdText(amountUsd), windowUsd: usdText(windowUsd), windowCount, windowStart, historyCounted: windowCount }; const inputsHash = hashOf(i); const corp = { v: 1, kind: KIND_VERDICT, policyId: policy.id, policyHash, inputsHash, decision, reasons: reasons.filter(Boolean), unmeasured, measured }; return { ...corp, verdictHash: hashOf(corp) }; } }