Agora Pay is an early pilot of any-in → any-out settlement. The payer sends crypto they already hold. Pay in anything you hold, including USDT on any chain and XMR via a swap route. Merchants receive any asset on any chain the swap route supports; EU merchants: direct transfer in the merchant's asset or practice mode for every asset; real Exolix, MAYAChain and THORChain swaps are not available for EU yet (a MiCA-authorised partner is coming), and payouts exclude USDT (ESMA) and privacy coins like XMR (AMLR). Non-EU merchants: subject to local law and sanctions. Fiat bank payout via licensed partner is coming. The merchant receives on the payout rail they prefer. Merchants are all types: online shops, offline stores, and market stalls or street sellers. The merchant installs nothing. The primary way to pay is a QR or pay-link shown on a phone, not a website checkout the merchant must host.
The matcher shipped in this repository is one optional rail on Base Sepolia (test tokens, a read-only share link). It is not the product. Following this pilot does not require funding Base. Mainnet is not enabled. The merchant screen can print a market-stall sheet (#stall): a QR of that merchant’s https pay-link, the merchant name, and short Romanian and English instructions. That sheet does not move funds. Other payout rails and a live swap are still not what these screens do.
This page says only what the code in this repository does today. Each statement in the "What the code does today" table points to a test that checks it. Anything not in that table should be read as not done.
Nothing here handles real money. There is no server, no account system, no custody and no service run by the authors. Do not send real funds because of anything you read in this repository.
| # | What the code does | Checked by |
|---|---|---|
| 1 | The browser practice screens keep their data in the browser's localStorage (key agorapay-pilot-v1). If that key is empty and the previous key agoratap-demo-v1 still has a record, the app copies it once and then removes the old key. Its source contains no fetch, XHR, WebSocket or web3-library call, except ONE file, src/lib/liveFetch.ts, which sends read-only JSON-RPC POSTs (eth_blockNumber, eth_chainId, eth_getLogs, eth_getBlockByNumber, eth_getTransactionReceipt) to public Base Sepolia endpoints, and only when the user opens the live-test panel. The opt-in 'Pay for real' panel also calls the keyless Exolix, THORChain (Liquify gateway) and MAYAChain quote APIs through injected fetch. src/lib/walletPay.ts is the only file that hands a transaction to the payer's own wallet (EIP-1193 eth_sendTransaction, UniSat sendBitcoin); the wallet asks the payer to approve, and the app holds no keys. The panel tries https://sepolia.base.org first, then https://base-sepolia-rpc.publicnode.com. eth_chainId confirms chain 84532 and refuses chain 8453. (The installable-app service worker only caches the app's own files. The app has one outbound link, to GNU Taler's public demo; nothing is sent with it.) |
src/lib/readmeClaims.test.ts → "app source makes no direct network calls except the read-only RPC gateway; swap adapters get fetch injected", "the gateway only allows read-only RPC methods; only walletPay hands transactions to the payer wallet and no keys exist in app source" |
| 2 | src/lib/chainRequest.ts turns a euro price into an exact EURC or USDC amount on Base (EURC is the default, no exchange rate needed). |
src/lib/chainRequest.test.ts → "converts EUR to atomic USDC", "defaults to EURC: merchant is asked for the euro amount with no exchange rate" |
| 3 | It gives two open requests for the same price different exact amounts (base amount plus a tag of 1 to 9999 micro-units). | "gives two customers paying the same price different exact amounts" |
| 4 | It builds an EIP-681 payment link addressed to the merchant's own address. | "builds an EIP-681 USDC transfer URI with chain id" |
| 5 | Given a list of transfers that you supply, it reports matched, unpaid or ambiguous by exact amount and recipient. Two payments of the same amount are reported as ambiguous, never as paid. |
"tells which payment belongs to which order", "flags a duplicate payment of the exact same amount as ambiguous, never as paid", "ignores payments to other addresses" |
| 6 | An offer can list several accepted assets. Paying in two different assets is reported as ambiguous. The code does no conversion. | "offer: merchant accepts several assets, buyer pays in the one they hold, no conversion by us", "offer: payment in two different assets is ambiguous, not paid" |
| 7 | It rejects invalid addresses and zero or negative amounts. | "rejects bad addresses and amounts", "offer: rejects empty or duplicated asset lists" |
| 8 | chainRequest.ts has no private key, no seed phrase, no fee and no network call. |
src/lib/readmeClaims.test.ts → "chainRequest has no keys, no fee and no network access" |
| 9 | The session report export is built in the browser, is labelled as demo data, and carries no free-text or personal fields. | src/lib/pilotReport.test.ts → "includes the snapshot, completion, generated-at time and demo label", "contains no fields beyond the local snapshot, friction capture and demo metadata" |
| 10 | src/lib/screening.ts compares an address to a snapshot of the public OFAC SDN digital-currency addresses (src/data/ofacAddresses.json, 499 addresses, downloaded 2026-10-02) and returns listed, not-listed, list-not-loaded or empty. EVM addresses match in any letter case. An empty list says list not loaded, never clean. It does no network call. screenAddressAll also checks a second snapshot: the blockchain wallet addresses written in the public EU consolidated financial sanctions list (src/data/euSanctionsAddresses.json, 9 addresses from 2 EU entries, Garantex and Grinex, EU file of 2026-10-15, downloaded 2026-10-18). EU names and entities are not screened. A missing EU list also says list not loaded. It also checks a third snapshot: the crypto addresses written in the public UK Sanctions List (FCDO, which includes the UN designations the UK implements; src/data/ukSanctionsAddresses.json, 59 addresses from 6 UK entries, UK report of 2026-10-16, downloaded 2026-10-21; 35 of them are in neither the OFAC nor the EU snapshot). UK names and entities are not screened. A missing UK list also says list not loaded. |
src/lib/ukSanctions.test.ts → "warns on an address that is only on the UK list and names the UK list", "says not on the UK snapshot for a clean address, and list not loaded if the UK list is missing"; src/lib/euSanctions.test.ts → "warns on an address that is only on the EU list, in any letter case, and names the EU list", "reports list not loaded if the EU list is missing, even when OFAC is loaded"; src/lib/screening.test.ts → "warns when an address is on the OFAC list, in any letter case", "passes an address that is not on the list, and says only that it is not on this snapshot", "reports list not loaded for an empty list instead of calling the address clean" |
| 11 | The merchant New sale screen has an optional address field that shows a visible warning when the address is on the snapshot. It only warns: nothing is disabled or blocked, and the address is not sent or stored. |
src/AddressCheck.test.ts → "renders a visible warning, not a block, for a listed address", "renders list not loaded when the list is empty and never says not on the snapshot" |
| 12 | scripts/update-ofac.mjs is a hand-run maintenance script (not part of the app) that downloads the public OFAC list and extracts its Digital Currency Address entries into the snapshot file. scripts/update-eu-sanctions.mjs does the same for the EU FSF CSV: the EU file has no crypto field, so it extracts only addresses written after a "wallet addresses" remark (and rejoins one address the EU split across two lines). scripts/update-uk-sanctions.mjs does the same for the UK Sanctions List CSV: the UK writes addresses in free text, sometimes glued together, so it scans for address shapes and keeps a Bitcoin or Tron candidate only if its base58check or bech32 checksum is valid. If the download fails or the result is implausibly small, the last good snapshot is kept. |
src/lib/euSanctions.test.ts → "extracts wallet addresses from EU remark text, keeps chain labels and lowercases 0x and bech32", "joins a Tron address the EU split across two lines, and ignores text outside a wallet-address remark"; src/lib/ofacParse.test.ts → "extracts Digital Currency Address entries, lowercases 0x and bech32, keeps base58 exact" |
| 13 | src/lib/chainReader.ts reads ERC-20 Transfer logs for one token and one recipient through an injected fetch, splits too-wide ranges, drops removed logs, rejects logs from another contract/recipient/range, and stops at a call budget. A bound transaction is read with eth_getTransactionReceipt, keeping only that token's Transfer logs. It sends only read-only RPC methods. With a Base Sepolia URL list, a timeout, network error, or HTTP 429/5xx after that URL's retries tries the next URL. A JSON-RPC error stays on the URL that returned it. A mainnet URL in the list is refused before any call. If every URL fails, the error is a fixed sentence and mainnet is not contacted. The panel reader also checks eth_chainId is 84532. |
src/lib/chainReader.test.ts → "asks the node for ONLY this token, Transfer events and this recipient (filtering happens at the node)", "sends only read-only RPC methods, ever", "throws when the node returns a log for another recipient or outside the range (never trusts the node blindly)", "stops at the call budget instead of hammering a free endpoint", "reads one transaction receipt and keeps only that token Transfer logs", "tries the public fallback when the primary Base Sepolia endpoint times out", "says every public endpoint failed and does not call a third host", "refuses a mainnet URL in the Sepolia fallback list before any call", "does not switch endpoints on a JSON-RPC error", "panel reader confirms chain id 84532 and refuses a mainnet chain id" |
| 14 | src/lib/confirmation.ts turns a request, its transfers and the chain head into one state: unpaid, pending, confirmed (with a final flag only if the node reports a finalized block), ambiguous, reorged or unknown. A transfer seen earlier that disappears is reorged; a head that moves backwards is unknown; two matching transfers are ambiguous. A bound transaction hash overrides that amount rule (row 17). |
src/lib/confirmation.test.ts → "pending until N confirmations (the block itself counts as 1), then confirmed", "REORG: a transfer seen earlier that vanished is reported, never silently unpaid", "two matching transfers are ambiguous, even if both are deep: never paid", "unknown (never a guess) when the head goes backwards compared to the last look" |
| 15 | src/lib/tagging.ts draws the per-request amount tag at random (not smallest-free) and excludes tags already used by open requests and by transfers the merchant already received on chain. |
src/lib/tagging.test.ts → "is not guessable: the first request is NOT always base+1 (unlike smallest-free-tag)", "COLLISION: 2,000 requests at one price and merchant never share an amount", "never reuses an amount the merchant already received on chain (history)" |
| 16 | The merchant New sale screen and the buyer wallet screen each have a clearly labelled live test panel (the optional Base Sepolia matcher; mainnet links are refused) wired to the modules above. The merchant panel creates a request and shows its state; the buyer panel parses a pasted payment link and shows what it asks. The buyer wallet button uses the plain transfer link, without the reference fragment. Neither signs, sends or holds anything. |
src/lib/liveSession.test.ts → "refuses mainnet unless explicitly allowed", "opens at the head, avoids amounts the merchant already received, then goes unpaid -> pending -> confirmed(final)", "detects a reorg between two polls"; src/lib/requestLink.test.ts → "rejects other tokens, chains, extra parameters, zero amounts and junk"; src/LivePanels.test.ts → "merchant panel is labelled testnet read-only", "buyer panel refuses a mainnet link", "buyer wallet link is a plain transfer and the reference stays on the share link" |
| 17 | A live sale embeds a stable reference in the share link (#ref= plus 32 bytes). The wallet link stays a plain EIP-681 transfer, so the reference is not a transfer argument. Matching uses the transaction hash bound to that reference: that transfer is selected even when another transfer has the same amount. With no hash, the amount tag is only a secondary signal, and two equal amounts stay ambiguous. |
src/lib/reference.test.ts → "embeds a stable reference in the share link and keeps the wallet link a plain EIP-681 transfer"; src/lib/confirmation.test.ts → "primary match is the transaction reference: the same amount twice is ambiguous until the tx hash selects one"; src/lib/referenceMatch.integration.test.ts → "injected chain: reference match goes unpaid, then pending, then confirmed while a second identical amount is ignored" |
| 18 | A live merchant sale (reference, receiving address, exact amount, block window, and any bound transaction hash) can be exported or imported as JSON (agorapay.merchant-session version 1) and saved on this device. The file has no wallet key. Import refuses Base mainnet, refuses key material, and refuses a share link that does not match the sale. The header reset clears the on-device copy. |
src/lib/merchantSession.test.ts → "round-trips a Sepolia sale to JSON without keys and restores the reference and window", "refuses a mainnet sale and a backup that contains key material", "refuses a backup whose share link does not match the sale", "IndexedDB store saves, reloads and clears the same backup JSON", "a device clear wins over a load that has not finished" |
| 19 | The merchant live panel has a Missed payment? check. Paste the sale link and a transaction hash. It uses the saved block window and the same reference match as the live panels. If that window is not on the device, it does not decide the payment and does not read the chain. A Base mainnet link is refused before any chain read. A match still does not prove who paid. | src/lib/missedPayment.test.ts → "checks a pasted hash against the saved window and ignores a second equal amount", "does not decide the payment when the block window is missing, and does not read the chain", "refuses a mainnet link before any chain read"; src/LivePanels.test.ts → "merchant panel offers a device backup and a missed-payment check" |
| 20 | The merchant screen links to #stall, a print-friendly sheet (@media print) with the merchant name, a large QR of that merchant’s https pay-link, and the lines “Scanează și plătește cu ce crypto ai” and “Scan and pay with any crypto you hold”. The sheet names USDC, EURC, BTC and ETH and does not name USDT. It keeps any-in → any-out framing and calls Base an optional rail. It does not move funds. |
src/StallSheet.test.ts → "prints a market-stall sheet with a pay-link QR, bilingual instructions, and no USDT" |
| 21 | An EU merchant cannot place a real Exolix order or a MAYAChain/THORChain route: those calls are refused before any provider request. Direct transfer to the merchant's own address stays available, and practice mode stays available for every asset. Swap options stay visible and are labelled not available for EU yet. A non-EU merchant can still create an Exolix order and a MAYAChain or THORChain route. The practice receipt labels the network fee as simulated. | src/lib/euRealSwap.test.ts → "refuses an Exolix order and a THOR or MAYA route for an EU merchant before any provider call", "still creates an Exolix order and a THOR or MAYA route for a non-EU merchant"; src/lib/publicCopy.test.ts → "public copy avoids the banned non-EU wording and does not hard-code a Network fee euro amount" |
Run all checks: npm ci && npm test.
- The practice flows (balances, quotes, tap, receipts) are still fictional. Only the two "live test" panels (row 16), including the missed-payment check (row 19), touch the chain, on the optional Base Sepolia rail, read-only. Real mainnet use is switched off in the screens. The market-stall sheet (row 20) prints a QR of an https pay-link. It does not run a live any-in → any-out swap, and it does not settle other payout rails.
- The Base Sepolia matcher is one rail. It does not define Agora Pay, and this pilot does not ask anyone to fund Base.
- The sale backup is readable JSON on the device. It is not encrypted, not signed, and not a wallet backup. The fictional practice balances stay in
localStorage. - The chain reader has been run once against the real public Base mainnet endpoint (see
docs/evidence/), never against a real merchant payment. The live panels tryhttps://sepolia.base.orgfirst, thenhttps://base-sepolia-rpc.publicnode.com. Each URL gets the reader's HTTP 429/5xx retries (the panels use one retry and an 8 second timeout, then the next URL). If both fail, the screen shows a fixed message and does not call mainnet. That is still two public endpoints: they rate-limit, they see the user's IP address, and a lying node that reports chain id 84532 is not detected. The Sepolia harness still talks only tohttps://sepolia.base.org. The historical mainnet probe is a separate read-only script, not the pages app. - Confirmation count (12 blocks, about 24 s on Base) is a chosen default, not a measured safe value. "final" is only as good as the node's
finalizedanswer. - The amount tag does not tell who paid and cannot stop a third party from sending the exact amount (see
docs/MATCHING_DESIGN.md). Binding a transaction hash to the payment reference selects one transfer when amounts collide. It still does not prove who paid: anyone who knows the hash can paste it. EIP-3009 and a router contract are described there and are not built. - Matching can be fooled: transfers on a public chain can be made by anyone, including a third party sending the same amount or pasting that transaction hash. Do not treat a match as proof of payment from a particular person.
- Sanctions screening is a warning only, from a snapshot. The list is a static copy of the OFAC SDN file taken on 2026-10-02 and committed to the repository; the app never downloads it. It may be incomplete or outdated and is only refreshed when someone runs
node scripts/update-ofac.mjsand commits the result. "Not on the list" is not a statement that an address is lawful or safe. Covered: OFAC SDN digital-currency addresses, plus the 9 wallet addresses written in the EU consolidated financial sanctions list (EU file of 2026-10-15, refreshed only when someone runsnode scripts/update-eu-sanctions.mjsand commits the result). Also covered: the 59 crypto addresses written in the UK Sanctions List (UK report of 2026-10-16, refreshed only when someone runsnode scripts/update-uk-sanctions.mjsand commits the result); it includes UN designations the UK implements, but the UN list itself is not read. Not covered: EU and UK names and entities (most listings have no crypto address), the UN consolidated list directly, and hack-address lists. - Screening matches by exact address. The snapshot has 499 addresses, of which 91 are in
0x(EVM) format (listed by OFAC as ETH, USDT, USDC or ETC). The rest are other chains (mostly Bitcoin and Tron, plus a few others such as Litecoin, Monero and Solana). The screening code compares any string exactly, but addresses of other chains are only matched if you type them in the check field; the payment code itself accepts only0xaddresses on Base. Whether an EVM address that is listed on another EVM chain is also risky on Base is not decided by this code: it simply matches the address. - Screening is not wired into
chainRequest.ts. The check field in the merchant screen is separate from the fictional demo sale, and no payer address is read from the chain (see above), so nothing checks a real payer automatically. - No unlinkable payments, no tokens, no GNU Taler integration. The text in the demo that mentions unlinkable payments describes a design goal that is not implemented.
- No custody, issuance, exchange, fiat settlement or bank connection of any kind.
- Payers can pay in anything, incl. USDT on any chain and XMR, in practice mode. For a real payment, EU merchants take a direct transfer in the merchant's asset; real Exolix, MAYAChain and THORChain swaps are not available for EU yet (a MiCA-authorised partner is coming). EU payouts exclude USDT (ESMA opinion 8 Oct 2026) and privacy coins like XMR (EU AMLR, from July 2027). Non-EU merchants: subject to local law and sanctions. Fiat bank payout via licensed partner: coming.
- No audit, no security review, no legal review. No lawyer has signed off on anything here.
- The authors have not chosen how, or whether, this project will make money.
No licence is granted at the moment. The code is visible but all rights are reserved until a licence is chosen. If you want to use it, ask first.
This is software under development, not a payment service, and not legal, tax or financial advice. Whether and how any such software may be offered depends on the law where it is used. That question is open and is being worked on with counsel; this repository claims no answer to it.
Requirements: Node.js 22+ and npm.
npm ci
npm run dev # open the URL Vite prints
npm test # includes the Sepolia harness guards and the offline reference replay
npm run build
npm run sepolia:reference-e2enpm run sepolia:reference-e2e is a hand-run Base Sepolia script, not part of the app. With no payer key, or with a key that lacks test EURC or Base Sepolia ETH, it prints SKIP and exits 0. It does not create a key, does not write evidence, and does not send a transaction. CI runs it that way. A key is created only when SEPOLIA_E2E_CREATE_KEY=1. SEPOLIA_E2E_RECORD_BLOCKED=1 writes a blocked run.json and exits 2. The key stays outside the repo. The script refuses Base mainnet.
Practice-screen state stays in the browser's localStorage. Live sales stay in IndexedDB and can be exported as JSON. The reset icon clears both. Nothing is uploaded.
- docs/PILOT_ONBOARDING.md: the product is any-in → any-out for every kind of merchant. The Base Sepolia steps are an optional matcher walkthrough. Mainnet stays off.
- docs/CORE_RULE.md: the rules that protect the project's core.
- docs/REGULATORY_BOUNDARIES.md, docs/ARCHITECTURE.md: current design notes (rewritten for the non-custodial model; still working documents, not statements of legal position).
- docs/ESMA_USDT_EU_FOLD_2026-10-11.md: Pay-in includes USDT on any chain. EU merchants: direct transfer or practice mode; real swaps are not available for EU yet; payouts exclude USDT and privacy coins like XMR. Non-EU merchants: subject to local law and sanctions. Product copy only; Tom Hagen owns the legal read.
- SECURITY.md
Non-EU merchants: subject to local law and sanctions. For a non-EU merchant, the Review screen can create a real, non-custodial swap order through the keyless public Exolix API (src/lib/exolix.ts): live float rate, deposit address + exact amount + QR, status polling every 15 s. Destination is the merchant payout address. MAYAChain and THORChain native swaps stay available. Agora Pay never holds funds or keys. Practice mode (no funds) works for every crypto asset.
EU merchants: the app does not create an Exolix order and does not request a MAYAChain or THORChain route. Those options stay on the Review screen, disabled, labelled not available for EU yet. An EU payer can pay by direct transfer (the merchant's chosen asset, same chain, sent to the merchant's own address) or use practice mode for every asset. If the chosen asset would need a swap, the screen says: "Swap for EU merchants via a MiCA-authorised partner — coming. Pay directly in or use practice mode." EU payouts still exclude USDT and privacy coins. Exolix lists no EURC.
For a non-EU merchant, routes are quoted in parallel and ranked by lowest minimum: direct transfer (payer asset == payout asset, same chain: no swap, no minimum), MAYAChain and THORChain native swaps (keyless public nodes, minimums from about $1 to $24; the payer wallet must attach the memo shown), then Exolix (about $48 minimum, no memo). See src/lib/routes.ts. For an EU merchant only the direct transfer is executable; MAYAChain, THORChain and Exolix are refused in src/lib/routes.ts and src/lib/exolix.ts before any provider call. SideShift needs a registered affiliateId; StealthEX, LetsExchange, SimpleSwap, Swapzone and ChangeHero need API keys.

