87 lines
9.0 KiB
JavaScript
87 lines
9.0 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';
|
|
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) };
|
|
}
|
|
}
|