/* 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's address pool: which address a stranger is told to send money // to, and how many times that address may be told to anybody. // // There is no processor here to notice a mistake. Handing one address to two // orders means the second buyer's EURC settles the FIRST buyer's order while // the second lapses unpaid — real money at an address we control, and a // support ticket only a human can close. So the burn-before-handing-out order, // the persisted cursor, the resume after a restart, and every pool the rail // refuses to start with are all pinned here, alongside the EIP-681 amount the // buyer's wallet actually pre-fills. // // Nothing in this suite touches the network. The chains fixture points at // unreachable endpoints on purpose, and CheckPaid is exercised ONLY on payIds // that fail to split — the one branch that answers before an RPC is dialed. import std; import Catcrafts.Shared; import Catcrafts.Server; using namespace Catcrafts; namespace { int failures = 0; void Check(bool ok, std::string_view what, std::string_view got = {}) { if (ok) return; ++failures; std::println(std::cerr, "FAIL: {}{}{}", what, got.empty() ? "" : " got: ", got); } void WriteFile(const std::filesystem::path& p, std::string_view content) { std::ofstream(p, std::ios::binary) << content; } // "0x" + 40 hex digits, with a short tail naming the line it belongs to. Built // rather than typed out: a 40-character literal is exactly where a miscount // hides, and a pool line one digit short would exercise the refusal path // instead of whatever the assertion meant to prove. std::string Addr(std::string_view tail) { std::string s = "0x"; s.append(40 - tail.size(), '0'); s.append(tail); return s; } // The rail parks its high-water mark beside the pool, as ".cursor". std::filesystem::path CursorOf(const std::filesystem::path& pool) { return std::filesystem::path(pool.string() + ".cursor"); } std::optional CursorValue(const std::filesystem::path& pool) { std::ifstream in(CursorOf(pool), std::ios::binary); std::size_t v = 0; if (!(in >> v)) return std::nullopt; return v; } std::string Show(const std::optional& v) { return v ? std::to_string(*v) : std::string("(no cursor)"); } // The payId is "
@"; both halves are asserted // separately because they fail for different reasons. std::string AddressOf(std::string_view payId) { const std::size_t at = payId.rfind('@'); if (at == std::string_view::npos) return {}; return std::string(payId.substr(0, at)); } std::int64_t DeadlineOf(std::string_view payId) { const std::size_t at = payId.rfind('@'); if (at == std::string_view::npos) return 0; const std::string_view digits = payId.substr(at + 1); std::int64_t v = 0; const auto [ptr, ec] = std::from_chars(digits.data(), digits.data() + digits.size(), v); if (ec != std::errc{} || ptr != digits.data() + digits.size()) return 0; return v; } std::int64_t NowUnix() { return std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); } Server::RailConfig Config(const std::filesystem::path& chains, const std::filesystem::path& pool, int windowHours = 24) { return Server::RailConfig{ .mode = "eurc", .eurcChainsPath = chains, .eurcPoolPath = pool, .eurcWindowHours = windowHours }; } // Two chains on purpose. The first carries a chain_id, so it renders a wallet // link; the second omits it, so the "watched but not linkable" branch is real // rather than hypothetical. Both endpoints are unreachable by design — a // suite that accidentally dialed one would be a suite that fails on a train. constexpr std::string_view kChains = R"({"chains":[ {"name":"base","rpc":"https://rpc.invalid/base", "contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42", "decimals":6,"chain_id":8453,"note":"lowest fees"}, {"name":"quiet","rpc":"https://rpc.invalid/quiet", "contract":"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c"}]})"; // Whatever the checkout hands in as the redirect. This rail has no hosted // page of its own, so this exact string is what must come back out. const std::string kOrderPage = "https://catcrafts.net/order/tok"; } // namespace int main() { const std::filesystem::path root = std::filesystem::temp_directory_path() / "catcrafts-eurc-pool"; std::error_code ec; std::filesystem::remove_all(root, ec); std::filesystem::create_directories(root, ec); const std::filesystem::path chains = root / "chains.json"; WriteFile(chains, kChains); // ── one address, one order ──────────────────────────────────────── { const std::filesystem::path pool = root / "issue.txt"; // Line 1 is written in checksum case, the way a wallet exports it. // Everything downstream — the pool, the payId, the covering check — // compares the lowercase form, so that is what must come back out. WriteFile(pool, Addr("A1") + "\n" + Addr("b2") + "\n" + Addr("c3") + "\n"); std::unique_ptr rail = Server::MakeEurcRail(Config(chains, pool)); Check(rail != nullptr, "pool: a valid chains file and a valid pool load"); if (rail) { Check(rail->Name() == "eurc", "rail: names itself — the ledger via prefix"); Check(rail->PollInterval() == std::chrono::seconds(30), "rail: sweeps at a finality-shaped cadence, not a busy one"); const std::int64_t before = NowUnix(); const std::optional first = rail->CreateLink(57043, "desc", kOrderPage); const std::optional second = rail->CreateLink(57043, "desc", kOrderPage); Check(first.has_value() && second.has_value(), "issue: two checkouts each get a payment link"); if (first && second) { Check(AddressOf(first->payId) == Addr("a1"), "issue: the first order gets pool line 1, lowercased", first->payId); Check(AddressOf(second->payId) == Addr("b2"), "issue: the second order gets pool line 2", second->payId); // The assertion this whole rail exists to satisfy. Check(AddressOf(first->payId) != AddressOf(second->payId), "issue: never the same address twice"); // There is no hosted checkout to send the buyer to: the order // page IS the payment page, so the caller's own URL comes back. Check(first->payUrl == kOrderPage, "issue: no provider page — the redirect is handed back", first->payUrl); // 24 configured hours = 86400 seconds past the moment of issue. // Bracketed by readings taken either side of the call, so the // bound is exact rather than a tolerance that could drift. const std::int64_t deadline = DeadlineOf(first->payId); Check(deadline >= before + 86400 && deadline <= NowUnix() + 86400, "issue: the deadline is the moment of issue plus the window", std::to_string(deadline - before)); } // Refused before the pool is touched. A zero or negative total is a // caller bug, and burning an address for one would spend the single // resource this rail cannot regenerate on its own. Check(!rail->CreateLink(0, "desc", kOrderPage).has_value(), "issue: a zero total buys no address"); Check(!rail->CreateLink(-1, "desc", kOrderPage).has_value(), "issue: a negative total buys no address"); } // The cursor is on DISK, not merely in memory: it is the only thing // standing between a restart and republishing line 1 to a new buyer. Check(CursorValue(pool) == std::size_t{ 2 }, "cursor: two issued, two burned — and the refusals burned none", Show(CursorValue(pool))); // A fresh rail over the SAME two files must resume, never rewind. std::unique_ptr restarted = Server::MakeEurcRail(Config(chains, pool)); Check(restarted != nullptr, "restart: a partly spent pool still loads"); if (restarted) { const std::optional third = restarted->CreateLink(57043, "desc", kOrderPage); Check(third.has_value() && AddressOf(third->payId) == Addr("c3"), "restart: issues line 3, not line 1", third ? third->payId : std::string("no link")); } Check(CursorValue(pool) == std::size_t{ 3 }, "cursor: the restarted rail advanced the same file", Show(CursorValue(pool))); } // ── what the buyer's wallet is told to send ─────────────────────── { const std::filesystem::path pool = root / "instructions.txt"; WriteFile(pool, Addr("d1") + "\n" + Addr("d2") + "\n"); std::unique_ptr rail = Server::MakeEurcRail(Config(chains, pool)); Check(rail != nullptr, "instructions: rail loads"); if (rail) { const std::string payId = Addr("ab") + "@1800000000"; const std::optional ins = rail->Instructions(payId, 57043); Check(ins.has_value(), "instructions: a well-formed payId renders"); if (ins) { Check(ins->address == Addr("ab"), "instructions: the address half is where the money goes", ins->address); // EURC is euro-denominated at par, so the token figure IS the // euro figure: 57043 cents shown as 570.43. No rate, no quote. Check(ins->amount == "570.43", "instructions: the amount is the euro total at par", ins->amount); Check(ins->deadlineUnix == 1800000000, "instructions: the deadline travels inside the id"); Check(ins->chains.size() == 2, "instructions: every watched chain is offered, in file order", std::to_string(ins->chains.size())); } if (ins && ins->chains.size() == 2) { Check(ins->chains[0].name == "base" && ins->chains[0].note == "lowest fees", "instructions: the first chain is the file's recommendation"); Check(ins->chains[0].contract == "0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42", "instructions: contract lowercased for the buyer to compare", ins->chains[0].contract); // The uint256 is the literal quantity the wallet sends: // cents x 10^(decimals-2) = 57043 x 10^4 = 570430000 base // units. A wrong scale charges 10,000x too much or too little, // and the too-little case never satisfies the covering check in // CheckPaid — so the order lapses with real money already sat // at our address, which is the expensive direction. const std::string expected = "ethereum:0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42@8453" "/transfer?address=" + Addr("ab") + "&uint256=570430000"; Check(ins->chains[0].link == expected, "instructions: EIP-681 link, amount in token base units", ins->chains[0].link); // chain_id 0 means "watched, but we cannot name the network in // a wallet link". The chain is still LISTED so the buyer can // pay there by hand — an empty link, never a guessed one. Check(ins->chains[1].name == "quiet" && ins->chains[1].link.empty(), "instructions: a chain without a chain_id is listed, unlinked", ins->chains[1].link); } // The same id in checksum case resolves to the same address: what // is published, compared and paid is always the lowercase form. const std::optional upper = rail->Instructions(Addr("AB") + "@1800000000", 57043); Check(upper.has_value() && upper->address == Addr("ab"), "instructions: a mixed-case payId normalises to one address"); Check(!rail->Instructions(payId, 0).has_value(), "instructions: nothing to ask a buyer for at zero"); Check(!rail->Instructions("garbage", 57043).has_value(), "instructions: an id that does not split renders nothing"); } } // ── an id that cannot identify a payment ────────────────────────── // // These are the ONLY CheckPaid inputs this suite may use: each fails to // split, and SplitPayId runs before BalanceOf, so the answer arrives // without a single RPC. A truncated or hand-edited ledger line must lapse // its order — returning Pending here would leave it awaiting payment // forever, and treating it as an address worth polling would ask a chain // about a string we never issued. { const std::filesystem::path pool = root / "dead.txt"; WriteFile(pool, Addr("e1") + "\n"); std::unique_ptr rail = Server::MakeEurcRail(Config(chains, pool)); Check(rail != nullptr, "dead: rail loads"); if (rail) { for (const std::string& id : { std::string("not-an-id"), Addr("ab") + "@notanumber", std::string("0xdeadbeef@1800000000"), std::string("@1800000000"), Addr("ab") + "@" }) { const std::optional st = rail->CheckPaid(id, 57043); Check(st.has_value() && st->state == Server::PayState::Dead && st->method.empty(), "dead: a payId that does not split lapses the order", id); } } } // ── pools this shop refuses to start with ───────────────────────── // // Each of these is a STARTUP refusal rather than a runtime surprise. The // failure a buyer would otherwise meet lands at the one moment they are // already committed, so it is moved to the moment the operator is watching. { // Lines 2 and 4 are one address in two casings. A chain does not care // about checksum case either, so a "different" line here is the // address-reuse bug wearing a disguise. const std::filesystem::path dup = root / "dup.txt"; WriteFile(dup, Addr("11") + "\n" + Addr("AB") + "\n" + Addr("33") + "\n" + Addr("ab") + "\n"); Check(Server::MakeEurcRail(Config(chains, dup)) == nullptr, "refuse: a duplicate address, even in a different case"); // Fatal rather than skipped: a line that does not parse is as likely to // be a mangled good address as a stray note, and skipping it would // quietly shorten the list of places we can be paid. const std::filesystem::path bad = root / "bad.txt"; WriteFile(bad, Addr("11") + "\n0xdeadbeef\n" + Addr("33") + "\n"); Check(Server::MakeEurcRail(Config(chains, bad)) == nullptr, "refuse: a line that is not an address is fatal, never skipped"); const std::filesystem::path empty = root / "empty.txt"; WriteFile(empty, ""); Check(Server::MakeEurcRail(Config(chains, empty)) == nullptr, "refuse: an empty pool has nothing to hand out"); Check(Server::MakeEurcRail(Config(chains, root / "absent.txt")) == nullptr, "refuse: a pool file that does not exist"); const std::filesystem::path good = root / "good.txt"; WriteFile(good, Addr("f1") + "\n"); Check(Server::MakeEurcRail(Config(root / "absent.json", good)) == nullptr, "refuse: a chains file that does not exist"); // Strict about addresses, not about tidiness: the comment lines, blank // lines, indentation and CRLF a human produces while topping the pool // up from the wallet must not be mistaken for a bad pool. const std::filesystem::path messy = root / "messy.txt"; WriteFile(messy, "# topped up 2026-08-15 from the cold wallet\n" "\n" " " + Addr("d4") + " # first of the batch\n" + Addr("e5") + " \t\r\n" "\n"); std::unique_ptr tidy = Server::MakeEurcRail(Config(chains, messy)); Check(tidy != nullptr, "accept: comments, blank lines, indent and trailing CR"); if (tidy) { // Proves the stripping produced the ADDRESS and not the decoration // around it — a loader that stored " 0x…d4" would still "load". const std::optional link = tidy->CreateLink(1000, "desc", kOrderPage); Check(link.has_value() && AddressOf(link->payId) == Addr("d4"), "accept: the comment and the indent are stripped, not stored", link ? link->payId : std::string("no link")); } // Exhausted is a refusal, not a wrap-around. Wrapping would reissue // addresses already sitting in somebody's wallet app. const std::filesystem::path used = root / "used.txt"; WriteFile(used, Addr("21") + "\n" + Addr("22") + "\n"); WriteFile(CursorOf(used), "2\n"); Check(Server::MakeEurcRail(Config(chains, used)) == nullptr, "refuse: the cursor says every address in the pool is spent"); // An unreadable cursor reads as exhausted, never as zero. Rewinding to // the top of a pool whose head is already published is the duplicate // bug again, arriving through a corrupted file instead of a typo. const std::filesystem::path garbled = root / "garbled.txt"; WriteFile(garbled, Addr("31") + "\n" + Addr("32") + "\n"); WriteFile(CursorOf(garbled), "x"); Check(Server::MakeEurcRail(Config(chains, garbled)) == nullptr, "refuse: an unparseable cursor never rewinds to line 1"); } // ── the payment window ──────────────────────────────────────────── { const std::filesystem::path pool = root / "window.txt"; WriteFile(pool, Addr("91") + "\n" + Addr("92") + "\n"); // Zero hours is not "no window": there is no processor to expire // anything here, so an unset value has to mean the generous default // rather than a deadline that is already in the past at issue time. std::unique_ptr dflt = Server::MakeEurcRail(Config(chains, pool, 0)); Check(dflt != nullptr, "window: the default-window rail loads"); if (dflt) { const std::int64_t before = NowUnix(); const std::optional link = dflt->CreateLink(1000, "desc", kOrderPage); Check(link.has_value() && DeadlineOf(link->payId) >= before + 24 * 3600 && DeadlineOf(link->payId) <= NowUnix() + 24 * 3600, "window: an unset window is 24 hours, not zero"); } // The same pool, one address further along, with the hours set. std::unique_ptr hour = Server::MakeEurcRail(Config(chains, pool, 1)); Check(hour != nullptr, "window: a one-hour rail loads on the same pool"); if (hour) { const std::int64_t before = NowUnix(); const std::optional link = hour->CreateLink(1000, "desc", kOrderPage); Check(link.has_value() && DeadlineOf(link->payId) >= before + 3600 && DeadlineOf(link->payId) <= NowUnix() + 3600, "window: the configured hours are what the id carries"); } } std::filesystem::remove_all(root, ec); if (failures != 0) { std::println(std::cerr, "{} check(s) failed", failures); return 1; } return 0; }