/* catcrafts.net Copyright (C) 2026 Catcrafts The source code of this website is made available for viewing purposes only. No permission is granted to copy, modify, distribute, or create derivative works. */ // The EURC rail against a REAL chain: a €1 donation paid in real (testnet) // EURC on Ethereum Sepolia, settled by the same balanceOf quorum the mainnet // rail runs. The fake-rail suites prove the order lifecycle; the unit suites prove // the decoding; what neither can prove is that the rail's actual RPC // conversation — eth_chainId verification, the ABI-encoded eth_call, two // endpoints corroborating a covering balance — works against nodes we do not // control. A mistake there costs real money on mainnet, which is why this // suite exists and why it is a MANDATORY deploy gate in CI, network flake and // all: a deploy that cannot prove the crypto rail settles is not a deploy // (the operator's call — caution over convenience). // // Locally it skips unless EURC_E2E_PRIVATE_KEY is exported, so a train ride // still runs the rest of the suites. In CI the same missing key is a FAILURE: // a vanished secret must never quietly turn the gate into a pass. // // What it needs (see deploy/README.md "Live payment suites in CI"): // EURC_E2E_PRIVATE_KEY an Ethereum Sepolia key holding testnet EURC // (Circle's faucet, network set to Ethereum Sepolia) // and some Sepolia ETH for gas (the pk910 PoW faucet // mines it, no account). Each run spends 1 EURC + gas. // cast Foundry's CLI, the only tool in a stock shell that // can sign an ERC-20 transfer. The server itself // deliberately cannot — that is the rail's design. // // The receiving pool is generated FRESH here, random addresses nobody holds a // key for. That is load-bearing, not laziness: CheckPaid compares the // address's TOTAL balance, so a reused address still holding last run's // 1 EURC would settle this run's order before any payment — a false pass. // The 1 EURC sent each run is stranded at the random address, which on a // testnet costs nothing. // // block_tag is "latest" rather than production's "finalized": Sepolia-family // finality is ~13 minutes, and a reorg un-paying a testnet donation is not a // risk worth a quarter-hour CI stall. The rail warns about it at load; that // warning appearing in this suite's server log is expected. import std; import Crafter.Network; import Catcrafts.E2eHarness; using namespace Catcrafts::E2e; namespace fs = std::filesystem; namespace { // Circle's EURC on Ethereum Sepolia. Verify only against // https://developers.circle.com/stablecoins/eurc-contract-addresses constexpr std::string_view kContract = "0x08210F9170F89Ab7658F0B5E3fF39b0E03C594D4"; constexpr std::string_view kChainId = "11155111"; // Two endpoints from different operators (Allnodes, 1RPC) — the same two // operators the production ethereum entry uses — because the rule under test // is "no single node's word settles an order": min_confirmations=2 below // makes both agree, same as the mainnet chains file. constexpr std::string_view kRpcPrimary = "https://ethereum-sepolia-rpc.publicnode.com"; constexpr std::string_view kRpcSecondary = "https://1rpc.io/sepolia"; // €1 donation = 100 cents = 100 × 10^(6-2) EURC base units. constexpr std::string_view kTransferUnits = "1000000"; bool IsCi() { for (const char* v : { "CI", "GITHUB_ACTIONS", "FORGEJO_ACTIONS" }) { if (const char* s = std::getenv(v); s && *s) return true; } return false; } // The order token from a checkout redirect's Location header — the EURC rail // has no hosted page, so the 303 lands back on the order page itself. std::string TokenOf(const Crafter::HTTPResponse& r) { const auto it = r.headers.find("location"); if (it == r.headers.end()) return {}; std::smatch m; if (std::regex_search(it->second, m, std::regex(R"(/order/([0-9a-f]{32})$)"))) { return m[1].str(); } return {}; } std::string RandomAddress(std::mt19937_64& rng) { static constexpr char hex[] = "0123456789abcdef"; std::string s = "0x"; for (int i = 0; i < 40; ++i) s += hex[rng() & 0xf]; return s; } std::string Trimmed(std::string s) { while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ')) { s.pop_back(); } return s; } } // namespace int main(int argc, char** argv) { if (argc < 2) { std::println(std::cerr, "usage: ShouldSettleEurcOnTestnet "); return 2; } const char* senderKey = std::getenv("EURC_E2E_PRIVATE_KEY"); if (!senderKey || !*senderKey) { if (IsCi()) { std::println(std::cerr, "FAIL: EURC_E2E_PRIVATE_KEY is not set. This suite is a mandatory " "deploy gate: add the secret in Forgejo (repo Settings -> Actions -> " "Secrets) — an Ethereum Sepolia key funded with testnet EURC and gas. " "See deploy/README.md \"Live payment suites in CI\"."); return 1; } std::println("ShouldSettleEurcOnTestnet: skipped — EURC_E2E_PRIVATE_KEY is not " "set (mandatory in CI, opt-in locally)"); return 0; } // The key never enters a command line this process builds: cast reads it // from the environment via shell expansion, so it cannot leak through a // logged command or the failure output below. if (std::system("command -v cast >/dev/null 2>&1") != 0) { std::println(std::cerr, "FAIL: EURC_E2E_PRIVATE_KEY is set but `cast` (Foundry) is not on PATH — " "the suite cannot sign the testnet transfer without it. CI installs it in " "the deploy workflow; locally: https://getfoundry.sh"); return 1; } // The suite's own scratch dir: the chains file and pool must exist before // the server spawns, so they cannot live in TestServer's work dir. std::random_device rd; std::mt19937_64 rng((static_cast(rd()) << 32) ^ rd()); const fs::path dir = fs::temp_directory_path() / std::format("catcrafts-eurc-testnet-{:016x}", rng()); std::error_code ec; fs::remove_all(dir, ec); fs::create_directories(dir); const fs::path chains = dir / "chains.json"; WriteFile(chains, std::format( R"({{"chains":[{{"name":"ethereum-sepolia",)" R"("rpcs":["{}","{}"],)" R"("min_confirmations":2,)" R"("contract":"{}","chain_id":{},)" R"("block_tag":"latest","note":"testnet"}}]}})", kRpcPrimary, kRpcSecondary, kContract, kChainId)); // Fresh random addresses — see the header for why reuse would false-pass. const fs::path pool = dir / "pool.txt"; { std::string lines; for (int i = 0; i < 3; ++i) lines += RandomAddress(rng) + "\n"; WriteFile(pool, lines); } ServerOptions options; // Bank slot on the fake rail so the donation form offers the choice; the // crypto slot is the real EURC rail pointed at the testnet chains file. options.extraArgs = { "--rail=fake", "--crypto-rail=eurc" }; options.env = { { "EURC_CHAINS", chains.string() }, { "EURC_POOL", pool.string() } }; TestServer srv(argv[1], 8218, options); // ── checkout: a €1 crypto donation ──────────────────────────────── const auto created = srv.Post("/shop/donation", "amount=1&pay=crypto"); const std::string token = TokenOf(created); Check(created.status == "303" && !token.empty(), "a €1 crypto donation 303s to its order page", created.status); if (token.empty()) { std::println(std::cerr, "server log:\n{}", ReadFile(srv.Work() / "server.log")); fs::remove_all(dir, ec); return Finish(); } const std::string orderPath = std::format("/order/{}", token); // The issued address, from the ledger — the same place the reconciler // reads it, so this is the address the rail is actually watching. std::string address; { const std::string ledger = srv.OrdersText(); std::smatch m; if (std::regex_search(ledger, m, std::regex( R"lit("id":")lit" + token + R"lit(".*?"pay_choice":"crypto".*?"pay_id":"(0x[0-9a-f]{40})@[0-9]+")lit"))) { address = m[1].str(); } Check(!address.empty(), "the ledger records a crypto order with an address@deadline pay id"); } if (address.empty()) { std::println(std::cerr, "ledger was:\n{}\nserver log:\n{}", srv.OrdersText(), ReadFile(srv.Work() / "server.log")); fs::remove_all(dir, ec); return Finish(); } std::println("issued receiving address: {}", address); // ── the order page asks for exactly the right payment ──────────── { const std::string page = srv.Body(orderPath); Check(page.find("Pay with EURC") != std::string::npos, "the order page renders the self-hosted payment instructions"); Check(page.find(address) != std::string::npos, "the order page shows the issued address"); // The EIP-681 wallet link carries the amount in base units — the // number a wrong scale would corrupt 10,000× in either direction. Check(page.find(std::format("uint256={}", kTransferUnits)) != std::string::npos, "the wallet link asks for €1 in EURC base units"); Check(page.find("ethereum-sepolia") != std::string::npos || page.find("Ethereum-sepolia") != std::string::npos, "the order page names the watched chain"); } // ── negative control: an unpaid order must not settle ──────────── // The reconciler has polled the real RPCs at least once by now (first // sweep lands ~1 s after the order); a zero balance must read as Pending. std::this_thread::sleep_for(std::chrono::seconds(3)); Check(srv.Body(orderPath).find("awaiting payment") != std::string::npos, "an unpaid order stays awaiting after a real RPC poll"); // ── the customer pays: 1 real EURC on Ethereum Sepolia ──────────── { const fs::path who = dir / "sender.txt"; std::system(std::format( "cast wallet address --private-key \"$EURC_E2E_PRIVATE_KEY\" > \"{}\" 2>&1", who.string()).c_str()); std::println("paying from: {}", Trimmed(ReadFile(who))); } const fs::path sendLog = dir / "cast-send.log"; const int sent = std::system(std::format( "cast send {} \"transfer(address,uint256)\" {} {} " "--rpc-url {} --private-key \"$EURC_E2E_PRIVATE_KEY\" > \"{}\" 2>&1", kContract, address, kTransferUnits, kRpcPrimary, sendLog.string()).c_str()); Check(sent == 0, "cast send transfers 1 testnet EURC and gets a receipt"); if (sent != 0) { std::println(std::cerr, "cast send failed — if it reports insufficient funds, top the sender " "up: EURC at https://faucet.circle.com (network: Ethereum Sepolia), " "gas ETH at https://sepolia-faucet.pk910.de. cast said:\n{}", ReadFile(sendLog)); fs::remove_all(dir, ec); return Finish(); } std::println("transfer confirmed on-chain; waiting for the rail to notice"); // ── the rail notices, both endpoints agreeing ───────────────────── // Cadence: the reconciler polls this order every 30 s (the rail's own // interval), and both endpoints must see the balance at "latest". 150 s // is five polls past the ~12 s block that included the transfer — roomy, // tight enough to fail a deploy that would leave real buyers unconfirmed. { const std::string page = srv.WaitForBody(orderPath, "Thank you", 600); Check(page.find("Thank you") != std::string::npos, "the paid donation page appears once the balance covers the order"); Check(page.find("Pay with EURC") == std::string::npos, "a settled order stops asking for money"); } { const std::string ledger = srv.OrdersText(); Check(ledger.find("\"status\":\"paid\"") != std::string::npos, "the paid transition reached the ledger"); // The via column must name the chain that settled it — the fact the // bookkeeping keeps about where the money lives. Check(ledger.find("\"via\":\"eurc-ethereum-sepolia\"") != std::string::npos, "the paid event records eurc-ethereum-sepolia as the method"); Check(ledger.find("\"type\":\"invoice\"") == std::string::npos, "a donation settles without an invoice"); } if (failures != 0) { std::println(std::cerr, "server log:\n{}", ReadFile(srv.Work() / "server.log")); } fs::remove_all(dir, ec); return Finish(); }