crypto fix
All checks were successful
Deploy / build-deploy (push) Successful in 2m26s

This commit is contained in:
Jorijn van der Graaf 2026-08-20 00:09:08 +02:00
commit 47d302a9a4
4 changed files with 364 additions and 39 deletions

View file

@ -277,7 +277,17 @@ std::optional<std::vector<EurcChain>> ParseEurcChains(std::string_view json) {
if (!v.IsObject()) return std::nullopt;
EurcChain c;
c.name = std::string(v.Str("name"));
c.rpcUrl = std::string(v.Str("rpc"));
// "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) {
@ -286,11 +296,72 @@ std::optional<std::vector<EurcChain>> ParseEurcChains(std::string_view json) {
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.rpcUrl.empty()) return std::nullopt;
if (c.name.empty() || c.rpcUrls.empty()) return std::nullopt;
if (!IsAddress(c.contract)) return std::nullopt;
if (!ParseEndpoint(c.rpcUrl)) 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
@ -413,8 +484,11 @@ public:
// 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);
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
@ -529,36 +603,61 @@ public:
// 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;
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);
anyUnreachable = true;
anyUnknown = 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) {
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 (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) {
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.
@ -637,8 +736,79 @@ private:
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 address is left-padded into a
// 32-byte ABI word: 24 zero bytes, then the 20 address bytes.
std::string data;
@ -651,7 +821,7 @@ private:
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);
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
@ -679,14 +849,18 @@ private:
// 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& body) {
const std::optional<Endpoint> ep = ParseEndpoint(chain.rpcUrl);
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;
std::mutex& connLock = ConnLockFor(chain.name);
// 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(chain.name);
if (slot == clients_.end()) return std::nullopt; // not a loaded chain
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
@ -714,13 +888,57 @@ private:
return res.body;
} catch (const std::exception& e) {
std::println(std::cerr, "eurc: chain '{}' call failed: {}", chain.name, e.what());
if (const auto slot = clients_.find(chain.name); slot != clients_.end()) {
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) {
@ -993,19 +1211,26 @@ private:
return p;
}
// One connection lock per chain, created at load and never rehashed after,
// so ConnLockFor needs no lock of its own. Sized from chains_ in Load.
std::mutex& ConnLockFor(const std::string& name) {
auto it = connLocks_.find(name);
// Every chain gets an entry in Load; a name that is not there cannot
// reach here, but falling back to the rail mutex is safer than a
// dangling reference if that ever stops being true.
// 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_;

View file

@ -375,10 +375,22 @@ export namespace Catcrafts::Server {
// of them at once.
struct EurcChain {
std::string name; // ledger via suffix: "base" -> "eurc-base"
std::string rpcUrl;
// One or more independent JSON-RPC endpoints for the SAME chain, in
// preference order. More than one is the point: a payment is confirmed
// only when minConfirmations of them independently agree that the money
// is there, so no single node's word can settle an order. The config
// spells this "rpcs": [...]; the older single "rpc" still parses and
// lands here as a one-element list.
std::vector<std::string> rpcUrls;
std::string contract; // the EURC token contract on this chain
std::string blockTag = "finalized";
int decimals = 6;
// How many endpoints must independently report a covering balance
// before an order settles. Defaults to 2 when at least two endpoints
// are configured, 1 when only one is (which is the old behaviour, and
// is warned about at load — one source means trusting one operator).
// Never exceeds rpcUrls.size().
int minConfirmations = 1;
// For the order page. chainId names the network in the EIP-681 wallet
// link (1 = Ethereum, 8453 = Base); 0 omits the link rather than
// guessing. note is a short display hint ("lowest fees") — copy is