catcrafts.net/server/implementations/Catcrafts.Server-Eurc.cpp

1023 lines
49 KiB
C++
Raw Normal View History

2026-08-15 00:54:05 +02:00
/*
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.
2026-08-15 00:54:05 +02:00
// 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;
2026-08-19 23:55:01 +02:00
// 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 <fcntl.h>
#include <unistd.h>
2026-08-15 00:54:05 +02:00
module Catcrafts.Server;
import std;
import Catcrafts.Shared;
import Crafter.Network;
using namespace Crafter;
namespace Catcrafts::Server {
2026-08-19 23:55:01 +02:00
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
2026-08-15 00:54:05 +02:00
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<char>(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<Endpoint> 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<std::uint16_t>(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<std::int64_t> 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.
2026-08-19 23:55:01 +02:00
// 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.
2026-08-15 00:54:05 +02:00
// 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.
2026-08-19 23:55:01 +02:00
// 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<std::int64_t>(id->number) == want;
}
2026-08-15 00:54:05 +02:00
std::optional<std::int64_t> 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;
2026-08-19 23:55:01 +02:00
// 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.
2026-08-15 00:54:05 +02:00
if (out > (std::numeric_limits<std::int64_t>::max() - digit) / 16) {
2026-08-19 23:55:01 +02:00
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;
2026-08-15 00:54:05 +02:00
}
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<std::vector<EurcChain>> 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<EurcChain> 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<int>(d->number);
}
if (const Json::Value* d = v.Find("chain_id"); d && d->type == Json::Type::Number) {
c.chainId = static_cast<std::int64_t>(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
2026-08-19 23:55:01 +02:00
// 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;
2026-08-15 00:54:05 +02:00
// "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);
}
2026-08-19 23:55:01 +02:00
// 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;
}
}
2026-08-15 00:54:05 +02:00
out.push_back(std::move(c));
}
if (out.empty()) return std::nullopt;
return out;
}
namespace {
// The two halves of this rail's payId ("<address>@<unix-deadline>"), 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<PayIdParts> 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;
2026-08-19 23:55:01 +02:00
// 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<std::mutex>());
clients_.emplace(chain.name, nullptr);
}
2026-08-15 00:54:05 +02:00
cursor_ = ReadCursor();
2026-08-19 23:55:01 +02:00
// 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;
2026-08-15 00:54:05 +02:00
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<PaymentLink> 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::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count()
+ static_cast<std::int64_t>(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<PaidStatus> CheckPaid(const std::string& payId,
std::int64_t expectedMinor) override {
2026-08-19 23:55:01 +02:00
// 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.
2026-08-15 00:54:05 +02:00
const std::optional<PayIdParts> 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<std::int64_t> required = RequiredUnits(chain, expectedMinor);
if (!required) {
std::println(std::cerr, "eurc: chain '{}' has an unusable scale", chain.name);
anyUnreachable = true;
continue;
}
const std::optional<std::int64_t> 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::seconds>(
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<PayInstructions> Instructions(const std::string& payId,
std::int64_t totalMinor) const override {
const std::optional<PayIdParts> 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<std::size_t>(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<std::int64_t> RequiredUnits(const EurcChain& chain,
std::int64_t expectedMinor) const {
if (expectedMinor <= 0) return std::nullopt;
const std::optional<std::int64_t> scale = Pow10(chain.decimals - 2);
if (!scale) return std::nullopt;
if (expectedMinor > std::numeric_limits<std::int64_t>::max() / *scale) {
return std::nullopt;
}
return expectedMinor * *scale;
}
std::optional<std::int64_t> 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<std::string> res = Call(chain, body);
if (!res) return std::nullopt;
2026-08-19 23:55:01 +02:00
// 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;
}
2026-08-15 00:54:05 +02:00
const std::optional<std::int64_t> 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.
2026-08-19 23:55:01 +02:00
// 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.
2026-08-15 00:54:05 +02:00
std::optional<std::string> Call(const EurcChain& chain, const std::string& body) {
const std::optional<Endpoint> ep = ParseEndpoint(chain.rpcUrl);
if (!ep) return std::nullopt;
2026-08-19 23:55:01 +02:00
std::mutex& connLock = ConnLockFor(chain.name);
std::lock_guard conn(connLock);
2026-08-15 00:54:05 +02:00
try {
2026-08-19 23:55:01 +02:00
const auto slot = clients_.find(chain.name);
if (slot == clients_.end()) return std::nullopt; // not a loaded chain
std::unique_ptr<Crafter::ClientHTTP1>& client = slot->second;
2026-08-15 00:54:05 +02:00
if (!client) {
client = ep->tls
? std::make_unique<Crafter::ClientHTTP1>(
ep->host, ep->port, Crafter::TLSClientCredentials{})
: std::make_unique<Crafter::ClientHTTP1>(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());
2026-08-19 23:55:01 +02:00
if (const auto slot = clients_.find(chain.name); slot != clients_.end()) {
slot->second.reset(); // dial fresh next time
}
2026-08-15 00:54:05 +02:00
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<char>(in)),
std::istreambuf_iterator<char>());
std::optional<std::vector<EurcChain>> 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<std::string> 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;
}
2026-08-19 23:55:01 +02:00
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<std::size_t>::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;
}
2026-08-15 00:54:05 +02:00
// 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;
2026-08-19 23:55:01 +02:00
// 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<char>(in),
std::istreambuf_iterator<char>() };
std::string_view body = text;
while (!body.empty() && (body.back() == '\n' || body.back() == '\r'
|| body.back() == ' ' || body.back() == '\t')) {
body.remove_suffix(1);
}
2026-08-15 00:54:05 +02:00
std::size_t value = 0;
2026-08-19 23:55:01 +02:00
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) {
2026-08-15 00:54:05 +02:00
std::println(std::cerr, "eurc: cursor file '{}' is unreadable — treating "
"the pool as exhausted rather than reissuing",
CursorPath().string());
return std::numeric_limits<std::size_t>::max();
}
return value;
}
bool WriteCursor(std::size_t value) const {
2026-08-19 23:55:01 +02:00
// 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());
}
2026-08-15 00:54:05 +02:00
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;
}
2026-08-19 23:55:01 +02:00
if (!FsyncPath(tmp, /*isDirectory=*/false)) return false;
2026-08-15 00:54:05 +02:00
std::error_code ec;
std::filesystem::rename(tmp, CursorPath(), ec);
2026-08-19 23:55:01 +02:00
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;
2026-08-15 00:54:05 +02:00
}
std::filesystem::path CursorPath() const {
std::filesystem::path p = cfg_.eurcPoolPath;
p += ".cursor";
return p;
}
2026-08-19 23:55:01 +02:00
// 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;
}
2026-08-15 00:54:05 +02:00
RailConfig cfg_;
std::vector<EurcChain> chains_;
2026-08-19 23:55:01 +02:00
std::map<std::string, std::unique_ptr<std::mutex>> connLocks_;
2026-08-15 00:54:05 +02:00
std::vector<std::string> pool_;
std::size_t cursor_ = 0;
std::mutex mutex_;
std::map<std::string, std::unique_ptr<Crafter::ClientHTTP1>> clients_;
};
} // namespace
std::unique_ptr<PaymentRail> MakeEurcRail(const RailConfig& config) {
auto rail = std::make_unique<EurcRail>(config);
if (!rail->Load()) return nullptr;
return rail;
}
} // namespace Catcrafts::Server