/* 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 payment rail — the crypto half of the checkout, with no processor. // // Accepting crypto for goods makes this shop a MERCHANT and not a crypto-asset // service provider, with or without a processor in between, so the licence was // never the question; what a processor would buy is EUR settlement, and what it // would cost is a KYB gate standing between the shop and its own checkout. // Here nobody sits in the payment path at all: the buyer sends EURC to an // address this shop already owns, and the server's only role is to notice. // // Why EURC and not a coin: EURC is euro-denominated at par, so there is no rate // to quote, no quote to expire, no revaluation at year end, and no exchange-rate // line in the books. €573.80 owed is 573800000 EURC base units owed, forever. // That collapses the entire pricing problem to integer arithmetic, which is the // same arithmetic every other amount in this codebase already uses. // // Why an address POOL and not xpub derivation. Deriving addresses on demand // would need BIP32, secp256k1 and Keccak-256 in this process, and would put an // extended public key on the internet-facing box. A pool needs none of it: the // addresses are generated once, offline, by the wallet that holds the keys, and // arrive here as a plain list. This process can therefore only ever LEARN an // address it was given — it cannot derive the next one, cannot recognise a // sibling, and has nothing on disk that is worth stealing. It is the same rule // the bunq key follows, taken one step further. // // Why balanceOf and not log scanning. One eth_call answers "how much EURC does // this address hold", which is the entire question. Asking it AT A FINALIZED // BLOCK makes reorg handling somebody else's problem rather than a confirmation // counter this code would have to get right. The function selector is the first // four bytes of keccak256("balanceOf(address)") — a constant since 2015, spelled // out below, which is why no Keccak implementation is needed here either. // // Why one address covers several chains. An EVM address is derived from a public // key and is not chain-specific, so the SAME address is valid on Ethereum, Base // and Avalanche at once. One assignment therefore covers every chain we watch, // the buyer pays on whichever is cheapest for them, and the classic "sent it on // the wrong network" support ticket becomes a payment we were watching for // anyway. The chain that settles it is recorded as the ledger's via column // ("eurc-base"), because which chain the money arrived on is a fact worth // keeping. // // Trust direction is unchanged and, for once, trivially so: there is no provider // to send a callback, so there is nothing to ignore. An order becomes paid when // an RPC we chose to call reports a covering balance at a finalized block. // // ONE HAZARD A PROCESSOR WOULD NOT HAVE, stated plainly because it will // eventually happen: Dead here does NOT mean the money bounced. A processor's // invoice that expires is dead in the sense that no money can arrive against it. // An address is ours forever, so a buyer who pays after the window still sends // real EURC to a real address we control. The order lapses; the money arrives // regardless. That is why lapsing logs the address rather than dropping it, why // the address stays bound to the order in the ledger, and why the window // defaults to a generous 24 hours instead of a processor's twenty minutes — // there is no cost to waiting when the destination is our own wallet. module; // The one place this codebase reaches past the standard library: durability. // std::ofstream::flush() reaches the kernel, not the disk, and there is no // portable "make this actually persistent" in C++ — so the cursor write below // needs fsync(2), and fsync needs a file descriptor. Included in the global // module fragment, which is what a module unit has instead of plain includes. #include #include module Catcrafts.Server; import std; import Catcrafts.Shared; import Crafter.Network; using namespace Crafter; namespace Catcrafts::Server { namespace { // Flush one path all the way to the platter (or the drive's cache, which is as // far as fsync promises). Files and directories both, because a durable rename // needs the directory synced too, and only the directory case may be opened // read-only. bool FsyncPath(const std::filesystem::path& path, bool isDirectory) { const int fd = ::open(path.c_str(), isDirectory ? (O_RDONLY | O_DIRECTORY) : O_WRONLY); if (fd < 0) return false; const int rc = ::fsync(fd); // Report the fsync's verdict, not the close's, but still close: leaking a // descriptor per issued address would outlast any single order. const bool ok = rc == 0; ::close(fd); return ok; } } // namespace namespace { // keccak256("balanceOf(address)")[0..4). A constant of the ERC-20 ABI, not a // value we compute — which is the whole reason this unit needs no Keccak. constexpr std::string_view kBalanceOfSelector = "0x70a08231"; // EURC carries 6 decimals on every chain Circle deploys it to. Amounts in this // codebase are EUR cents (2 decimals), so a covering balance is // cents * 10^(decimals-2). Kept per chain anyway: a future token with a // different scale should be a config line, not a patch. constexpr int kDefaultDecimals = 6; bool IsHexDigit(char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); } std::string LowerAscii(std::string_view s) { std::string out(s); for (char& c : out) { if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); } return out; } // "0x" followed by exactly 40 hex digits. Deliberately NOT an EIP-55 checksum // check: verifying the mixed-case checksum would need Keccak, which this unit // does not carry. The consequence is operational and is documented at the pool // loader — addresses must be COPIED from the wallet that generated them, never // retyped, because a typo that stays hex will not be caught here. bool IsAddress(std::string_view s) { if (s.size() != 42) return false; if (s[0] != '0' || (s[1] != 'x' && s[1] != 'X')) return false; for (std::size_t i = 2; i < s.size(); ++i) { if (!IsHexDigit(s[i])) return false; } return true; } // Split an RPC endpoint into the pieces Crafter::ClientHTTP1 wants. Plain http // is accepted so a node on the home LAN can be used later without a certificate; // anything else is a configuration error rather than a silent default. struct Endpoint { std::string host; std::string path = "/"; std::uint16_t port = 443; bool tls = true; }; std::optional ParseEndpoint(std::string_view url) { Endpoint ep; if (url.starts_with("https://")) { url.remove_prefix(8); } else if (url.starts_with("http://")) { ep.tls = false; ep.port = 80; url.remove_prefix(7); } else { return std::nullopt; } if (url.empty()) return std::nullopt; const std::size_t slash = url.find('/'); std::string_view authority = slash == std::string_view::npos ? url : url.substr(0, slash); if (slash != std::string_view::npos) ep.path = std::string(url.substr(slash)); if (authority.empty()) return std::nullopt; // A colon here is a port, not IPv6-in-a-URL: those are bracketed, and an // RPC endpoint spelled with a bare IPv6 literal is not a case worth // guessing at. if (const std::size_t colon = authority.rfind(':'); colon != std::string_view::npos) { std::uint32_t parsed = 0; const std::string_view digits = authority.substr(colon + 1); const auto [ptr, ec] = std::from_chars(digits.data(), digits.data() + digits.size(), parsed); if (ec != std::errc{} || ptr != digits.data() + digits.size() || parsed == 0 || parsed > 65535) { return std::nullopt; } ep.port = static_cast(parsed); authority = authority.substr(0, colon); } if (authority.empty()) return std::nullopt; ep.host = std::string(authority); return ep; } // 10^n as an integer, saturating rather than wrapping. n is small and config- // bounded, but this is money arithmetic and a silent wrap is the wrong failure. std::optional Pow10(int n) { if (n < 0 || n > 18) return std::nullopt; std::int64_t out = 1; for (int i = 0; i < n; ++i) out *= 10; return out; } } // namespace // A 32-byte uint256 hex word, as eth_call returns it, reduced to an int64. // A value that does not fit is nullopt ("could not determine"), never a // saturated maximum: int64 base units is already far past EURC's whole supply, // so anything bigger is a broken or hostile node rather than a large balance, // and the one thing it must not do is satisfy the covering comparison. // Exported so the self-test can drive it with canned RPC bodies, the same way // ParseMolliePayment is driven — the HTTP around it is thin, the decoding is // where a mistake would cost money. // True when the reply carries exactly the numeric id we sent. Absent or // non-numeric is false: an answer that will not say which question it belongs // to is not evidence about a balance. bool JsonRpcIdIs(std::string_view json, std::int64_t want) { auto doc = Json::Parse(json); if (!doc || !doc->IsObject()) return false; const Json::Value* id = doc->Find("id"); if (!id || id->type != Json::Type::Number) return false; return static_cast(id->number) == want; } std::optional ParseEthCallUint(std::string_view json) { auto doc = Json::Parse(json); if (!doc || !doc->IsObject()) return std::nullopt; // A JSON-RPC error is a real answer and must not read as a zero balance: // "the node refused" and "the buyer has not paid" are different facts and // only one of them should ever lapse an order. if (const Json::Value* err = doc->Find("error"); err && err->type != Json::Type::Null) { return std::nullopt; } const Json::Value* res = doc->Find("result"); if (!res || res->type != Json::Type::String) return std::nullopt; std::string_view hex = res->string; if (!hex.starts_with("0x") && !hex.starts_with("0X")) return std::nullopt; hex.remove_prefix(2); if (hex.empty() || hex.size() > 64) return std::nullopt; std::int64_t out = 0; for (const char c : hex) { if (!IsHexDigit(c)) return std::nullopt; int digit = 0; if (c >= '0' && c <= '9') digit = c - '0'; else if (c >= 'a' && c <= 'f') digit = c - 'a' + 10; else digit = c - 'A' + 10; // A value too large for int64 is not a rich buyer, it is a broken or // lying node, and it must NOT read as "covers the invoice". // // int64 base units at six decimals is nine trillion EURC — orders of // magnitude past the token's entire supply, so no honest balanceOf can // reach here. This used to saturate to INT64_MAX, which then satisfied // every >= comparison downstream: a node answering 0xffff…ff marked // any order paid. nullopt is the honest answer ("could not determine, // retry"), and it is the safe one — an unknown never settles an order // and never lapses one. if (out > (std::numeric_limits::max() - digit) / 16) { std::println(std::cerr, "eurc: a node returned a balance too large to be real " "({} hex digits) — treating it as unknown, not as paid", hex.size()); return std::nullopt; } out = out * 16 + digit; } return out; } // Parse the chains file. Refuses partial success on purpose: a chain list where // one entry silently dropped is a shop that quietly stops noticing payments on // that chain, which is indistinguishable from a buyer who never paid. std::optional> ParseEurcChains(std::string_view json) { auto doc = Json::Parse(json); if (!doc || !doc->IsObject()) return std::nullopt; const Json::Value* arr = doc->Find("chains"); if (!arr || !arr->IsArray()) return std::nullopt; std::vector out; for (const Json::Value& v : arr->array) { if (!v.IsObject()) return std::nullopt; EurcChain c; c.name = std::string(v.Str("name")); c.rpcUrl = std::string(v.Str("rpc")); c.contract = LowerAscii(v.Str("contract")); c.blockTag = std::string(v.Str("block_tag", "finalized")); if (const Json::Value* d = v.Find("decimals"); d && d->type == Json::Type::Number) { c.decimals = static_cast(d->number); } if (const Json::Value* d = v.Find("chain_id"); d && d->type == Json::Type::Number) { c.chainId = static_cast(d->number); } c.note = std::string(v.Str("note")); if (c.chainId < 0) return std::nullopt; if (c.name.empty() || c.rpcUrl.empty()) return std::nullopt; if (!IsAddress(c.contract)) return std::nullopt; if (!ParseEndpoint(c.rpcUrl)) return std::nullopt; // 2 is the floor because amounts arrive as cents; anything below it // cannot represent the invoice at all. The ceiling is NOT 18 (the ERC-20 // maximum) but what the arithmetic can actually carry: RequiredUnits // multiplies cents by 10^(decimals-2), so at 18 decimals any invoice // over €9.22 overflows int64 and returns nullopt — and nullopt means // "unknown, retry", so the order would never settle AND never lapse, // silently, forever. A limit the maths cannot honour is not a limit. // 12 leaves room for every invoice this shop can issue (10^10 cents, // a hundred million euro) against every real EURC deployment, which is // 6 everywhere Circle has issued it. if (c.decimals < 2 || c.decimals > 12) return std::nullopt; // The block tag is interpolated into the eth_call params array, so it // is the one field that must be an allowlist rather than a shape check. // Left unvalidated it took anything: a typo silenced the chain // permanently (an unknown tag makes every call fail, which is nullopt // forever — the same never-settles-never-lapses trap as above), and a // value containing a quote closed the JSON string and appended further // params, reaching the state-override slot on nodes that implement it. static constexpr std::string_view kTags[] = { "finalized", "safe", "latest", "earliest", "pending" }; const bool namedTag = std::ranges::find(kTags, c.blockTag) != std::end(kTags); // A specific block number is legitimate and is hex-quantity shaped. const bool hexTag = c.blockTag.size() > 2 && c.blockTag.size() <= 18 && c.blockTag.starts_with("0x") && std::ranges::all_of( std::string_view(c.blockTag).substr(2), [](unsigned char ch) { return std::isxdigit(ch) != 0; }); if (!namedTag && !hexTag) return std::nullopt; // "latest" is accepted but is a foot-gun worth naming: it reports state // that a reorg can still take back. if (c.blockTag == "latest") { std::println(std::cerr, "eurc: chain '{}' watches block_tag=latest — a reorg can " "un-pay a settled order; prefer 'finalized'", c.name); } // Circle's own EURC deployments, compiled in. NOT a refusal: Circle can // deploy to a new chain, and a shop that cannot be pointed at one until // this file is edited is worse than one that warns. But a contract that // merely LOOKS like an address is otherwise checked by nobody — // IsAddress accepts any 40 hex digits, EIP-55 is deliberately not // verified, and asking balanceOf of the wrong token means a dust // balance of something else can cover an invoice. So when the chain is // one we know, say so loudly. struct KnownContract { std::string_view chain; std::string_view contract; }; static constexpr KnownContract kCircle[] = { { "base", "0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42" }, { "ethereum", "0x1abaea1f7c830bd89acc67ec4af516284b1bc33c" }, }; for (const KnownContract& known : kCircle) { if (known.chain == c.name && known.contract != c.contract) { std::println(std::cerr, "eurc: WARNING: chain '{}' points at contract {} but " "Circle's EURC on that chain is {} — a wrong contract " "means watching the wrong token. Verify against " "developers.circle.com/stablecoins/eurc-contract-addresses", c.name, c.contract, known.contract); } } // Two chains sharing a name is not a naming nit: the HTTP clients are // held in a map keyed by name, so the second entry silently reuses the // first one's connection and its requests go to the FIRST host. One // chain then goes unwatched, and during a testnet rehearsal a testnet // balance could settle a mainnet order. The pool loader already refuses // duplicate addresses for the same class of reason. for (const EurcChain& seen : out) { if (seen.name == c.name) { std::println(std::cerr, "eurc: two chains are both named '{}' — names key the " "connection map, so one of them would never be queried", c.name); return std::nullopt; } } out.push_back(std::move(c)); } if (out.empty()) return std::nullopt; return out; } namespace { // The two halves of this rail's payId ("
@"), or // nullopt for anything that does not parse — which CheckPaid reads as Dead // (the id came from us; a mangled one identifies no payment) and Instructions // reads as "nothing to render". struct PayIdParts { std::string address; std::int64_t deadline = 0; }; std::optional SplitPayId(std::string_view payId) { const auto at = payId.rfind('@'); if (at == std::string_view::npos) return std::nullopt; PayIdParts parts; parts.address = LowerAscii(payId.substr(0, at)); if (!IsAddress(parts.address)) return std::nullopt; const std::string_view digits = payId.substr(at + 1); const auto [ptr, ec] = std::from_chars(digits.data(), digits.data() + digits.size(), parts.deadline); if (ec != std::errc{} || ptr != digits.data() + digits.size()) return std::nullopt; return parts; } class EurcRail final : public PaymentRail { public: explicit EurcRail(RailConfig cfg) : cfg_(std::move(cfg)) {} // Loading is separate from construction so a bad pool or chain file is a // startup refusal with a reason, not a rail that constructs fine and then // fails at the one moment a buyer is committed. bool Load() { if (!LoadChains()) return false; if (!LoadPool()) return false; // One lock and one (initially empty) connection slot per chain, both // created here so neither map is ever structurally modified again. // That is what makes it safe for two chains to be in Call at the same // time under different locks: operator[] on a missing key would insert, // and inserting into a shared map from two threads is a race the // per-chain locks could not see. for (const EurcChain& chain : chains_) { connLocks_.emplace(chain.name, std::make_unique()); clients_.emplace(chain.name, nullptr); } cursor_ = ReadCursor(); // The cursor is an index into a SPECIFIC pool file, but nothing in it // ever said which — so a cursor and a pool that do not belong together // used to load silently. Two routine operator actions produce exactly // that: restoring an older ledger backup (the closing advice in // tools/enable-eurc.sh has the operator back the cursor up alongside // orders.jsonl, and restoring rewinds it), and replacing the pool with // one from a different seed (the stale cursor then skips the new // pool's head while every old order's index resolves to a different // address, so the reconciler watches the wrong place and those orders // never settle). // // A stamp file next to the cursor closes both. It records how many // lines the pool had and a digest of the addresses the cursor has // ALREADY issued — the prefix that must never change, since those are // published. A pool that still starts with the same issued prefix and // has only grown is a legitimate append; anything else is a refusal // with the reason spelled out, because guessing here reissues live // addresses. if (!CheckPoolStamp()) return false; if (cursor_ >= pool_.size()) { std::println(std::cerr, "eurc: address pool is exhausted ({} of {} used) — top it " "up from the wallet before enabling the crypto rail", cursor_, pool_.size()); return false; } const std::size_t left = pool_.size() - cursor_; std::println(std::cerr, "eurc: {} chains, {} addresses left of {}", chains_.size(), left, pool_.size()); if (left < kLowWaterMark) { std::println(std::cerr, "eurc: WARNING only {} addresses left — top up the pool", left); } return true; } std::optional CreateLink(std::int64_t amountMinor, const std::string& description, const std::string& redirectUrl) override { std::lock_guard lock(mutex_); (void)description; // nothing off-box to label; the ledger holds it if (amountMinor <= 0) return std::nullopt; if (cursor_ >= pool_.size()) { std::println(std::cerr, "eurc: refusing checkout — address pool exhausted"); return std::nullopt; } // Burn the address BEFORE handing it out. A crash between these two // points wastes one address; the opposite order would hand the same // address to two orders, and the second buyer's payment would appear to // settle the first. Wasting is recoverable, reuse is not. const std::string address = pool_[cursor_]; if (!WriteCursor(cursor_ + 1)) { std::println(std::cerr, "eurc: could not persist the address cursor — refusing " "checkout rather than risk reusing {}", address); return std::nullopt; } ++cursor_; const std::int64_t deadline = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count() + static_cast(WindowSeconds()); PaymentLink link; // The id carries the deadline because CheckPaid is given nothing but the // id and the amount, and this rail — unlike a processor's — has to know // on its own when a window closed. Both halves are worth keeping in the // ledger anyway: the address is the audit trail, the deadline explains // why an order lapsed when it did. link.payId = address + "@" + std::to_string(deadline); // There is no hosted checkout to send the buyer to. The order page is // the payment page: it already knows the order, and the address is in // the ledger next to it. link.payUrl = redirectUrl; if (pool_.size() - cursor_ < kLowWaterMark) { std::println(std::cerr, "eurc: WARNING {} addresses left after issuing {}", pool_.size() - cursor_, address); } return link; } std::optional CheckPaid(const std::string& payId, std::int64_t expectedMinor) override { // NO rail mutex here, deliberately, and this is a fix rather than an // omission. Everything this function reads — chains_, and the config — // is immutable once Load has returned; the only shared mutable state it // touches is each chain's HTTP connection, which Call now guards with // that chain's own lock. // // Holding mutex_ across the calls below was a checkout outage waiting // for a slow node. ClientHTTP1 defaults to a 30 s request and 15 s // handshake timeout, so one hung endpoint held the rail for ~45 s per // chain — and the reconciler walks EVERY awaiting order per sweep, // each taking the same lock, while a real buyer's CreateLink (which // needs the mutex only to hand out a pool address, no network at all) // queued behind the whole procession. The Mollie side of this file's // sibling had the identical incident; see the arrival-poll note in // Catcrafts.Server-Http.cpp. const std::optional parts = SplitPayId(payId); if (!parts) return PaidStatus{ PayState::Dead, {} }; const std::string& address = parts->address; const std::int64_t deadline = parts->deadline; // Ask every chain before judging. A transport failure on one chain is // NOT evidence of non-payment, so an unreachable chain poisons the whole // answer to nullopt ("unknown, retry") rather than letting the reachable // chains lapse an order that may well be paid on the silent one. bool anyUnreachable = false; for (const EurcChain& chain : chains_) { const std::optional required = RequiredUnits(chain, expectedMinor); if (!required) { std::println(std::cerr, "eurc: chain '{}' has an unusable scale", chain.name); anyUnreachable = true; continue; } const std::optional balance = BalanceOf(chain, address); if (!balance) { anyUnreachable = true; continue; } // Full cover on ONE chain. Deliberately not a sum across chains: a // total assembled from partial transfers on several networks is not // a payment this shop wants to accept automatically, and reading it // as one would let two unrelated dust sends settle an invoice. if (*balance >= *required) { PaidStatus out; out.state = PayState::Paid; out.method = "eurc-" + chain.name; return out; } } if (anyUnreachable) return std::nullopt; const std::int64_t now = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); if (now >= deadline) { // See the header: this is not "the money bounced". The address stays // ours, so a late payment still lands — which is why the address is // shouted here rather than quietly dropped. std::println(std::cerr, "eurc: order at {} lapsed unpaid after its window — the " "address remains ours, so a late payment will still " "arrive there and needs settling by hand", address); return PaidStatus{ PayState::Dead, {} }; } return PaidStatus{ PayState::Pending, {} }; } // What the order page renders in place of a hosted-checkout button. Reads // only chains_ and the payId, both fixed after load — no lock, per the // interface contract, so a slow RPC poll can never stall page rendering. std::optional Instructions(const std::string& payId, std::int64_t totalMinor) const override { const std::optional parts = SplitPayId(payId); if (!parts || totalMinor <= 0) return std::nullopt; PayInstructions out; out.address = parts->address; out.deadlineUnix = parts->deadline; // EURC is euro-denominated at par, so the token amount IS the euro // total — same digits, different unit label. The one place that fact // is relied on for display, and the reason there is no rate line. out.amount = Money::FormatMinor(totalMinor); for (const EurcChain& chain : chains_) { PayChainOption opt; opt.name = chain.name; opt.contract = chain.contract; opt.note = chain.note; // EIP-681: a URI wallets open with token, network, recipient and // amount pre-filled — the buyer cannot mistype what they never // type. Base units, so the same scaling as the covering check; // skipped when it cannot be represented, never approximated. if (chain.chainId > 0) { if (const auto units = RequiredUnits(chain, totalMinor)) { opt.link = std::format("ethereum:{}@{}/transfer?address={}&uint256={}", chain.contract, chain.chainId, parts->address, *units); } } out.chains.push_back(std::move(opt)); } if (out.chains.empty()) return std::nullopt; return out; } std::string_view Name() const override { return "eurc"; } // Finality is minutes on every chain here, so a faster sweep would only // spend somebody's RPC quota learning nothing. The buyer's own arrival at // the order page still triggers one immediate poll. std::chrono::seconds PollInterval() const override { return std::chrono::seconds(30); } private: static constexpr std::size_t kLowWaterMark = 25; std::size_t WindowSeconds() const { return cfg_.eurcWindowHours > 0 ? static_cast(cfg_.eurcWindowHours) * 3600u : 24u * 3600u; } // cents -> token base units, saturating. Both halves are bounded by config // and by the catalogue, but this is the number an order is judged against. std::optional RequiredUnits(const EurcChain& chain, std::int64_t expectedMinor) const { if (expectedMinor <= 0) return std::nullopt; const std::optional scale = Pow10(chain.decimals - 2); if (!scale) return std::nullopt; if (expectedMinor > std::numeric_limits::max() / *scale) { return std::nullopt; } return expectedMinor * *scale; } std::optional BalanceOf(const EurcChain& chain, const std::string& address) { // eth_call to the token contract. The address is left-padded into a // 32-byte ABI word: 24 zero bytes, then the 20 address bytes. std::string data; data.reserve(2 + 8 + 64); data += kBalanceOfSelector; data.append(24 * 2, '0'); data += address.substr(2); const std::string body = std::string(R"({"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":")") + chain.contract + R"(","data":")" + data + R"("},")" + chain.blockTag + R"("]})"; const std::optional res = Call(chain, body); if (!res) return std::nullopt; // The response's id must be the one we sent. On a fresh connection per // call this is belt-and-braces, but the client keeps connections alive // between polls, and a pipelined or mismatched reply read as this // address's balance is the one decoding mistake that could settle the // wrong order. Cheap to check, so check it. if (!JsonRpcIdIs(*res, 1)) { std::println(std::cerr, "eurc: chain '{}' answered with a different request id — " "discarding rather than reading it as this balance", chain.name); return std::nullopt; } const std::optional units = ParseEthCallUint(*res); if (!units) { std::println(std::cerr, "eurc: chain '{}' returned an undecodable balance: {}", chain.name, res->substr(0, 200)); return std::nullopt; } return units; } // One JSON-RPC POST; nullopt on transport failure or a non-2xx answer. The // reconciler treats nullopt as "unknown, retry" — never as unpaid or dead. // Called WITHOUT the rail mutex held — see the note on CheckPaid. What it // needs instead is exclusive use of this chain's connection, which is its // own lock, per chain: two chains can be in flight at once, and neither // blocks a buyer's checkout. std::optional Call(const EurcChain& chain, const std::string& body) { const std::optional ep = ParseEndpoint(chain.rpcUrl); if (!ep) return std::nullopt; std::mutex& connLock = ConnLockFor(chain.name); std::lock_guard conn(connLock); try { const auto slot = clients_.find(chain.name); if (slot == clients_.end()) return std::nullopt; // not a loaded chain std::unique_ptr& client = slot->second; if (!client) { client = ep->tls ? std::make_unique( ep->host, ep->port, Crafter::TLSClientCredentials{}) : std::make_unique(ep->host, ep->port); } Crafter::HTTPRequest req; req.method = "POST"; req.path = ep->path; req.authority = ep->host; req.body = body; req.headers["content-type"] = "application/json"; req.headers["accept"] = "application/json"; req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)"; const Crafter::HTTPResponse res = client->Send(req); if (res.status.size() != 3 || res.status[0] != '2') { // The RPC URL can carry a key in its path; log the chain, never // the endpoint. std::println(std::cerr, "eurc: chain '{}' -> {} {}", chain.name, res.status, res.body.substr(0, 200)); return std::nullopt; } return res.body; } catch (const std::exception& e) { std::println(std::cerr, "eurc: chain '{}' call failed: {}", chain.name, e.what()); if (const auto slot = clients_.find(chain.name); slot != clients_.end()) { slot->second.reset(); // dial fresh next time } return std::nullopt; } } bool LoadChains() { std::ifstream in(cfg_.eurcChainsPath, std::ios::binary); if (!in) { std::println(std::cerr, "eurc: cannot read chains file '{}'", cfg_.eurcChainsPath.string()); return false; } const std::string text((std::istreambuf_iterator(in)), std::istreambuf_iterator()); std::optional> parsed = ParseEurcChains(text); if (!parsed) { std::println(std::cerr, "eurc: chains file '{}' is malformed — every entry needs a " "name, an http(s) rpc, and a 20-byte contract address", cfg_.eurcChainsPath.string()); return false; } chains_ = std::move(*parsed); return true; } // One address per line; '#' comments and blank lines ignored. A malformed // line is fatal rather than skipped: the pool is the list of places this // shop will tell strangers to send money, and a line that does not parse is // as likely to be a mangled good address as a stray note. // // Addresses must be COPIED from the wallet that generated them. The checksum // case cannot be verified here (see IsAddress), so a hand-retyped address // that stays hex will be accepted, published to a buyer, and paid to a place // nobody holds a key for. bool LoadPool() { std::ifstream in(cfg_.eurcPoolPath, std::ios::binary); if (!in) { std::println(std::cerr, "eurc: cannot read address pool '{}'", cfg_.eurcPoolPath.string()); return false; } std::set seen; std::string line; std::size_t lineNo = 0; while (std::getline(in, line)) { ++lineNo; if (const std::size_t hash = line.find('#'); hash != std::string::npos) { line.erase(hash); } while (!line.empty() && (line.back() == ' ' || line.back() == '\t' || line.back() == '\r')) { line.pop_back(); } std::size_t start = 0; while (start < line.size() && (line[start] == ' ' || line[start] == '\t')) { ++start; } const std::string entry = LowerAscii(std::string_view(line).substr(start)); if (entry.empty()) continue; if (!IsAddress(entry)) { std::println(std::cerr, "eurc: address pool line {} is not an address", lineNo); return false; } // A duplicate in the pool is the reuse bug wearing a different hat. if (!seen.insert(entry).second) { std::println(std::cerr, "eurc: address pool line {} repeats an earlier address", lineNo); return false; } pool_.push_back(entry); } if (pool_.empty()) { std::println(std::cerr, "eurc: address pool '{}' is empty", cfg_.eurcPoolPath.string()); return false; } return true; } std::filesystem::path StampPath() const { std::filesystem::path p = cfg_.eurcPoolPath; p += ".issued"; return p; } // A cheap, dependency-free digest of the issued prefix. Not a security // hash and not trying to be: the threat is an operator mistake — a // restored backup, a swapped pool — not someone forging a stamp they // already have write access to. FNV-1a over the issued addresses in order // catches every reordering, substitution and truncation that matters. std::string IssuedDigest(std::size_t upTo) const { std::uint64_t h = 0xcbf29ce484222325ULL; for (std::size_t i = 0; i < upTo && i < pool_.size(); ++i) { for (const unsigned char c : pool_[i]) { h = (h ^ c) * 0x100000001b3ULL; } h = (h ^ '\n') * 0x100000001b3ULL; } return std::format("{:016x}", h); } // Verify the cursor belongs to this pool, then record the new stamp. // Missing stamp with a zero cursor is a fresh pool; missing stamp with a // non-zero cursor is a pool from before stamping existed, which is // accepted once (there is nothing to compare against) and stamped now. bool CheckPoolStamp() { if (cursor_ == std::numeric_limits::max()) return true; // already refusing std::ifstream in(StampPath(), std::ios::binary); if (in) { std::size_t stampedCount = 0; std::size_t stampedCursor = 0; std::string stampedDigest; if (!(in >> stampedCount >> stampedCursor >> stampedDigest)) { std::println(std::cerr, "eurc: pool stamp '{}' is unreadable — refusing rather " "than risk reissuing a published address. Delete it only " "if you are certain the cursor matches the pool.", StampPath().string()); return false; } if (stampedCursor > cursor_) { std::println(std::cerr, "eurc: the cursor went BACKWARDS ({} now, {} before) — " "a restored backup or a reverted write. Refusing: the " "addresses between the two are already published and " "reissuing one would settle two orders on one payment. " "To recover, set the cursor file to at least {} once you " "have confirmed against the order ledger which addresses " "really went out.", cursor_, stampedCursor, stampedCursor); return false; } if (pool_.size() < stampedCount) { std::println(std::cerr, "eurc: the pool SHRANK ({} lines now, {} before) — it is " "append-only. Refusing rather than reindexing addresses " "already bound to live orders.", pool_.size(), stampedCount); return false; } if (stampedDigest != IssuedDigest(stampedCursor)) { std::println(std::cerr, "eurc: the pool's first {} addresses — the ones already " "issued — are not the ones this cursor was written " "against. This is a different pool (a new seed?) with an " "old cursor. Refusing: every existing order's address " "would resolve somewhere else.", stampedCursor); return false; } } // Record where we are now. A write failure is a warning, not a // refusal: the check is a safety net over the cursor, and refusing to // start over an un-writable net would be its own outage. if (!WriteStamp(cursor_)) { std::println(std::cerr, "eurc: WARNING: could not write the pool stamp '{}'", StampPath().string()); } return true; } // Written BEFORE the cursor it describes, deliberately. If the machine dies // between the two, the stamp is ahead of the cursor and the next load sees // "the cursor went backwards" and refuses — which is the outcome we want, // because the address for that index is already out. The reverse order // would leave the rewind invisible and hand the address out twice. bool WriteStamp(std::size_t value) const { std::filesystem::path tmp = StampPath(); tmp += ".tmp"; { std::ofstream out(tmp, std::ios::binary | std::ios::trunc); if (!out) return false; out << pool_.size() << ' ' << value << ' ' << IssuedDigest(value) << '\n'; out.flush(); if (!out) return false; } if (!FsyncPath(tmp, /*isDirectory=*/false)) return false; std::error_code ec; std::filesystem::rename(tmp, StampPath(), ec); return !ec; } // The cursor is the high-water mark of addresses ever issued. Missing reads // as zero (a fresh pool); anything unparseable is fatal at load rather than // silently rewinding to the start of a pool whose head is already published. std::size_t ReadCursor() const { std::ifstream in(CursorPath(), std::ios::binary); if (!in) return 0; // Read the WHOLE file and parse it strictly. `in >> value` stops at the // first non-digit, so it accepted "5 GARBAGE" as 5, "3.9" as 3 and "+4" // as 4 — a cursor file corrupted into any of those shapes would have // been believed, and believing a too-small cursor reissues addresses // that are already published against live orders. std::string text{ std::istreambuf_iterator(in), std::istreambuf_iterator() }; std::string_view body = text; while (!body.empty() && (body.back() == '\n' || body.back() == '\r' || body.back() == ' ' || body.back() == '\t')) { body.remove_suffix(1); } std::size_t value = 0; const auto [end, ec] = std::from_chars(body.data(), body.data() + body.size(), value); const bool clean = ec == std::errc{} && end == body.data() + body.size() && !body.empty(); if (!clean) { std::println(std::cerr, "eurc: cursor file '{}' is unreadable — treating " "the pool as exhausted rather than reissuing", CursorPath().string()); return std::numeric_limits::max(); } return value; } bool WriteCursor(std::size_t value) const { // Write-then-rename AND fsync, in that order, because the two protect // against different crashes and only one of them was here before. // // Rename alone survives a process crash: a reader sees either the old // cursor or the new one, never a half-written one. It does NOT survive // a machine crash — without fsync the bytes may still be in the page // cache when the power goes, and the rename can be durable while the // data it points at is not. Both post-crash outcomes are the money bug // this file's header calls unrecoverable: a cursor that rewinds hands // the next order an address already published against a live one (two // buyers, one address, and CheckPaid compares the address's TOTAL // balance, so one payment settles both), and a cursor that lands empty // reads as unparseable and refuses the rail. // // So: fsync the temp file, rename, then fsync the DIRECTORY, which is // what makes the rename itself durable. This costs one flush per // issued address, on a path that issues at most one per checkout. // Stamp first — see WriteStamp for why this order is the safe one. if (!WriteStamp(value)) { std::println(std::cerr, "eurc: WARNING: could not write the pool stamp '{}' — a power " "cut from here could rewind the cursor undetected", StampPath().string()); } std::filesystem::path tmp = CursorPath(); tmp += ".tmp"; { std::ofstream out(tmp, std::ios::binary | std::ios::trunc); if (!out) return false; out << value << '\n'; out.flush(); if (!out) return false; } if (!FsyncPath(tmp, /*isDirectory=*/false)) return false; std::error_code ec; std::filesystem::rename(tmp, CursorPath(), ec); if (ec) return false; // A failure here means the rename may not survive a power cut. That is // worth a warning, not a refusal: the address IS out either way, and // returning false would fail a checkout whose address is already spent. if (!FsyncPath(CursorPath().parent_path().empty() ? std::filesystem::path(".") : CursorPath().parent_path(), /*isDirectory=*/true)) { std::println(std::cerr, "eurc: WARNING: could not fsync the directory holding '{}' — " "the cursor is written but a power cut could still rewind it", CursorPath().string()); } return true; } std::filesystem::path CursorPath() const { std::filesystem::path p = cfg_.eurcPoolPath; p += ".cursor"; return p; } // One connection lock per chain, created at load and never rehashed after, // so ConnLockFor needs no lock of its own. Sized from chains_ in Load. std::mutex& ConnLockFor(const std::string& name) { auto it = connLocks_.find(name); // Every chain gets an entry in Load; a name that is not there cannot // reach here, but falling back to the rail mutex is safer than a // dangling reference if that ever stops being true. return it == connLocks_.end() ? mutex_ : *it->second; } RailConfig cfg_; std::vector chains_; std::map> connLocks_; std::vector pool_; std::size_t cursor_ = 0; std::mutex mutex_; std::map> clients_; }; } // namespace std::unique_ptr MakeEurcRail(const RailConfig& config) { auto rail = std::make_unique(config); if (!rail->Load()) return nullptr; return rail; } } // namespace Catcrafts::Server