diff --git a/README.md b/README.md index a4bd142..44c044d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ notarization command of the verification layer, and the agents' x402 wallet (EIP | [`control-plane/`](control-plane/) | from findings to a finished migration: a prioritized plan from the inventory and the scanner, its execution through the gateway, KMS and PKI with consent per action and a measured proof afterwards, recipes for the servers the products do not touch and a rescan that judges them, a compliance report against NIST IR 8547, the EU roadmap and optionally CNSA 2.0, and a console that checks it all again | | [`agents/`](agents/) | limits an AI agent cannot break unseen: a post-quantum identity (ML-DSA-65), a policy (spending per time window, allowed tools and recipients, which actions need human approval), a signed ledger of every action judged against the policy, approvals and revocation signed by people, and a verifier that re-runs the policy over the whole ledger without trusting the agent; two branches of one ledger are a proof of equivocation anyone can check; and a wallet that pays over x402 only what the agent's ledger records and its policy allows, either holding the payment key for the owner or co-signing from a 2-of-2 contract wallet that neither the agent nor the owner can spend alone; both run on the public testnet with their evidence; and the chain as the witness of a ledger: a notarized head, read through the AIP-23 verifier under a post-quantum certified anchor, bounds entry times from below (a backdated entry is caught) | | [`proof-of-ai/`](proof-of-ai/) | the AIP-23 envelope of an AI agent's action: model, version, weights digest, prompt, input, output, tools, agent, requester, as digests; verified by the same AIP-23 verifier as every other proof | +| [`payments/`](payments/) | the risk step and the receipt of a payment for a person or a company: a declared policy (per-payment and sliding-window limits in USD per payer, assets allowed, counterparties refused with their source) gives a verdict anyone reproduces from the same inputs, with the signals it does not have named as unmeasured (no sanctions list, no identity tier); the receipt is an instruction (payer, payee, the amount in the asset and in USD at the rate declared as an input, the transaction, the verdict with its policy under hash) committed to by an AIP-23 settlement envelope, notarized and read back under a post-quantum certified anchor; a reader checks it against the policy, the inputs and the chain with plain JSON-RPC | | [`market-settlement/`](market-settlement/) | the dispute and settlement rail of a verifiable infrastructure market, measured end to end on testnet 28001: an order with its reward in escrow and a provider bond, the result committed on chain with its execution envelope notarized under a post-quantum certified anchor, a challenge window in which anyone disputes with a recomputed result and a bond, an arbiter that can only route the escrow (with a permissionless 14-day timeout), deadline reclaims, and an AIP-23 settlement receipt; a pure judge over the job's timeline that checks deadlines, the window, who resolved, the payout, the replay and the proof | | [`verifiable-data/`](verifiable-data/) | a dataset whose every answer is checkable against a short commitment without the data: RFC 9162 commitments per version (append-only, consistency proofs), equality and range queries proven correct AND complete (contiguous run of a sorted index plus its two neighbours), hash-based selective disclosure of fields, availability sampling against the serving endpoint, and the version's AIP-23 envelope notarized and read back under a post-quantum certified anchor; not zero knowledge, and says so | | [`ai-trust/`](ai-trust/) | the trust chain of an AI result: the agent's identity (agents/), the model artifact's attestation, the host's runtime record (verify-layer/), the policy and signed ledger, the Proof of AI envelope and the chain as witness, joined in one bundle whose verifier checks only the links between them | @@ -42,6 +43,7 @@ test, counts as a failure of the control. Results measured on 2026-09-29 (Node.j | identity | 44/44 (`node proba-identity.mjs`, with the AIP-23 verifier for its envelope test; without it that test is reported as skipped and the exit code is 2), compliance 15/15 (`node proba-conformitate.mjs`), Travel Rule 19/19 (`node proba-travel-rule.mjs`), SD-JWT 23/23 (`node proba-sdjwt.mjs`, the RFC 9901 vectors included), measured 2026-09-30 | 49/49 (`node control-negativ-identity.mjs`); compliance 14/14 (`node control-negativ-conformitate.mjs`); Travel Rule 19/19 (`node control-negativ-travel-rule.mjs`); SD-JWT 28/28 (`node control-negativ-sdjwt.mjs`) | | agents | policy 27/27 with the AIP-23 verifier (without it 25 run, 2 are reported as skipped and the exit code is 2), ledger 51/51, approval and revocation 39/39, the chain as witness 26/26 without a network and 7/7 on testnet 28001 on 2026-09-30 (`proba-agent-ancora-testnet.mjs`, needs a funded testnet key and the AIP-23 verifier), command line 23/23 through files and processes only (on Linux and macOS one more test checks the key file mode; not measured here); x402 wallet 25/25, 2-of-2 co-signing 16/16 and payment verifier 14/14 without a network; on the public testnet 28001, 9/9 with the wallet key and 13/13 with the 2-of-2 contract wallet (`x402/proba-x402-testnet.mjs`, `x402/proba-cosign-testnet.mjs`, each needs a funded testnet key); the contract's artifact recompiles byte for byte with solc 0.8.23 (`node x402/recompileaza-contract.mjs --solc `) | 26/26 (`node control-negativ-aprobare.mjs`); the chain as witness 11/11 (`node control-negativ-ancora.mjs`); x402 30/30 (`node x402/control-negativ-wallet.mjs`); the contract's own tests (7) and their negative control (4/4) run in the Aere Network contracts project, not in this repository | | proof-of-ai | 6/6 with the AIP-23 verifier (`AERE_VERIFY_PROOF=... node proba-proof-of-ai.mjs`); without it the probe reports NEMASURAT and exits 2 | one negative control inside the test (an envelope touched after its hash: INVALID) | +| payments | 22/22 without a chain (`node proba-risc.mjs`: the verdicts under a policy, the reproducible verdict hash, a receipt verified, nine forged ones refused by name); 9/9 on testnet 28001 on 2026-10-02 (`AERE_TESTNET_KEY_FILE=... AERE_ETHERS_DIR=... node proba-plata-28001.mjs`: a payment allowed, paid, its receipt notarized and read back with finality PASSED, the public reader VALID; a payment above the limit refused with nothing sent; the fourth in the window refused; a forged receipt and a loosened policy INVALID) | 8/8 (`node proba-risc.mjs --control-negativ`: counterparty, rate, per-payment limit, window, linkage, USD arithmetic, verdict reproduction, chain facts, each planted out on a copy and each forged fixture then accepted) | | market-settlement | 20/20 on testnet 28001 on 2026-10-02 (`AERE_TESTNET_KEY_FILE=... AERE_ETHERS_DIR=... node proba-piata-28001.mjs`: five scenarios on AereComputeMarketV3 deployed from the committed artifact, every refusal by its named custom error, the provider's notarized envelope read back under a post-quantum certified anchor); the judge on the recorded timelines 16/16 without a chain (`node proba-judecata.mjs`: five honest timelines VALID, ten forged ones INVALID for their named reason, the settlement receipt) | 7/7 (`node proba-judecata.mjs --control-negativ`: deadline, window, arbiter, payout, result bytes, replay, proof, each planted out on a copy and each forged fixture then accepted) | | verifiable-data | 22/22 with the AIP-23 verifier (`AERE_VERIFY_PROOF=... node proba-verifiable-data.mjs`: honest answers VALID, nine forged ones INVALID for their named reason, availability sampling on a local server; the envelope row is NEMASURAT without the verifier, exit 2); 24/24 with `--viu` on testnet 28001 on 2026-10-02 (a version notarized with a test key and read back under a post-quantum certified anchor; evidence in `dovezi/`) | 5/5 (`node proba-verifiable-data.mjs --control-negativ`: field commitment, index order, completeness, consistency, sampling, each planted out on a copy and each forged fixture then accepted) | | ai-trust | 13/13 with the AIP-23 verifier (`AERE_VERIFY_PROOF=... node proba-lant-de-incredere.mjs`: the whole chain on a committed fixture, then ten links broken on purpose, each refused for its named reason); without the verifier it exits 2 | 8/8 (`node proba-lant-de-incredere.mjs --control-negativ`) | @@ -53,4 +55,4 @@ interface, command line and data (`definePolicy`, `verifyLedger`, `approve`, ... ## Licence -MIT, see [LICENSE](LICENSE). Files: 183 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 12, proof-kinds 3, control-plane 19, agents 39, identity 15, proof-of-ai 3, ai-trust 6, verifiable-data 7, market-settlement 9, readiness 4). +MIT, see [LICENSE](LICENSE). Files: 191 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 12, proof-kinds 3, control-plane 19, agents 39, identity 15, proof-of-ai 3, ai-trust 6, verifiable-data 7, market-settlement 9, payments 8, readiness 4). diff --git a/payments/README.md b/payments/README.md new file mode 100644 index 0000000..48bdbdf --- /dev/null +++ b/payments/README.md @@ -0,0 +1,51 @@ +# AERE Payments: the risk step and the receipt + +The two pieces that were missing from the payment chain for people and companies (identity -> authorization -> risk -> payment -> +proof -> settlement -> receipt), built as pure modules on what already exists: the AIP-23 `settlement` envelope (`proof-kinds/`), +the notary call of the house (`agents/agent-ancora.mjs`) and the AIP-23 reference verifier. Prices are in USD; the asset is only the +settlement. + +**Risk (`risc.mjs`).** A declared policy (`definePolicy`: a per-payment limit and a sliding-window limit in USD per payer, the assets +allowed, the counterparties refused with the source of each entry) and the inputs of one payment (payer, payee, asset and amount, the +USD rate DECLARED as an input with its source and time, the payer's history as this issuer recorded it, the time) give one verdict +(`judeca`): ALLOW or REFUSE, the reasons, what was measured, and what was NOT. The verdict's hash covers the policy's hash and the +inputs' hash, so anyone with the same policy and inputs reproduces it; a policy with a field the engine does not know is refused, so +no one can be shown a rule that is not applied. The signals the module does not have are named in every verdict, never simulated: +sanctions screening (no source), identity tier, device or location, chargeback history, and the payer's payments outside this +issuer's own receipts. Without a declared rate the USD value is unmeasured and the policy refuses; it is never guessed. + +**Receipt (`chitanta.mjs`).** An instruction (payer, payee, the amount in the asset and in USD at the declared rate, the transaction +and its block, the risk verdict with its policy under hash, the unmeasured signals) and the AIP-23 `settlement` envelope that commits +to it by hash. The envelope is what the issuer notarizes on AereNotary: the receipt's time is then the chain's, read back under a +post-quantum certified anchor by the reference verifier. `verificaChitanta` checks that the envelope commits to the instruction, that +the USD amount is exactly amount x rate, that the policy shown hashes to the one under the verdict, that the verdict reproduces from +the inputs and was ALLOW, and, with the chain facts, that the transaction is this payment (from, to, value, block, status). + +``` +node proba-risc.mjs # 22 rows, no chain: the verdicts under a policy, a receipt verified, nine forged ones refused by name +node proba-risc.mjs --control-negativ # 8 plantings on copies of risc.mjs and chitanta.mjs; each forged fixture must then be accepted +AERE_TESTNET_KEY_FILE= AERE_ETHERS_DIR= node proba-plata-28001.mjs # the flow on testnet 28001 +node verifica-chitanta.mjs dovezi/chitanta-28001-.json --rpc https://testnet-rpc.aere.network --verify-proof +``` + +Measured on 2026-10-02 on testnet 28001 (9/9): a person's payment of 0.4 tAERE at a declared 0.5 USD/tAERE = 0.20 USD allowed, paid +by a plain transfer the payer signed, its receipt issued, notarized by the payee and read back with finality PASSED under a +post-quantum certified anchor; a payment of 1.50 USD refused above the per-payment limit with no transaction sent; the fourth payment +in the window refused above the count; the receipt forged (amount changed) INVALID; a looser policy shown under the receipt's policy +hash INVALID. The public reader (`verifica-chitanta.mjs`) needs only Node.js: the chain is read by plain JSON-RPC. + +## What it is not, and what is not measured + +- **Not KYC, not AML, not sanctions screening.** The policy's refused counterparties are addresses this house measured as sinks on + 28001 (contracts with no path to withdraw what is sent to them, with the deployment record as source) and the zero address; no + sanctions list is consulted, and the verdict says so in `unmeasured`. An identity or compliance record (`identity/`) can be named in + the inputs; it is not judged here. +- **The policy is the issuer's word.** One issuer, one policy, one key: the receipt proves that THIS policy, as hashed, gave THIS verdict + on THESE inputs; it does not prove the policy is a good one, nor that the issuer's history of the payer is complete (it is only the + issuer's own receipts). +- **The rate is declared, not observed.** The USD amount is exact at the rate the issuer declares as an input (source and time on the + receipt); a wrong declared rate gives a wrong USD figure, honestly attributed. +- **Native transfers only in the measured flow.** An ERC-20 payment (the testnet USD token through x402) is supported by the policy's + asset list but not measured; the receipt's chain check compares `value` only for the native asset. +- **Not a payment API.** No service, no queue, no callbacks; the issuer runs these modules in its own process. +- **Chain 2800: nothing** ran there; test keys only, the payee's derived from the payer's and never printed. diff --git a/payments/chitanta.mjs b/payments/chitanta.mjs new file mode 100644 index 0000000..ff29aa9 --- /dev/null +++ b/payments/chitanta.mjs @@ -0,0 +1,63 @@ +// chitanta.mjs: the RECEIPT of a payment for a person or a company (roadmap point 25): an INSTRUCTION document (payer, payee, the amount +// in the asset and in USD at the rate declared as an input, the transaction, the risk verdict with its policy under hash) and the AIP-23 +// `settlement` envelope that commits to it (instructionHash = SHA-256 of the canonical instruction; txHash and blockHash of the payment; +// chainId, from, to, amount, asset). The envelope is what gets notarized on AereNotary, so the receipt's time is the chain's, read back +// under a post-quantum certified anchor by the reference verifier; the instruction is what a reader checks against the chain and against +// the risk module. `verificaChitanta` is pure (the chain facts come in as arguments: the transaction and its receipt, fetched by the +// caller); every rule carries a PAZA marker for the planting control. Only node:crypto and proof-kinds. +import crypto from 'node:crypto'; +import { buildProof } from '../proof-kinds/proof-kinds.mjs'; +import { canonical, hashOf, definePolicy, judeca, usdOf, usdText } from './risc.mjs'; + +export const KIND_INSTRUCTION = 'aere-payment-receipt-instruction'; +const sha256 = (b) => '0x' + crypto.createHash('sha256').update(b).digest('hex'); +const H32 = /^0x[0-9a-f]{64}$/i, low = (x) => String(x || '').toLowerCase(); + +/** Builds the instruction and its settlement envelope from a payment, its chain facts and an ALLOW verdict. */ +export function construiesteChitanta({ chainId, txHash, blockHash, payer, payee, asset, decimals, amount, rate, verdict, policyId, purpose = null, createdAt = new Date().toISOString() }) { + if (!verdict || verdict.decision !== 'ALLOW') throw new Error('a receipt is issued only on an ALLOW verdict'); + const instruction = { v: 1, kind: KIND_INSTRUCTION, chainId, payer: low(payer), payee: low(payee), asset: low(asset), decimals, amount: String(amount), + amountUsd: usdText(usdOf(amount, decimals, rate.usdPerUnit)), rate: { usdPerUnit: rate.usdPerUnit, source: rate.source, at: rate.at }, + txHash: low(txHash), blockHash: low(blockHash), risk: { policyId, policyHash: verdict.policyHash, inputsHash: verdict.inputsHash, verdictHash: verdict.verdictHash, decision: verdict.decision, unmeasured: verdict.unmeasured }, purpose, issuedAt: createdAt }; + const envelope = buildProof('settlement', { chainId, txHash: low(txHash), blockHash: low(blockHash), from: low(payer), to: low(payee), amount: String(amount), asset: low(asset), instruction: Buffer.from(canonical(instruction), 'utf8'), createdAt }); + return { instruction, envelope }; +} +/** + * Verifies a receipt against the policy, the inputs the issuer judged, and (when given) the chain facts. + * @param {object} r { instruction, envelope } + * @param {object} o { policy, inputs, tx?: {from, to, value(hex|string), blockHash}, receipt?: {status(hex|number), blockHash} } + */ +export function verificaChitanta(r, o = {}) { + const motive = []; const ok = (c, m) => { if (!c) motive.push(m); return !!c; }; + const ins = r && r.instruction, env = r && r.envelope; + if (!ins || ins.kind !== KIND_INSTRUCTION || !env || !env.statement) return { verdict: 'INVALID', motive: ['not a receipt (instruction + settlement envelope)'] }; + const st = env.statement; + // the envelope commits to this instruction, and says the same thing + const okLegatura = env.kind === 'aere-proof-of-settlement-attestation' && sha256(Buffer.from(JSON.stringify(st), 'utf8')) === low(env.statementHash) + && st.instructionHash === sha256(Buffer.from(canonical(ins), 'utf8')) && st.chainId === ins.chainId && low(st.from) === ins.payer && low(st.to) === ins.payee && st.amount === ins.amount && low(st.asset) === ins.asset && low(st.txHash) === ins.txHash && low(st.blockHash) === ins.blockHash /*PAZA:legatura*/; + ok(okLegatura, 'the envelope does not commit to this instruction (instructionHash, statementHash or the fields differ)'); + ok(H32.test(ins.txHash) && H32.test(ins.blockHash), 'txHash and blockHash must be 32-byte hashes'); + // the USD amount is the amount at the declared rate, exactly + try { ok(ins.amountUsd === usdText(usdOf(ins.amount, ins.decimals, ins.rate.usdPerUnit)) /*PAZA:suma*/, 'amountUsd is not amount x the declared rate'); } catch (e) { motive.push('the amount or the rate is malformed: ' + e.message); } + // the policy under the hash is the policy shown + if (o.policy) { try { ok(definePolicy(o.policy).policyHash === ins.risk.policyHash, 'the policy shown does not hash to the policy the verdict names (a policy changed under its hash)'); } catch (e) { motive.push('the policy is refused: ' + e.message); } } + else motive.push('no policy given: the risk verdict cannot be reproduced'); + // the verdict is reproduced from the same inputs, and it was ALLOW + if (o.policy && o.inputs) { + try { + const v = judeca(o.policy, o.inputs); + const okRisc = hashOf(o.inputs) === ins.risk.inputsHash && v.verdictHash === ins.risk.verdictHash && v.decision === 'ALLOW' && ins.risk.decision === 'ALLOW' /*PAZA:risc*/; + ok(okRisc, v.decision !== 'ALLOW' ? 'the risk module REFUSES these inputs under this policy: ' + v.reasons.join('; ') : 'the risk verdict does not reproduce from the inputs and the policy (inputsHash or verdictHash differ)'); + ok(low(o.inputs.payer) === ins.payer && low(o.inputs.payee) === ins.payee && String(o.inputs.amount) === ins.amount && low(o.inputs.asset) === ins.asset && o.inputs.chainId === ins.chainId, 'the inputs judged are not the payment of this receipt'); + ok(o.inputs.rate && o.inputs.rate.usdPerUnit === ins.rate.usdPerUnit && o.inputs.rate.source === ins.rate.source && o.inputs.rate.at === ins.rate.at, 'the rate judged is not the rate on the receipt'); + } catch (e) { motive.push('the risk verdict could not be reproduced: ' + e.message); } + } else if (o.policy) motive.push('no inputs given: the risk verdict cannot be reproduced'); + // the chain facts, when given: the transaction is the payment, mined, in the block the receipt names + if (o.tx) { + const val = typeof o.tx.value === 'string' && o.tx.value.startsWith('0x') ? BigInt(o.tx.value).toString() : String(o.tx.value); + const status = o.receipt ? Number(BigInt(o.receipt.status)) : null; + const okLant = low(o.tx.from) === ins.payer && low(o.tx.to) === ins.payee && (ins.asset !== 'native' || val === ins.amount) && low(o.tx.blockHash) === ins.blockHash && (status === null || status === 1) && (!o.receipt || low(o.receipt.blockHash) === ins.blockHash) /*PAZA:lant*/; + ok(okLant, 'the transaction on chain is not this payment (from, to, value, block or status differ)'); + } + return { verdict: motive.length ? 'INVALID' : 'VALID', motive, amountUsd: ins.amountUsd, chainChecked: !!o.tx }; +} diff --git a/payments/dovezi/chitanta-28001-2026-10-02T1716Z.json b/payments/dovezi/chitanta-28001-2026-10-02T1716Z.json new file mode 100644 index 0000000..e38f843 --- /dev/null +++ b/payments/dovezi/chitanta-28001-2026-10-02T1716Z.json @@ -0,0 +1,113 @@ +{ + "instruction": { + "v": 1, + "kind": "aere-payment-receipt-instruction", + "chainId": 28001, + "payer": "0x86d6c68408ddb2cc0d1cf4dfd6e07d3830600faf", + "payee": "0x5c5dc3eb504affbb15d0237fd0bff509840ebf64", + "asset": "native", + "decimals": 18, + "amount": "400000000000000000", + "amountUsd": "0.200000", + "rate": { + "usdPerUnit": "0.5", + "source": "declared by the payee for this test (28001 has no market price; tAERE is a test asset)", + "at": "2026-10-02T17:16:44.549Z" + }, + "txHash": "0xfd50a39264c2498e675edeaa45622b93244bb72b0f384380bc20840e3bb19cb6", + "blockHash": "0x3c2fc5e4071a6ddcc4b5f843d142dbaeaa7019edf9ceee45fe51a0365dd0c102", + "risk": { + "policyId": "payee-company-28001-2026-10-02", + "policyHash": "0x49c2478b8a5a5bc30dbfc8d2a10e1365126f1b10bc94beb67d678cd02d5bf390", + "inputsHash": "0xfc4cf1b16150c6a614c2184cd88673aa2b13b7fd6b56ec0bff1ec835b83afe13", + "verdictHash": "0xf3ed4242b45057c3569846e9168bab453702206578250810732351ce7f196f66", + "decision": "ALLOW", + "unmeasured": [ + "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" + ] + }, + "purpose": "test purchase P1", + "issuedAt": "2026-10-02T17:16:49.250Z" + }, + "envelope": { + "v": 1, + "kind": "aere-proof-of-settlement-attestation", + "statement": { + "v": 1, + "kind": "aere-proof-of-settlement", + "instructionHash": "0xd98c5c84b4994e7310f777a504df09925c6917363794d89cffd92beed2b12b78", + "txHash": "0xfd50a39264c2498e675edeaa45622b93244bb72b0f384380bc20840e3bb19cb6", + "blockHash": "0x3c2fc5e4071a6ddcc4b5f843d142dbaeaa7019edf9ceee45fe51a0365dd0c102", + "chainId": 28001, + "from": "0x86d6c68408ddb2cc0d1cf4dfd6e07d3830600faf", + "to": "0x5c5dc3eb504affbb15d0237fd0bff509840ebf64", + "amount": "400000000000000000", + "asset": "native", + "createdAt": "2026-10-02T17:16:49.250Z" + }, + "statementHash": "0x1a76b3cfeab71bf185344b8c64571117c8194300bd969f48469f4a451bf51fa0", + "notarization": { + "chainId": 28001, + "notary": "0x70099E62735500AA2F85B60C518551a57B202d54", + "firstSeen": 1790961411, + "txHash": "0x0c3ec57754dc3ab47221cb18c29be70099e3ca993fecd0c9de3b65ee0e1bba3b", + "block": 4449144 + } + }, + "policy": { + "v": 1, + "kind": "aere-payment-risk-policy", + "id": "payee-company-28001-2026-10-02", + "currency": "USD", + "perPaymentMaxUsd": "1.000000", + "window": { + "seconds": 86400, + "maxUsd": "2.500000", + "maxCount": 3 + }, + "allowedAssets": [ + { + "chainId": 28001, + "asset": "native", + "decimals": 18 + } + ], + "refusedCounterparties": [ + { + "address": "0x0000000000000000000000000000000000000000", + "reason": "the zero address: funds sent there are unrecoverable", + "source": "measured on every EVM chain" + }, + { + "address": "0x4145d6f639436d840957f2d4b9f2e73f34f56b5d", + "reason": "a contract that only pays out through its own job flow (AereComputeMarketV3 on 28001): a plain transfer to it is lost", + "source": "tools/aere-market-settlement/desfasurare-28001.json" + }, + { + "address": "0x70099e62735500aa2f85b60c518551a57b202d54", + "reason": "a contract with no path to withdraw what is sent to it (AereNotary on 28001)", + "source": "contracts/deployments/notary-28001.json" + } + ], + "requireRate": true + }, + "inputs": { + "chainId": 28001, + "payer": "0x86d6c68408ddb2cc0d1cf4dfd6e07d3830600faf", + "payee": "0x5c5dc3eb504affbb15d0237fd0bff509840ebf64", + "asset": "native", + "amount": "400000000000000000", + "rate": { + "usdPerUnit": "0.5", + "source": "declared by the payee for this test (28001 has no market price; tAERE is a test asset)", + "at": "2026-10-02T17:16:44.549Z" + }, + "identity": null, + "history": [], + "now": 1790961406 + } +} \ No newline at end of file diff --git a/payments/dovezi/plata-28001-2026-10-02T1717Z.json b/payments/dovezi/plata-28001-2026-10-02T1717Z.json new file mode 100644 index 0000000..598c0ac --- /dev/null +++ b/payments/dovezi/plata-28001-2026-10-02T1717Z.json @@ -0,0 +1,303 @@ +{ + "la": "2026-10-02T17:16:44.551Z", + "chainId": 28001, + "rpc": "https://testnet-rpc.aere.network", + "policy": { + "v": 1, + "kind": "aere-payment-risk-policy", + "id": "payee-company-28001-2026-10-02", + "currency": "USD", + "perPaymentMaxUsd": "1.000000", + "window": { + "seconds": 86400, + "maxUsd": "2.500000", + "maxCount": 3 + }, + "allowedAssets": [ + { + "chainId": 28001, + "asset": "native", + "decimals": 18 + } + ], + "refusedCounterparties": [ + { + "address": "0x0000000000000000000000000000000000000000", + "reason": "the zero address: funds sent there are unrecoverable", + "source": "measured on every EVM chain" + }, + { + "address": "0x4145d6f639436d840957f2d4b9f2e73f34f56b5d", + "reason": "a contract that only pays out through its own job flow (AereComputeMarketV3 on 28001): a plain transfer to it is lost", + "source": "tools/aere-market-settlement/desfasurare-28001.json" + }, + { + "address": "0x70099e62735500aa2f85b60c518551a57b202d54", + "reason": "a contract with no path to withdraw what is sent to it (AereNotary on 28001)", + "source": "contracts/deployments/notary-28001.json" + } + ], + "requireRate": true + }, + "policyHash": "0x49c2478b8a5a5bc30dbfc8d2a10e1365126f1b10bc94beb67d678cd02d5bf390", + "rate": { + "usdPerUnit": "0.5", + "source": "declared by the payee for this test (28001 has no market price; tAERE is a test asset)", + "at": "2026-10-02T17:16:44.549Z" + }, + "plati": [ + { + "eticheta": "P1", + "instruction": { + "v": 1, + "kind": "aere-payment-receipt-instruction", + "chainId": 28001, + "payer": "0x86d6c68408ddb2cc0d1cf4dfd6e07d3830600faf", + "payee": "0x5c5dc3eb504affbb15d0237fd0bff509840ebf64", + "asset": "native", + "decimals": 18, + "amount": "400000000000000000", + "amountUsd": "0.200000", + "rate": { + "usdPerUnit": "0.5", + "source": "declared by the payee for this test (28001 has no market price; tAERE is a test asset)", + "at": "2026-10-02T17:16:44.549Z" + }, + "txHash": "0xfd50a39264c2498e675edeaa45622b93244bb72b0f384380bc20840e3bb19cb6", + "blockHash": "0x3c2fc5e4071a6ddcc4b5f843d142dbaeaa7019edf9ceee45fe51a0365dd0c102", + "risk": { + "policyId": "payee-company-28001-2026-10-02", + "policyHash": "0x49c2478b8a5a5bc30dbfc8d2a10e1365126f1b10bc94beb67d678cd02d5bf390", + "inputsHash": "0xfc4cf1b16150c6a614c2184cd88673aa2b13b7fd6b56ec0bff1ec835b83afe13", + "verdictHash": "0xf3ed4242b45057c3569846e9168bab453702206578250810732351ce7f196f66", + "decision": "ALLOW", + "unmeasured": [ + "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" + ] + }, + "purpose": "test purchase P1", + "issuedAt": "2026-10-02T17:16:49.250Z" + }, + "verificare": { + "verdict": "VALID", + "motive": [], + "amountUsd": "0.200000", + "chainChecked": true + }, + "verifyProof": { + "verdict": "VALID", + "statementHash": "0x1a76b3cfeab71bf185344b8c64571117c8194300bd969f48469f4a451bf51fa0", + "levels": [ + { + "level": "form", + "state": "PASSED", + "detail": "kind=aere-proof-of-settlement-attestation" + }, + { + "level": "integrity", + "state": "PASSED", + "detail": "sha256(statement) == statementHash (0x1a76b3cfeab71bf1..)" + }, + { + "level": "signature", + "state": "ABSENT", + "detail": "the envelope carries no signature" + }, + { + "level": "finality", + "state": "PASSED", + "detail": "post-quantum: the state of block 4449151, the parent of anchor 4449152 whose post-quantum certificate verified here records this statementHash in AereNotary 0x70099e62735500aa2f85b60c518551a57b202d54 as first seen at 2026-10-02T17:16:51.000Z (1790961411); first seen in block 4449144, transaction 0x0c3ec57754dc3ab4.. (located through the endpoint's logs, not part of what is certified)" + } + ] + }, + "cli": [ + "receipt: 0.200000 USD = 400000000000000000 units of native at 0.5 USD/unit (declared by the payee for this test (28001 has no market price; tAERE is a test asset)), 0x86d6c684.. -> 0x5c5dc3eb.., tx 0xfd50a39264c2498e.., risk ALLOW under policy 0x49c2478b8a5a5bc3..", + "rules: VALID (transaction compared on chain)", + "envelope (reference verifier): VALID; finality PASSED: post-quantum: the state of block 4449151, the parent of anchor 4449152 whose post-quantum certificate verified here records this statementHa", + "VALID, notarized under a post-quantum certified anchor" + ] + }, + { + "eticheta": "P2", + "verdict": { + "v": 1, + "kind": "aere-payment-risk-verdict", + "policyId": "payee-company-28001-2026-10-02", + "policyHash": "0x49c2478b8a5a5bc30dbfc8d2a10e1365126f1b10bc94beb67d678cd02d5bf390", + "inputsHash": "0x6dfef476dd4be04db918de89084a17074b16825d846f4674037cbaf1cbf9caad", + "decision": "REFUSE", + "reasons": [ + "above the per-payment limit: 1.500000 USD > 1.000000 USD" + ], + "unmeasured": [ + "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" + ], + "measured": { + "amountUsd": "1.500000", + "windowUsd": "0.200000", + "windowCount": 1, + "windowStart": 1790875051, + "historyCounted": 1 + }, + "verdictHash": "0x5709b7902a1d542aeebef7c1eca01bdd6fd4dc457bb108ffc9ec4855fcd63cc7" + }, + "nonce": [ + 78, + 78 + ] + }, + { + "eticheta": "P3", + "chitante": [ + { + "v": 1, + "kind": "aere-payment-receipt-instruction", + "chainId": 28001, + "payer": "0x86d6c68408ddb2cc0d1cf4dfd6e07d3830600faf", + "payee": "0x5c5dc3eb504affbb15d0237fd0bff509840ebf64", + "asset": "native", + "decimals": 18, + "amount": "400000000000000000", + "amountUsd": "0.200000", + "rate": { + "usdPerUnit": "0.5", + "source": "declared by the payee for this test (28001 has no market price; tAERE is a test asset)", + "at": "2026-10-02T17:16:44.549Z" + }, + "txHash": "0xaf62a46d6251a885f3302153dcea57141b0d3802882bb0ac763bf45c08732266", + "blockHash": "0x2a7d37e1ebdd5dcd2e403b52ab406f62d9e5fc22c9cebd3fd67a2a631187c692", + "risk": { + "policyId": "payee-company-28001-2026-10-02", + "policyHash": "0x49c2478b8a5a5bc30dbfc8d2a10e1365126f1b10bc94beb67d678cd02d5bf390", + "inputsHash": "0x4816a80bba24a3ea9d227715f2fb0bb6cd2476a9b4b3ad44ec58ccdc260a1324", + "verdictHash": "0xdcdf6e662514763fb76e5e96277fac0d5ed392d92be567d50a2ed88f2e6717a0", + "decision": "ALLOW", + "unmeasured": [ + "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" + ] + }, + "purpose": "test purchase P3a", + "issuedAt": "2026-10-02T17:17:34.958Z" + }, + { + "v": 1, + "kind": "aere-payment-receipt-instruction", + "chainId": 28001, + "payer": "0x86d6c68408ddb2cc0d1cf4dfd6e07d3830600faf", + "payee": "0x5c5dc3eb504affbb15d0237fd0bff509840ebf64", + "asset": "native", + "decimals": 18, + "amount": "400000000000000000", + "amountUsd": "0.200000", + "rate": { + "usdPerUnit": "0.5", + "source": "declared by the payee for this test (28001 has no market price; tAERE is a test asset)", + "at": "2026-10-02T17:16:44.549Z" + }, + "txHash": "0x0f5171a865aefa7559620f80f51ab7b2ca31355c656d96e2001a173f569b8d73", + "blockHash": "0x341e5939a7e1d34db1b5b0d12b0bb3cd8d9fea0922bf3a41f04668e350200f58", + "risk": { + "policyId": "payee-company-28001-2026-10-02", + "policyHash": "0x49c2478b8a5a5bc30dbfc8d2a10e1365126f1b10bc94beb67d678cd02d5bf390", + "inputsHash": "0xf745936b47ee82ffecb3d7529a2bb5b8a9b999d4c9726a02de19725dd582f4b1", + "verdictHash": "0x8795afd5fa93e8c4e1566714c943d71193908717cc2943f18c81cd51ed61aacf", + "decision": "ALLOW", + "unmeasured": [ + "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" + ] + }, + "purpose": "test purchase P3b", + "issuedAt": "2026-10-02T17:17:37.356Z" + } + ], + "refuz": { + "v": 1, + "kind": "aere-payment-risk-verdict", + "policyId": "payee-company-28001-2026-10-02", + "policyHash": "0x49c2478b8a5a5bc30dbfc8d2a10e1365126f1b10bc94beb67d678cd02d5bf390", + "inputsHash": "0xa9e716869f824105697646c74b5df55f4478f1696b4af633a034e6a0f3c88d49", + "decision": "REFUSE", + "reasons": [ + "above the window count: 4 payments in 86400 s > 3" + ], + "unmeasured": [ + "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" + ], + "measured": { + "amountUsd": "0.200000", + "windowUsd": "0.600000", + "windowCount": 3, + "windowStart": 1790875056, + "historyCounted": 3 + }, + "verdictHash": "0x86efebc76881eb2104598e72458da58c2e768167dffc954187f2fa81edb8d31f" + } + } + ], + "istoric": [ + { + "at": 1790961406, + "amountUsd": "0.200000", + "payee": "0x5c5dc3eb504affbb15d0237fd0bff509840ebf64", + "txHash": "0xfd50a39264c2498e675edeaa45622b93244bb72b0f384380bc20840e3bb19cb6" + }, + { + "at": 1790961452, + "amountUsd": "0.200000", + "payee": "0x5c5dc3eb504affbb15d0237fd0bff509840ebf64", + "txHash": "0xaf62a46d6251a885f3302153dcea57141b0d3802882bb0ac763bf45c08732266" + }, + { + "at": 1790961453, + "amountUsd": "0.200000", + "payee": "0x5c5dc3eb504affbb15d0237fd0bff509840ebf64", + "txHash": "0x0f5171a865aefa7559620f80f51ab7b2ca31355c656d96e2001a173f569b8d73" + } + ], + "negative": { + "N1": { + "verdict": "INVALID", + "motive": [ + "the envelope does not commit to this instruction (instructionHash, statementHash or the fields differ)", + "amountUsd is not amount x the declared rate", + "the risk verdict does not reproduce from the inputs and the policy (inputsHash or verdictHash differ)", + "the transaction on chain is not this payment (from, to, value, block or status differ)" + ], + "amountUsd": "0.200000", + "chainChecked": true + }, + "N2": { + "verdict": "INVALID", + "motive": [ + "the policy shown does not hash to the policy the verdict names (a policy changed under its hash)", + "the risk verdict does not reproduce from the inputs and the policy (inputsHash or verdictHash differ)" + ], + "amountUsd": "0.200000", + "chainChecked": true + } + }, + "rezumat": { + "verzi": 9, + "rosii": 0 + } +} \ No newline at end of file diff --git a/payments/proba-plata-28001.mjs b/payments/proba-plata-28001.mjs new file mode 100644 index 0000000..686d7a9 --- /dev/null +++ b/payments/proba-plata-28001.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +// proba-plata-28001.mjs: the complete payment flow of a PERSON or a COMPANY (not an agent), measured on testnet 28001 (roadmap point 25): +// identity (the payer's address; a compliance record may be named, it is not judged here) -> authorization (the payer signs the +// transaction itself) -> RISK (risc.mjs: a declared policy, the USD rate declared as an input, the issuer's own receipts as history) +// -> payment (a plain native transfer) -> proof (the transaction and its receipt read back) -> settlement (the receipt's envelope +// notarized by the payee on AereNotary and read back under a post-quantum certified anchor) -> RECEIPT (instruction + AIP-23 settlement +// envelope, verified with verifica-chitanta.mjs and with the reference verifier). Test keys only: the payer's from AERE_TESTNET_KEY_FILE, +// the payee's derived from it (never printed), funded from it for the notarization gas. Never chain 2800. +// P1 0.4 tAERE at the declared rate 0.5 USD/tAERE = 0.20 USD: ALLOW -> paid -> notarized -> VALID (rules, chain, finality PASSED) +// P2 3 tAERE = 1.50 USD: REFUSED above the per-payment limit; no transaction is sent (measured: the payer's nonce does not move) +// P3 two more payments of 0.20 USD: ALLOW (history = this issuer's receipts); the fourth: REFUSED above the window count (4 > 3) +// N1 the receipt of P1 forged (amount changed): INVALID for the named reason; N2 a looser policy under the same hash: INVALID +// AERE_TESTNET_KEY_FILE= AERE_ETHERS_DIR= node proba-plata-28001.mjs +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { createRequire } from 'node:module'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { definePolicy, judeca, hashOf } from './risc.mjs'; +import { construiesteChitanta, verificaChitanta } from './chitanta.mjs'; +import { notarizeEnvelope } from '../agents/agent-ancora.mjs'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const RAD = path.resolve(AICI, '..', '..'); +const RPC = process.env.AERE_TESTNET_RPC || 'https://testnet-rpc.aere.network'; +const KF = process.env.AERE_TESTNET_KEY_FILE || path.join(RAD, 'tools', 'aere-quantum-shield', 'dovezi-l1', '.chei', 'cheie-de-proba-28001.key'); +const ETHERS_DIR = process.env.AERE_ETHERS_DIR || path.join(RAD, 'contracts'); +const VERIFY_PROOF = process.env.AERE_VERIFY_PROOF || path.join(RAD, 'verificator-falcon', 'verify-proof.mjs'); +const DOVEZI = path.join(AICI, 'dovezi'); fs.mkdirSync(DOVEZI, { recursive: true }); +const nem = (m) => { console.log('NEMASURAT: ' + m); process.exit(2); }; +let verzi = 0, rosii = 0; const cer = (n, c, d = '') => { console.log(` [${c ? 'VERDE ' : 'ROSU '}] ${n}${d ? ' (' + String(d).slice(0, 220) + ')' : ''}`); c ? verzi++ : rosii++; }; +if (!fs.existsSync(KF)) nem('no test key at ' + KF); +let ethers; try { ({ ethers } = createRequire(path.join(ETHERS_DIR, 'package.json'))('ethers')); } catch (e) { nem('ethers not loadable from ' + ETHERS_DIR + ': ' + e.message); } +const cheia = (f) => { const l = fs.readFileSync(f, 'utf8').split(/\r?\n/).map((x) => x.trim()).find((x) => /^(d|PRIVATE_KEY)=/.test(x)) || ''; const h = l.replace(/^(d|PRIVATE_KEY)=/, '').replace(/^0x/, ''); if (!/^[0-9a-fA-F]{1,64}$/.test(h)) nem('the key file has no d= line'); return '0x' + h.padStart(64, '0'); }; +const provider = new ethers.JsonRpcProvider(RPC, undefined, { staticNetwork: false }); +const chainId = Number((await provider.getNetwork()).chainId); if (chainId !== 28001) nem(`the RPC serves chain ${chainId}; only testnet 28001`); +const payer = new ethers.Wallet(cheia(KF), provider); +const payee = new ethers.Wallet('0x' + crypto.createHash('sha256').update(payer.privateKey + ':payee-company').digest('hex'), provider); // derived, never printed +const E = (n) => ethers.parseEther(n); +const tx = async (nume, p) => { const r = await (await p).wait(1, 180000); if (!r || r.status !== 1) throw new Error(nume + ' failed'); console.log(` ${nume}: tx ${r.hash.slice(0, 18)}.. block ${r.blockNumber} gas ${r.gasUsed}`); return r; }; +const verifyProof = (env, rpc = null) => { const f = path.join(os.tmpdir(), 'plata-' + crypto.randomUUID() + '.json'); fs.writeFileSync(f, JSON.stringify(env)); try { const r = spawnSync(process.execPath, [VERIFY_PROOF, f, '--json', ...(rpc ? ['--rpc', rpc, '--chain', '28001'] : [])], { encoding: 'utf8', timeout: 300000 }); try { return JSON.parse(r.stdout); } catch { return null; } } finally { fs.rmSync(f, { force: true }); } }; + +// the policy of this issuer (the payee company), declared; the refused counterparties are addresses the house MEASURED as sinks on 28001: +// contracts with no path to withdraw what is sent to them (deployment records in this repository), not a sanctions list +const desfPiata = (() => { try { return JSON.parse(fs.readFileSync(path.join(RAD, 'tools', 'aere-market-settlement', 'desfasurare-28001.json'), 'utf8')).market; } catch { return null; } })(); +const { policy, policyHash } = definePolicy({ v: 1, kind: 'aere-payment-risk-policy', id: 'payee-company-28001-2026-10-02', currency: 'USD', perPaymentMaxUsd: '1.00', window: { seconds: 86400, maxUsd: '2.50', maxCount: 3 }, + allowedAssets: [{ chainId: 28001, asset: 'native', decimals: 18 }], + refusedCounterparties: [{ address: '0x70099E62735500AA2F85B60C518551a57B202d54', reason: 'a contract with no path to withdraw what is sent to it (AereNotary on 28001)', source: 'contracts/deployments/notary-28001.json' }, + ...(desfPiata ? [{ address: desfPiata, reason: 'a contract that only pays out through its own job flow (AereComputeMarketV3 on 28001): a plain transfer to it is lost', source: 'tools/aere-market-settlement/desfasurare-28001.json' }] : []), + { address: '0x0000000000000000000000000000000000000000', reason: 'the zero address: funds sent there are unrecoverable', source: 'measured on every EVM chain' }] }); +const RATE = { usdPerUnit: '0.5', source: 'declared by the payee for this test (28001 has no market price; tAERE is a test asset)', at: new Date().toISOString() }; +const dovada = { la: new Date().toISOString(), chainId, rpc: RPC, policy, policyHash, rate: RATE, plati: [], istoric: [] }; +const istoric = []; // the issuer's own receipts: the only history the risk step has +const inputsPentru = (amount, now) => ({ chainId, payer: payer.address.toLowerCase(), payee: payee.address.toLowerCase(), asset: 'native', amount: amount.toString(), rate: RATE, identity: null, history: istoric.map((h) => ({ ...h })), now }); +const acum = async () => (await provider.getBlock('latest')).timestamp; + +try { + if ((await provider.getBalance(payee.address)) < E('0.02')) await tx('fund the payee company (gas for notarization)', payer.sendTransaction({ to: payee.address, value: E('0.02') })); + console.log(` issuer policy ${policyHash.slice(0, 18)}..: per payment 1.00 USD, window 24 h 2.50 USD / 3 payments, ${policy.refusedCounterparties.length} refused counterparties (measured sinks), rate declared ${RATE.usdPerUnit} USD/tAERE`); + // ----- P1: a payment that passes, paid, notarized, verified ----- + const plateste = async (eticheta, amount) => { + const now = await acum(); const inputs = inputsPentru(amount, now); const v = judeca(policy, inputs); + console.log(` ${eticheta}: risk ${v.decision} for ${ethers.formatEther(amount)} tAERE = ${v.measured.amountUsd ?? '?'} USD${v.reasons.length ? ' (' + v.reasons.join('; ') + ')' : ''}`); + if (v.decision !== 'ALLOW') return { v, inputs }; + const r = await tx(eticheta + ' native transfer (the payer signs)', payer.sendTransaction({ to: payee.address, value: amount })); + const { instruction, envelope } = construiesteChitanta({ chainId, txHash: r.hash, blockHash: r.blockHash, payer: payer.address, payee: payee.address, asset: 'native', decimals: 18, amount: amount.toString(), rate: RATE, verdict: v, policyId: policy.id, purpose: 'test purchase ' + eticheta }); + istoric.push({ at: now, amountUsd: instruction.amountUsd, payee: payee.address.toLowerCase(), txHash: r.hash }); + return { v, inputs, r, instruction, envelope }; + }; + const p1 = await plateste('P1', E('0.4')); + cer('P1 0.4 tAERE at 0.5 USD/tAERE = 0.20 USD: ALLOW, paid, receipt issued', !!p1.r && p1.instruction.amountUsd === '0.200000', p1.instruction && p1.instruction.amountUsd + ' USD, tx ' + p1.r.hash.slice(0, 18) + '..'); + const txP1 = await provider.getTransaction(p1.r.hash), rcP1 = await provider.getTransactionReceipt(p1.r.hash); + const chainFacts = { tx: { from: txP1.from, to: txP1.to, value: txP1.value.toString(), blockHash: txP1.blockHash }, receipt: { status: rcP1.status, blockHash: rcP1.blockHash } }; + const v1 = verificaChitanta({ instruction: p1.instruction, envelope: p1.envelope }, { policy, inputs: p1.inputs, ...chainFacts }); + cer('P1 the receipt verifies: envelope commits to the instruction, USD exact, policy under hash, verdict reproduced, the transaction on chain is this payment', v1.verdict === 'VALID', v1.motive.join('; ')); + // the payee (the company) notarizes the receipt's envelope: one notarization formula for the house, the key through a 0600 temp file + const kt = path.join(os.tmpdir(), 'plata-payee-' + process.pid + '.key'); fs.writeFileSync(kt, 'd=' + payee.privateKey.slice(2) + '\n', { mode: 0o600 }); + let n; try { n = await notarizeEnvelope({ envelope: p1.envelope, kind: 'aere-proof-of-settlement', rpc: RPC, keyFile: kt, ethers }); } finally { fs.rmSync(kt, { force: true }); } + cer('P1 the receipt\'s envelope notarized on 28001 by the payee', n.ok, n.ok ? 'firstSeen ' + n.envelope.notarization.firstSeen + (n.envelope.notarization.txHash ? ', tx ' + n.envelope.notarization.txHash.slice(0, 18) + '..' : '') : n.motiv); + const chitanta = { instruction: p1.instruction, envelope: n.ok ? n.envelope : p1.envelope, policy, inputs: p1.inputs }; + const fisier = path.join(DOVEZI, 'chitanta-28001-' + new Date().toISOString().replace(/[:.]/g, '').slice(0, 15) + 'Z.json'); fs.writeFileSync(fisier, JSON.stringify(chitanta, null, 1)); + let vf = null; for (let k = 0; k < 24; k++) { vf = verifyProof(chitanta.envelope, RPC); const f = vf && (vf.levels || []).find((l) => l.level === 'finality'); if (f && f.state !== 'PENDING') break; await new Promise((ok) => setTimeout(ok, 5000)); } + const fin = vf && (vf.levels || []).find((l) => l.level === 'finality'); + cer('P1 the reference verifier: envelope VALID, finality PASSED under a post-quantum certified anchor', vf && vf.verdict === 'VALID' && fin && fin.state === 'PASSED', fin ? fin.state + ': ' + String(fin.detail).slice(0, 140) : '?'); + const cli = spawnSync(process.execPath, [path.join(AICI, 'verifica-chitanta.mjs'), fisier, '--rpc', RPC, '--verify-proof', VERIFY_PROOF], { encoding: 'utf8', timeout: 300000 }); + cer('P1 verifica-chitanta.mjs (the public reader, plain JSON-RPC, no ethers): exit 0, VALID, notarized', cli.status === 0 && /^VALID, notarized/m.test(cli.stdout), (cli.stdout || '').trim().split('\n').pop()); + dovada.plati.push({ eticheta: 'P1', instruction: p1.instruction, verificare: v1, verifyProof: vf, cli: (cli.stdout || '').trim().split('\n') }); + // ----- P2: above the per-payment limit: refused, nothing sent ----- + const nonce0 = await provider.getTransactionCount(payer.address); + const p2 = await plateste('P2', E('3')); + const nonce1 = await provider.getTransactionCount(payer.address); + cer('P2 3 tAERE = 1.50 USD: REFUSED above the per-payment limit, no transaction sent (nonce unchanged)', p2.v.decision === 'REFUSE' && /per-payment limit/.test(p2.v.reasons.join()) && nonce0 === nonce1, p2.v.reasons.join('; ')); + dovada.plati.push({ eticheta: 'P2', verdict: p2.v, nonce: [nonce0, nonce1] }); + // ----- P3: the window count: two more pass, the fourth is refused ----- + const p3a = await plateste('P3a', E('0.4')), p3b = await plateste('P3b', E('0.4')); + const nonce2 = await provider.getTransactionCount(payer.address); + const p3c = await plateste('P3c', E('0.4')); + const nonce3 = await provider.getTransactionCount(payer.address); + cer('P3 the second and third payments of 0.20 USD pass (history = this issuer\'s receipts); the fourth is REFUSED above the window count (4 > 3), nothing sent', !!p3a.r && !!p3b.r && p3c.v.decision === 'REFUSE' && /window count/.test(p3c.v.reasons.join()) && nonce2 === nonce3, p3c.v.reasons.join('; ') + '; window ' + p3c.v.measured.windowUsd + ' USD / ' + p3c.v.measured.windowCount); + dovada.plati.push({ eticheta: 'P3', chitante: [p3a.instruction, p3b.instruction], refuz: p3c.v }); + // ----- negatives on the real receipt ----- + const fals = JSON.parse(JSON.stringify(chitanta)); fals.instruction.amount = '500000000000000000'; fals.inputs.amount = fals.instruction.amount; + const vn1 = verificaChitanta({ instruction: fals.instruction, envelope: fals.envelope }, { policy, inputs: fals.inputs, ...chainFacts }); + cer('N1 the receipt forged (amount 0.5 instead of 0.4): INVALID, the envelope no longer commits to the instruction and the chain disagrees', vn1.verdict === 'INVALID' && /does not commit/.test(vn1.motive.join()), vn1.motive.join('; ')); + const vn2 = verificaChitanta({ instruction: chitanta.instruction, envelope: chitanta.envelope }, { policy: { ...policy, perPaymentMaxUsd: '10.00' }, inputs: p1.inputs, ...chainFacts }); + cer('N2 a looser policy shown under the receipt\'s policy hash: INVALID (policy changed under its hash)', vn2.verdict === 'INVALID' && /changed under its hash/.test(vn2.motive.join()), vn2.motive.join('; ')); + dovada.negative = { N1: vn1, N2: vn2 }; + dovada.istoric = istoric; +} finally { + dovada.rezumat = { verzi, rosii }; const out = path.join(DOVEZI, 'plata-28001-' + new Date().toISOString().replace(/[:.]/g, '').slice(0, 15) + 'Z.json'); + fs.writeFileSync(out, JSON.stringify(dovada, null, 1)); console.log(' evidence: ' + path.relative(RAD, out)); + console.log(`payments on 28001: ${verzi} VERDE, ${rosii} ROSU`); process.exitCode = rosii ? 1 : 0; +} diff --git a/payments/proba-risc.mjs b/payments/proba-risc.mjs new file mode 100644 index 0000000..c8224e0 --- /dev/null +++ b/payments/proba-risc.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +// proba-risc.mjs: the laboratory of the risk step and the receipt (roadmap point 25), no chain: the risk verdicts under a declared +// policy (allowed, above the per-payment limit, above the window, refused counterparty, self-payment, no declared rate, reproducible +// hash, unmeasured signals named), and the receipt's verification on a synthetic payment (valid; then forged: amount, instruction, +// policy loosened under the same hash, inputs changed, a transaction that is not the payment). Three states per row. +// --control-negativ plants every PAZA marker out, one at a time, in copies of risc.mjs and chitanta.mjs, and requires the forged fixture +// it guards to be accepted (green-by-mistake), which proves the check is load-bearing. +// node proba-risc.mjs [--control-negativ] env: AERE_RISC_MODUL, AERE_CHITANTA_MODUL (the control sets its planted copies) +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const control = process.argv.includes('--control-negativ'); +const incarca = async (risc, chit) => { + // chitanta.mjs imports ./risc.mjs by name: a planted chitanta copy must import the risc copy beside it, so the copy is rewritten + const R = await import(pathToFileURL(risc).href + '?t=' + Date.now() + Math.random()); + const C = await import(pathToFileURL(chit).href + '?t=' + Date.now() + Math.random()); + return { R, C }; +}; +const POLITICA = { v: 1, kind: 'aere-payment-risk-policy', id: 'lab-2026-10-02', currency: 'USD', perPaymentMaxUsd: '1.00', window: { seconds: 86400, maxUsd: '2.50', maxCount: 3 }, + allowedAssets: [{ chainId: 28001, asset: 'native', decimals: 18 }], + refusedCounterparties: [{ address: '0x70099E62735500AA2F85B60C518551a57B202d54', reason: 'a contract with no path to withdraw what is sent to it (AereNotary on 28001)', source: 'contracts/deployments/notary-28001.json' }] }; +const PAYER = '0x' + 'aa'.repeat(20), PAYEE = '0x' + 'bb'.repeat(20), RATE = { usdPerUnit: '0.5', source: 'declared by the operator for the laboratory', at: '2026-10-02T17:00:00.000Z' }; +const inputs = (x = {}) => ({ chainId: 28001, payer: PAYER, payee: PAYEE, asset: 'native', amount: '400000000000000000', rate: RATE, identity: null, history: [], now: 1790960000, ...x }); +const TX = '0x' + '11'.repeat(32), BLOC = '0x' + '22'.repeat(32); +const chitantaValida = (R, C) => { const v = R.judeca(POLITICA, inputs()); const r = C.construiesteChitanta({ chainId: 28001, txHash: TX, blockHash: BLOC, payer: PAYER, payee: PAYEE, asset: 'native', decimals: 18, amount: '400000000000000000', rate: RATE, verdict: v, policyId: POLITICA.id, createdAt: '2026-10-02T17:00:01.000Z' }); return { ...r, policy: POLITICA, inputs: inputs(), tx: { from: PAYER, to: PAYEE, value: '0x58d15e176280000', blockHash: BLOC }, receipt: { status: '0x1', blockHash: BLOC } }; }; +const clona = (x) => JSON.parse(JSON.stringify(x)); +const verif = (C, r) => C.verificaChitanta({ instruction: r.instruction, envelope: r.envelope }, { policy: r.policy, inputs: r.inputs, tx: r.tx, receipt: r.receipt }); + +export function fixturi({ R, C }) { + const F = []; const dec = (x, cond) => ({ verdict: cond ? x.decision : 'INVALID', detail: x.reasons.join('; ') || x.measured.amountUsd + ' USD' }); + F.push({ nume: 'R1 0.4 units at 0.5 USD/unit = 0.20 USD: ALLOW; five unmeasured signals named', asteptat: 'ALLOW', r: () => { const v = R.judeca(POLITICA, inputs()); return dec(v, v.measured.amountUsd === '0.200000' && v.unmeasured.length === 5); } }); + F.push({ nume: 'R2 the same policy and inputs twice: the same verdictHash; one input changed: another', asteptat: 'ALLOW', r: () => { const a = R.judeca(POLITICA, inputs()), b = R.judeca(POLITICA, inputs()), c = R.judeca(POLITICA, inputs({ now: 1790960001 })); return { verdict: a.verdictHash === b.verdictHash && a.verdictHash !== c.verdictHash ? 'ALLOW' : 'INVALID', detail: a.verdictHash.slice(0, 18) + '..' }; } }); + F.push({ nume: 'R3 3 units = 1.50 USD: REFUSE above the per-payment limit', asteptat: 'REFUSE', paza: 'limita', r: () => dec(R.judeca(POLITICA, inputs({ amount: '3000000000000000000' })), true) }); + F.push({ nume: 'R4 two earlier payments of 1.20 USD in the window + 0.20: REFUSE above the window limit (2.60 > 2.50)', asteptat: 'REFUSE', paza: 'fereastra', r: () => dec(R.judeca(POLITICA, inputs({ history: [{ at: 1790959000, amountUsd: '1.200000', payee: PAYEE }, { at: 1790959500, amountUsd: '1.200000', payee: PAYEE }] })), true) }); + const H3 = [{ at: 1790959000, amountUsd: '0.100000' }, { at: 1790959100, amountUsd: '0.100000' }, { at: 1790959200, amountUsd: '0.100000' }]; + F.push({ nume: 'R5 three earlier small payments in the window: REFUSE above the window count (4 > 3)', asteptat: 'REFUSE', paza: 'fereastra', r: () => dec(R.judeca(POLITICA, inputs({ history: H3 })), true) }); + F.push({ nume: 'R5b the same with one of them outside the window: ALLOW (the window is a sliding 24 h)', asteptat: 'ALLOW', r: () => dec(R.judeca(POLITICA, inputs({ history: [...H3.slice(0, 2), { at: 1790959000 - 86400, amountUsd: '0.100000' }] })), true) }); + F.push({ nume: 'R6 a refused counterparty (the notary contract, with its source): REFUSE', asteptat: 'REFUSE', paza: 'contrapartida', r: () => dec(R.judeca(POLITICA, inputs({ payee: POLITICA.refusedCounterparties[0].address })), true) }); + F.push({ nume: 'R7 the payee is the payer: REFUSE', asteptat: 'REFUSE', paza: 'contrapartida', r: () => dec(R.judeca(POLITICA, inputs({ payee: PAYER })), true) }); + F.push({ nume: 'R8 no declared rate: REFUSE', asteptat: 'REFUSE', paza: 'curs', r: () => dec(R.judeca(POLITICA, inputs({ rate: null })), true) }); + F.push({ nume: 'R8b with no declared rate the USD value is left unmeasured and named so (never guessed)', asteptat: 'REFUSE', r: () => { const v = R.judeca(POLITICA, inputs({ rate: null })); return { verdict: v.measured.amountUsd === null && v.unmeasured.some((u) => /no declared rate/.test(u)) ? v.decision : 'INVALID', detail: v.unmeasured.find((u) => /rate/.test(u)) || '' }; } }); + F.push({ nume: 'R9 an asset the policy does not allow: REFUSE', asteptat: 'REFUSE', r: () => dec(R.judeca(POLITICA, inputs({ asset: 'erc20:0x8215ba247a3574af8ebc36606eb437811e318fbd' })), true) }); + F.push({ nume: 'R10 a policy with an unknown field is refused by definePolicy (nothing judged under a field the engine ignores)', asteptat: 'REFUSE', r: () => { try { R.definePolicy({ ...POLITICA, kycTier: 2 }); return { verdict: 'ALLOW', detail: 'accepted' }; } catch (e) { return { verdict: 'REFUSE', detail: e.message }; } } }); + // the receipt + F.push({ nume: 'C1 a receipt on an ALLOW verdict verifies: envelope commits to the instruction, USD exact, policy under hash, verdict reproduced, transaction matches', asteptat: 'VALID', r: () => { const r = chitantaValida(R, C); const v = verif(C, r); return { verdict: v.verdict, detail: v.motive.join('; ') || r.instruction.amountUsd + ' USD, ' + r.envelope.statementHash.slice(0, 18) + '..' }; } }); + F.push({ nume: 'C2 the same receipt without the chain facts: VALID but "chain not consulted"', asteptat: 'VALID', r: () => { const r = chitantaValida(R, C); const v = C.verificaChitanta({ instruction: r.instruction, envelope: r.envelope }, { policy: r.policy, inputs: r.inputs }); return { verdict: v.verdict === 'VALID' && v.chainChecked === false ? 'VALID' : 'INVALID', detail: v.motive.join('; ') }; } }); + F.push({ nume: 'N1 the instruction\'s purpose text changed after the envelope (nothing else touched): INVALID, the envelope no longer commits to it', asteptat: 'INVALID', paza: 'legatura', r: () => { const r = chitantaValida(R, C); r.instruction.purpose = 'another purpose'; const v = verif(C, r); return { verdict: v.verdict, detail: v.motive.join('; ') }; } }); + F.push({ nume: 'N1b the instruction\'s amount changed after the envelope: INVALID (linkage, USD arithmetic, verdict and chain all disagree)', asteptat: 'INVALID', r: () => { const r = chitantaValida(R, C); r.instruction.amount = '500000000000000000'; r.inputs.amount = r.instruction.amount; r.tx.value = '0x6f05b59d3b20000'; const v = verif(C, r); return { verdict: v.verdict, detail: v.motive.join('; ') }; } }); + F.push({ nume: 'N2 amountUsd overstated on the instruction (and the envelope rebuilt to match): INVALID (not amount x rate)', asteptat: 'INVALID', paza: 'suma', r: () => { const r = chitantaValida(R, C); r.instruction.amountUsd = '0.300000'; r.envelope = C.construiesteChitanta({ chainId: 28001, txHash: TX, blockHash: BLOC, payer: PAYER, payee: PAYEE, asset: 'native', decimals: 18, amount: r.instruction.amount, rate: RATE, verdict: R.judeca(POLITICA, inputs()), policyId: POLITICA.id, createdAt: r.instruction.issuedAt }).envelope; r.envelope.statement.instructionHash = '0x' + require_sha(C, r.instruction); r.envelope.statementHash = '0x' + require_sha_stmt(r.envelope.statement); const v = verif(C, r); return { verdict: v.verdict, detail: v.motive.join('; ') }; } }); + F.push({ nume: 'N3 the policy shown is LOOSER (per-payment 10 USD) but claims the receipt\'s policy hash: INVALID (policy changed under its hash; the verdict does not reproduce either)', asteptat: 'INVALID', r: () => { const r = chitantaValida(R, C); r.policy = { ...POLITICA, perPaymentMaxUsd: '10.00' }; const v = verif(C, r); return { verdict: v.verdict, detail: v.motive.join('; ') }; } }); + F.push({ nume: 'N4 the inputs shown differ from the ones judged (a shorter history): INVALID (verdict not reproduced)', asteptat: 'INVALID', paza: 'risc', r: () => { const r = chitantaValida(R, C); r.inputs = inputs({ history: [{ at: 1790959900, amountUsd: '0.100000' }] }); const v = verif(C, r); return { verdict: v.verdict, detail: v.motive.join('; ') }; } }); + F.push({ nume: 'N5 a receipt whose verdict was REFUSE cannot be built; one forged to say ALLOW over refusing inputs: INVALID', asteptat: 'INVALID', paza: 'risc', r: () => { const v = R.judeca(POLITICA, inputs({ amount: '3000000000000000000' })); let construit = true; try { C.construiesteChitanta({ chainId: 28001, txHash: TX, blockHash: BLOC, payer: PAYER, payee: PAYEE, asset: 'native', decimals: 18, amount: '3000000000000000000', rate: RATE, verdict: v, policyId: POLITICA.id }); } catch { construit = false; } const r = chitantaValida(R, C); r.instruction.amount = '3000000000000000000'; r.inputs = inputs({ amount: '3000000000000000000' }); r.instruction.risk.inputsHash = R.hashOf(r.inputs); r.instruction.amountUsd = '1.500000'; r.envelope = C.construiesteChitanta({ chainId: 28001, txHash: TX, blockHash: BLOC, payer: PAYER, payee: PAYEE, asset: 'native', decimals: 18, amount: '3000000000000000000', rate: RATE, verdict: { ...v, decision: 'ALLOW' }, policyId: POLITICA.id, createdAt: r.instruction.issuedAt }).envelope; r.instruction = JSON.parse(JSON.stringify(r.instruction)); r.instruction.risk.verdictHash = v.verdictHash; r.instruction.risk.decision = 'ALLOW'; r.instruction.txHash = TX; r.tx.value = '0x29a2241af62c0000'; const i2 = C.construiesteChitanta.length ? null : null; const inst = r.instruction; r.envelope.statement.instructionHash = '0x' + require_sha(C, inst); r.envelope.statementHash = '0x' + require_sha_stmt(r.envelope.statement); const vv = verif(C, r); return { verdict: !construit && vv.verdict === 'INVALID' ? 'INVALID' : 'VALID', detail: (construit ? 'a receipt WAS built on a REFUSE verdict; ' : 'refused to build; ') + vv.motive.join('; ') }; } }); + F.push({ nume: 'N6 the transaction on chain goes to another payee: INVALID', asteptat: 'INVALID', paza: 'lant', r: () => { const r = chitantaValida(R, C); r.tx.to = '0x' + 'cc'.repeat(20); const v = verif(C, r); return { verdict: v.verdict, detail: v.motive.join('; ') }; } }); + F.push({ nume: 'N7 the transaction on chain failed (status 0): INVALID', asteptat: 'INVALID', paza: 'lant', r: () => { const r = chitantaValida(R, C); r.receipt.status = '0x0'; const v = verif(C, r); return { verdict: v.verdict, detail: v.motive.join('; ') }; } }); + return F; +} +import crypto from 'node:crypto'; +const require_sha = (C, ins) => crypto.createHash('sha256').update(Buffer.from(JSON.stringify(sortKeys(ins)), 'utf8')).digest('hex'); +const require_sha_stmt = (st) => crypto.createHash('sha256').update(Buffer.from(JSON.stringify(st), 'utf8')).digest('hex'); +function sortKeys(x) { if (Array.isArray(x)) return x.map(sortKeys); if (x && typeof x === 'object') return Object.fromEntries(Object.keys(x).sort().map((k) => [k, sortKeys(x[k])])); return x; } + +const RISC = process.env.AERE_RISC_MODUL || path.join(AICI, 'risc.mjs'), CHIT = process.env.AERE_CHITANTA_MODUL || path.join(AICI, 'chitanta.mjs'); +if (!control) { + const M = await incarca(RISC, CHIT); let verzi = 0, rosii = 0, stricate = 0; + for (const f of fixturi(M)) { + let r; try { r = f.r(); } catch (e) { r = { verdict: 'STRICAT', detail: String(e.stack || e.message).split('\n').slice(0, 2).join(' ').slice(0, 160) }; } + const stare = r.verdict === 'STRICAT' ? 'STRICAT' : r.verdict === f.asteptat ? 'VERDE' : 'ROSU'; stare === 'VERDE' ? verzi++ : stare === 'ROSU' ? rosii++ : stricate++; + console.log(` [${stare.padEnd(7)}] ${f.nume}: ${r.verdict}${r.detail ? ' (' + String(r.detail).slice(0, 150) + ')' : ''}`); + } + console.log(`\npayments (risk + receipt): ${verzi} VERDE, ${rosii} ROSU, ${stricate} STRICAT`); process.exitCode = rosii || stricate ? 1 : 0; +} else { + const srcR = fs.readFileSync(RISC, 'utf8'), srcC = fs.readFileSync(CHIT, 'utf8'); let bune = 0; + // the policy-hash rule in chitanta.mjs carries no marker: it is a diagnostic (a clearer message) under the verdict reproduction, which + // already fails on any policy change (the policy hash is inside the verdict hash); the load-bearing check is PAZA:risc + const marcaje = [['contrapartida', 'R'], ['curs', 'R'], ['limita', 'R'], ['fereastra', 'R'], ['legatura', 'C'], ['suma', 'C'], ['risc', 'C'], ['lant', 'C']]; + const toate = fixturi(await incarca(RISC, CHIT)); + for (const [m, unde] of marcaje) { + const cR = path.join(AICI, `_plantat-risc-${process.pid}-${m}.mjs`), cC = path.join(AICI, `_plantat-chitanta-${process.pid}-${m}.mjs`); + const src = unde === 'R' ? srcR : srcC; const n = src.split(`/*PAZA:${m}*/`).length - 1; const tinte = toate.filter((f) => f.paza === m); + let stare, detaliu; + try { + if (n < 1 || !tinte.length) { stare = 'STRICAT'; detaliu = `marker ${n} times, ${tinte.length} fixtures`; } + else { + // the risc copy is always written (chitanta imports it by name); the planting goes in the file that holds the marker + fs.writeFileSync(cR, unde === 'R' ? srcR.split(`/*PAZA:${m}*/`).join("|| String(1) === '1' /*PLANTAT*/") : srcR); + fs.writeFileSync(cC, (unde === 'C' ? srcC.split(`/*PAZA:${m}*/`).join("|| String(1) === '1' /*PLANTAT*/") : srcC).replace("'./risc.mjs'", "'./" + path.basename(cR) + "'")); // the copy imports the risc copy beside it (written as a plain string: not an import of this file) + const fx = fixturi(await incarca(cR, cC)); const rez = []; + for (const t of tinte) { const f = fx.find((x) => x.nume === t.nume); const r = f.r(); rez.push(`${t.nume.split(' ')[0]}:${r.verdict}`); } + const trec = rez.every((x) => /:(VALID|ALLOW)$/.test(x)); stare = trec ? 'VERDE' : 'ROSU'; detaliu = (trec ? 'the forged fixture is accepted with the check planted out: ' : 'the planting did not change the verdict: ') + rez.join(' '); + } + } catch (e) { stare = 'STRICAT'; detaliu = String(e.message).slice(0, 160); } finally { fs.rmSync(cR, { force: true }); fs.rmSync(cC, { force: true }); } + if (stare === 'VERDE') bune++; + console.log(` [${stare.padEnd(7)}] PAZA:${m} in ${unde === 'R' ? 'risc.mjs' : 'chitanta.mjs'} (${n} marker${n === 1 ? '' : 's'}) ${detaliu}`); + } + const ramase = fs.readdirSync(AICI).filter((f) => f.startsWith('_plantat-')); + console.log(`CONTROL: ${bune} of ${marcaje.length} checks proven load-bearing${ramase.length ? '; copies left: ' + ramase.join(',') : ''}`); process.exitCode = bune === marcaje.length && !ramase.length ? 0 : 1; +} diff --git a/payments/risc.mjs b/payments/risc.mjs new file mode 100644 index 0000000..9670dfb --- /dev/null +++ b/payments/risc.mjs @@ -0,0 +1,86 @@ +// 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) }; + } +} diff --git a/payments/verifica-chitanta.mjs b/payments/verifica-chitanta.mjs new file mode 100644 index 0000000..03e6ce2 --- /dev/null +++ b/payments/verifica-chitanta.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// verifica-chitanta.mjs: a reader's check of a payment receipt (roadmap point 25), without ethers: the receipt file holds the +// instruction, the settlement envelope, the policy and the inputs the issuer judged; with --rpc the transaction and its receipt are +// read from the chain by plain JSON-RPC and compared; with --verify-proof the AIP-23 reference verifier judges the envelope and, +// with --rpc, its notarization under a post-quantum certified anchor (finality). +// node verifica-chitanta.mjs [--rpc URL] [--verify-proof ] [--chain 28001] +// Exit 0 VALID, 1 INVALID, 2 could not measure (no chain answer, no verifier). +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { verificaChitanta } from './chitanta.mjs'; + +const a = process.argv.slice(2); const opt = (k, d = null) => { const i = a.indexOf(k); return i >= 0 && i + 1 < a.length ? a[i + 1] : d; }; +const f = a[0]; if (!f || !fs.existsSync(f)) { console.log('usage: verifica-chitanta.mjs [--rpc URL] [--verify-proof path] [--chain id]'); process.exit(2); } +const r = JSON.parse(fs.readFileSync(f, 'utf8')); +const rpc = opt('--rpc'); const vp = opt('--verify-proof', process.env.AERE_VERIFY_PROOF || null); const chain = opt('--chain', r.instruction && r.instruction.chainId); +let tx = null, receipt = null, nem = null; +if (rpc) { + const apel = async (method, params) => { const x = await fetch(rpc, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(20000) }); const j = await x.json(); if (j.error) throw new Error(method + ': ' + j.error.message); return j.result; }; + try { tx = await apel('eth_getTransactionByHash', [r.instruction.txHash]); receipt = await apel('eth_getTransactionReceipt', [r.instruction.txHash]); if (!tx || !receipt) nem = 'the chain does not know transaction ' + r.instruction.txHash; } catch (e) { nem = 'the chain did not answer: ' + e.message; } +} +const v = verificaChitanta({ instruction: r.instruction, envelope: r.envelope }, { policy: r.policy, inputs: r.inputs, tx, receipt }); +let fin = null, verdictPlic = null; +if (vp && fs.existsSync(vp)) { + const t = path.join(os.tmpdir(), 'chitanta-' + process.pid + '.json'); fs.writeFileSync(t, JSON.stringify(r.envelope)); + try { const p = spawnSync(process.execPath, [vp, t, '--json', ...(rpc ? ['--rpc', rpc, '--chain', String(chain)] : [])], { encoding: 'utf8', timeout: 300000 }); try { const j = JSON.parse(p.stdout); verdictPlic = j.verdict; fin = (j.levels || []).find((l) => l.level === 'finality'); } catch { nem = nem || 'the reference verifier gave no JSON'; } } finally { fs.rmSync(t, { force: true }); } +} else if (vp) nem = nem || 'the reference verifier is not at ' + vp; +const randuri = [`receipt: ${r.instruction.amountUsd} USD = ${r.instruction.amount} units of ${r.instruction.asset} at ${r.instruction.rate.usdPerUnit} USD/unit (${r.instruction.rate.source}), ${r.instruction.payer.slice(0, 10)}.. -> ${r.instruction.payee.slice(0, 10)}.., tx ${r.instruction.txHash.slice(0, 18)}.., risk ${r.instruction.risk.decision} under policy ${r.instruction.risk.policyHash.slice(0, 18)}..`, + `rules: ${v.verdict}${v.motive.length ? ': ' + v.motive.join('; ') : ''}${v.chainChecked ? ' (transaction compared on chain)' : ' (chain not consulted)'}`, + verdictPlic ? `envelope (reference verifier): ${verdictPlic}; finality ${fin ? fin.state + ': ' + String(fin.detail).slice(0, 140) : 'absent'}` : 'envelope (reference verifier): not run']; +console.log(randuri.join('\n')); +const invalid = v.verdict === 'INVALID' || verdictPlic === 'INVALID' || (fin && fin.state === 'FAILED'); +process.exitCode = invalid ? 1 : nem ? 2 : 0; +if (nem && !invalid) console.log('UNMEASURED: ' + nem); +else console.log(invalid ? 'INVALID' : 'VALID' + (fin && fin.state === 'PASSED' ? ', notarized under a post-quantum certified anchor' : ''));