97 lines
11 KiB
JavaScript
97 lines
11 KiB
JavaScript
// 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';
|
|
/** The rules this module names. A refusal can only name one of these (the helper throws otherwise), so the list is the whole list. */
|
|
export const REGULI = ['forma', 'activ', 'contrapartida', 'curs', 'limita', 'fereastra'];
|
|
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 rules = new Set(); const cere = (c, m, regula) => { if (!REGULI.includes(regula)) throw new Error('risc: undeclared rule ' + regula); if (!c) { reasons.push(m); rules.add(regula); } return !!c; };
|
|
// declared before the first refusal: a refusal of form returns a verdict too, and the verdict reads these (2026-10-03: until today a
|
|
// malformed payment threw "Cannot access 'amountUsd' before initialization" instead of REFUSE naming the form rule; the conformance
|
|
// vector of that rule found it)
|
|
let amountUsd = null, windowUsd = 0n, windowCount = 0; const windowStart = Number.isInteger(i.now) ? i.now - policy.window.seconds : null;
|
|
cere(ADDR.test(i.payer) && ADDR.test(i.payee) /*PAZA:forma*/, 'payer and payee must be addresses', 'forma');
|
|
cere(Number.isInteger(i.chainId) && typeof i.asset === 'string' && INT.test(String(i.amount)) && BigInt(i.amount || 0) > 0n /*PAZA:forma*/, 'chainId, asset and a positive integer amount are required', 'forma');
|
|
cere(Number.isInteger(i.now) && i.now > 0 /*PAZA:forma*/, 'now (unix seconds) is required', 'forma');
|
|
// a history entry the engine cannot count is a refusal, never a payment silently left out of the window (2026-10-03)
|
|
cere(i.history == null || (Array.isArray(i.history) && i.history.every((h) => h && typeof h === 'object' && Number.isInteger(h.at) && typeof h.amountUsd === 'string' && DEC.test(h.amountUsd))) /*PAZA:forma*/, 'history must be a list of { at (unix seconds), amountUsd (decimal string) }', 'forma');
|
|
if (reasons.length) return verdict('REFUSE');
|
|
const asset = policy.allowedAssets.find((a) => a.chainId === i.chainId && a.asset === low(i.asset));
|
|
cere(!!asset /*PAZA:activ*/, `asset ${i.asset} on chain ${i.chainId} is not allowed by the policy`, 'activ');
|
|
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})` : '', 'contrapartida');
|
|
const rate = i.rate && typeof i.rate === 'object' ? i.rate : null;
|
|
const rataBuna = !!(rate && typeof rate.usdPerUnit === 'string' && DEC.test(rate.usdPerUnit)); // a decimal STRING: a JSON number is refused, not rounded
|
|
const okCurs = (rataBuna && !!rate.source && !!rate.at) || !policy.requireRate /*PAZA:curs*/;
|
|
if (!okCurs) { rules.add('curs'); 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');
|
|
if (okCurs && asset && rataBuna) { // 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`, 'limita');
|
|
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`, 'fereastra');
|
|
}
|
|
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 };
|
|
// `rules`: the named rules that refused (forma, activ, contrapartida, curs, limita, fereastra), OUTSIDE the hashed body, so the
|
|
// verdictHash of every receipt already issued stays what it was
|
|
return { ...corp, verdictHash: hashOf(corp), rules: [...rules].sort() };
|
|
}
|
|
}
|