/* 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. €563.30 owed is 563300000 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; module Catcrafts.Server; import std; import Catcrafts.Shared; import Crafter.Network; using namespace Crafter; namespace Catcrafts::Server { 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. // SATURATES rather than wraps: a balance larger than int64 can hold is still // unambiguously "covers any invoice this shop will ever issue", and saturating // there keeps every comparison downstream in ordinary signed arithmetic. // 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. 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; // Saturate on overflow instead of wrapping. if (out > (std::numeric_limits::max() - digit) / 16) { return std::numeric_limits::max(); } 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. if (c.decimals < 2 || c.decimals > 18) 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); } 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; cursor_ = ReadCursor(); 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 { std::lock_guard lock(mutex_); 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; 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. std::optional Call(const EurcChain& chain, const std::string& body) { const std::optional ep = ParseEndpoint(chain.rpcUrl); if (!ep) return std::nullopt; try { std::unique_ptr& client = clients_[chain.name]; 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()); clients_[chain.name].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; } // 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; std::size_t value = 0; if (!(in >> value)) { 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 so a crash mid-write cannot leave a truncated // cursor that reads as a smaller number than the addresses already out. 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; } std::error_code ec; std::filesystem::rename(tmp, CursorPath(), ec); return !ec; } std::filesystem::path CursorPath() const { std::filesystem::path p = cfg_.eurcPoolPath; p += ".cursor"; return p; } RailConfig cfg_; std::vector chains_; 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