catcrafts.net/server/implementations/Catcrafts.Server-Eurc.cpp
Jorijn van der Graaf 45992c4f91
Some checks failed
Deploy / build-deploy (push) Failing after 4m58s
payment tests
2026-08-20 01:36:42 +02:00

1263 lines
62 KiB
C++

/*
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 <fcntl.h>
#include <unistd.h>
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<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.
// 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<std::int64_t>(id->number) == want;
}
std::string BalanceOfCallData(std::string_view address) {
// Selector, then one 32-byte ABI word: 12 zero BYTES of left padding (24
// hex digits), then the 20 address bytes (40 hex digits). The first
// version of this appended 24 bytes of padding — bytes and hex digits
// confused — which shifted the address past the argument word. Solidity's
// decoder read the word's low 20 bytes (mostly padding plus the address's
// first 8 bytes), found a valid-looking address with nothing on it, and
// answered 0: every real payment read as "not arrived", with no error on
// either side. Pinned byte-for-byte in ShouldParseEurcChains, and proven
// against a real chain in ShouldSettleEurcOnTestnet — the suite that
// caught it.
std::string data;
data.reserve(2 + 8 + 64);
data += kBalanceOfSelector;
data.append(12 * 2, '0');
data += address.substr(2);
return data;
}
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;
// 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<std::int64_t>::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<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"));
// "rpcs": [...] is the real field; "rpc": "..." is the one-endpoint
// shorthand and still works, so a chains file written before
// corroboration existed keeps loading (with a warning, below).
if (const Json::Value* list = v.Find("rpcs"); list && list->IsArray()) {
for (const Json::Value& u : list->array) {
if (u.type != Json::Type::String) return std::nullopt;
c.rpcUrls.push_back(std::string(u.string));
}
} else if (const std::string_view one = v.Str("rpc"); !one.empty()) {
c.rpcUrls.push_back(std::string(one));
}
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);
}
// Default: agreement between two independent sources when two exist.
// An explicit value may only make the rule STRICTER than the number of
// endpoints allows in one direction — asking for more confirmations
// than there are endpoints would never settle anything, so it is a
// refusal rather than a clamp.
const bool explicitMin =
v.Find("min_confirmations") != nullptr
&& v.Find("min_confirmations")->type == Json::Type::Number;
if (explicitMin) {
c.minConfirmations =
static_cast<int>(v.Find("min_confirmations")->number);
} else {
c.minConfirmations = c.rpcUrls.size() >= 2 ? 2 : 1;
}
c.note = std::string(v.Str("note"));
if (c.chainId < 0) return std::nullopt;
if (c.name.empty() || c.rpcUrls.empty()) return std::nullopt;
if (!IsAddress(c.contract)) return std::nullopt;
// Every endpoint must be a usable http(s) URL, and no chain may list
// the same URL twice: a duplicate would "corroborate" itself, which is
// the one thing a quorum must never accept.
for (std::size_t i = 0; i < c.rpcUrls.size(); ++i) {
if (!ParseEndpoint(c.rpcUrls[i])) return std::nullopt;
for (std::size_t j = 0; j < i; ++j) {
if (c.rpcUrls[j] == c.rpcUrls[i]) {
std::println(std::cerr,
"eurc: chain '{}' lists the endpoint {} twice — one "
"source cannot corroborate itself", c.name,
c.rpcUrls[i]);
return std::nullopt;
}
}
}
if (c.minConfirmations < 1
|| c.minConfirmations > static_cast<int>(c.rpcUrls.size())) {
std::println(std::cerr,
"eurc: chain '{}' asks for {} confirmations from {} "
"endpoint(s) — that can never be satisfied, so no payment "
"would ever settle", c.name, c.minConfirmations,
c.rpcUrls.size());
return std::nullopt;
}
// Independence is the whole value of a quorum, and two endpoints at the
// same host are one source wearing two URLs. Not a refusal — a operator
// may deliberately run two of their own nodes behind one name — but it
// must be said out loud.
for (std::size_t i = 1; i < c.rpcUrls.size(); ++i) {
const auto a = ParseEndpoint(c.rpcUrls[i]);
for (std::size_t j = 0; j < i; ++j) {
const auto b = ParseEndpoint(c.rpcUrls[j]);
if (a && b && a->host == b->host) {
std::println(std::cerr,
"eurc: WARNING: chain '{}' has two endpoints at host "
"{} — they are not independent sources, so agreement "
"between them proves less than it looks like",
c.name, a->host);
}
}
}
if (c.minConfirmations < 2) {
std::println(std::cerr,
"eurc: WARNING: chain '{}' settles on {} source(s) with "
"min_confirmations=1 — one node's word is enough to mark an "
"order paid. Add a second independent 'rpcs' entry.",
c.name, c.rpcUrls.size());
}
// 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 ("<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;
// 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_) {
for (const std::string& url : chain.rpcUrls) {
connLocks_.emplace(url, std::make_unique<std::mutex>());
clients_.emplace(url, nullptr);
trust_.emplace(url, Trust::Unknown);
}
}
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<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 {
// 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<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.
const std::int64_t now =
std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
// Past the window, every reading becomes decisive: a lapse is as
// irreversible a judgement as a settlement, so it gets the same quorum.
const bool decisive = now >= deadline;
bool anyUnknown = 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);
anyUnknown = true;
continue;
}
switch (AskChain(chain, address, *required, decisive)) {
case ChainVerdict::Covered: {
// Full cover on ONE chain, agreed by minConfirmations of its
// endpoints. 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.
PaidStatus out;
out.state = PayState::Paid;
out.method = "eurc-" + chain.name;
return out;
}
case ChainVerdict::Unknown:
anyUnknown = true;
break;
case ChainVerdict::NotCovered:
break;
}
}
// A chain we could not read is NOT evidence of non-payment, so it
// poisons the whole answer to "unknown, retry" rather than letting the
// readable chains lapse an order that may well be paid on the silent
// one.
if (anyUnknown) {
if (decisive) {
// Worth saying out loud: the window has closed and the order
// still cannot be judged, so it will neither settle nor lapse
// until something answers. That is the safe state, but it is not
// a state anyone should discover by accident months later.
std::println(std::cerr,
"eurc: order at {} is past its window but cannot be "
"judged — an endpoint is unreachable or the sources "
"disagree. Holding it open (neither paid nor lapsed) "
"until they agree; check the chain endpoints.", address);
}
return std::nullopt;
}
if (decisive) {
// 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;
}
// What one chain says about one address, once its endpoints have been
// consulted. Three answers rather than two, because "I could not find out"
// must never collapse into "not paid" — that is what would lapse a paid
// order.
enum class ChainVerdict { Covered, NotCovered, Unknown };
// Ask a chain whether the address holds the required amount, and require
// minConfirmations independent endpoints to agree before saying Covered.
//
// Cost control matters here: the reconciler calls this for every awaiting
// order, forever, against public endpoints that rate-limit. So the routine
// path stays at ONE call per chain — the first endpoint saying "not covered"
// needs no corroboration, because "keep waiting" is not a decision anyone
// can be defrauded by. The extra calls happen only at the two moments that
// actually decide money:
//
// settling — a "covered" reading is never believed alone, so the other
// endpoints are asked before an order is marked paid; and
// lapsing — when the window has closed, a "not covered" reading is not
// believed alone either, so a node that lies (or lags) in the
// negative direction cannot cause a paid order to be lapsed.
//
// Both are once-per-order events, so the steady-state traffic is unchanged
// while every actual decision rests on agreement.
ChainVerdict AskChain(const EurcChain& chain, const std::string& address,
std::int64_t required, bool decisive) {
if (chain.rpcUrls.empty()) return ChainVerdict::Unknown;
if (!decisive) {
const std::optional<std::int64_t> first =
BalanceOf(chain, chain.rpcUrls.front(), address);
// Clearly short, from a source that answered: nothing to decide and
// nothing to corroborate.
if (first && *first < required) return ChainVerdict::NotCovered;
// Covered, or unknown — either way the full poll below is warranted.
}
int covered = 0;
int answered = 0;
for (const std::string& url : chain.rpcUrls) {
const std::optional<std::int64_t> balance = BalanceOf(chain, url, address);
if (!balance) continue; // silent or distrusted: no vote
++answered;
if (*balance >= required) ++covered;
if (covered >= chain.minConfirmations) return ChainVerdict::Covered;
}
if (covered > 0) {
// Some endpoints see the money and not enough of them do. Benign
// causes exist — one node lagging behind the others' view of
// finality — and so do hostile ones, and from here they look the
// same. Unknown is the answer for both: retry, settle nothing,
// lapse nothing, and make sure the operator can see it happening.
std::println(std::cerr,
"eurc: chain '{}' DISAGREES about {} — {} of {} endpoint(s) "
"that answered see a covering balance, {} needed. Not "
"settling. If this persists it is either a lagging node or "
"one that is lying.",
chain.name, address, covered, answered,
chain.minConfirmations);
return ChainVerdict::Unknown;
}
// Nobody saw the money. That is only "not covered" if enough sources
// actually answered to make the quorum meaningful.
if (answered < chain.minConfirmations) return ChainVerdict::Unknown;
return ChainVerdict::NotCovered;
}
std::optional<std::int64_t> BalanceOf(const EurcChain& chain,
const std::string& url,
const std::string& address) {
// A node that is not serving this chain does not get to answer.
if (!EndpointServesChain(chain, url)) return std::nullopt;
// eth_call to the token contract; the calldata encoding lives in
// BalanceOfCallData, where the self-test can pin it.
const std::string data = BalanceOfCallData(address);
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, url, 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<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.
// 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<std::string> Call(const EurcChain& chain, const std::string& url,
const std::string& body) {
const std::optional<Endpoint> ep = ParseEndpoint(url);
if (!ep) return std::nullopt;
// Keyed by URL, not by chain: each endpoint gets its own connection and
// its own lock, so two endpoints of one chain can be in flight at once
// and neither blocks the other (nor a buyer's checkout).
std::mutex& connLock = ConnLockFor(url);
std::lock_guard conn(connLock);
try {
const auto slot = clients_.find(url);
if (slot == clients_.end()) return std::nullopt; // not a loaded endpoint
std::unique_ptr<Crafter::ClientHTTP1>& client = slot->second;
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());
if (const auto slot = clients_.find(url); slot != clients_.end()) {
slot->second.reset(); // dial fresh next time
}
return std::nullopt;
}
}
// Is this endpoint actually serving the chain the config says it is?
//
// Verified once per endpoint, lazily on first use, and cached — startup must
// not depend on the network. Without it, an endpoint pointed at a testnet
// (or at a different L2 entirely) answers balanceOf perfectly happily and
// its zero-or-nonzero reading is treated as fact about mainnet. A rejected
// endpoint is never queried again: it cannot vote, which is the only safe
// thing to do with a source that is demonstrably not talking about this
// chain.
//
// chain_id 0 means "not stated" (it is optional, and drives the EIP-681
// link) so there is nothing to check and the endpoint is trusted as before.
bool EndpointServesChain(const EurcChain& chain, const std::string& url) {
if (chain.chainId == 0) return true;
{
std::lock_guard lock(trustMutex_);
const auto it = trust_.find(url);
if (it != trust_.end() && it->second != Trust::Unknown) {
return it->second == Trust::Verified;
}
}
const std::optional<std::string> res = Call(
chain, url, R"({"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]})");
// A transport failure is not a verdict: leave it Unknown so a later poll
// can try again rather than permanently disqualifying a node that was
// merely unreachable for a moment.
if (!res || !JsonRpcIdIs(*res, 1)) return false;
const std::optional<std::int64_t> got = ParseEthCallUint(*res);
if (!got) return false;
const bool ok = *got == chain.chainId;
{
std::lock_guard lock(trustMutex_);
trust_[url] = ok ? Trust::Verified : Trust::Rejected;
}
if (!ok) {
std::println(std::cerr,
"eurc: endpoint for chain '{}' reports chain id {} but the "
"config says {} — it is serving a DIFFERENT chain. Excluding "
"it from every balance vote.",
chain.name, *got, chain.chainId);
}
return ok;
}
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;
}
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;
}
// 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<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);
}
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<std::size_t>::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 ENDPOINT, created at load and never rehashed
// after, so ConnLockFor needs no lock of its own.
std::mutex& ConnLockFor(const std::string& url) {
auto it = connLocks_.find(url);
// Every configured endpoint gets an entry in Load; a URL 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;
}
// Whether an endpoint has been shown to serve the chain id its chain
// declares. Populated (as Unknown) at load so the map is never rehashed;
// the values change under trustMutex_.
enum class Trust { Unknown, Verified, Rejected };
RailConfig cfg_;
std::vector<EurcChain> chains_;
std::map<std::string, std::unique_ptr<std::mutex>> connLocks_;
std::map<std::string, Trust> trust_;
std::mutex trustMutex_;
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