diff --git a/server/implementations/Catcrafts.Server-Eurc.cpp b/server/implementations/Catcrafts.Server-Eurc.cpp index 9204b32..0f25d36 100644 --- a/server/implementations/Catcrafts.Server-Eurc.cpp +++ b/server/implementations/Catcrafts.Server-Eurc.cpp @@ -277,7 +277,17 @@ std::optional> 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> ParseEurcChains(std::string_view json) { if (const Json::Value* d = v.Find("chain_id"); d && d->type == Json::Type::Number) { c.chainId = static_cast(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(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(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()); - clients_.emplace(chain.name, nullptr); + for (const std::string& url : chain.rpcUrls) { + connLocks_.emplace(url, std::make_unique()); + 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::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 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 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::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 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 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 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 res = Call(chain, body); + const std::optional 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 Call(const EurcChain& chain, const std::string& body) { - const std::optional ep = ParseEndpoint(chain.rpcUrl); + std::optional Call(const EurcChain& chain, const std::string& url, + const std::string& body) { + const std::optional 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& 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 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 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 chains_; std::map> connLocks_; + std::map trust_; + std::mutex trustMutex_; std::vector pool_; std::size_t cursor_ = 0; std::mutex mutex_; diff --git a/server/interfaces/Catcrafts.Server.cppm b/server/interfaces/Catcrafts.Server.cppm index 9b2b7eb..412f2b2 100644 --- a/server/interfaces/Catcrafts.Server.cppm +++ b/server/interfaces/Catcrafts.Server.cppm @@ -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 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 diff --git a/tests/ShouldParseEurcChains/main.cpp b/tests/ShouldParseEurcChains/main.cpp index 4e16e9f..820e601 100644 --- a/tests/ShouldParseEurcChains/main.cpp +++ b/tests/ShouldParseEurcChains/main.cpp @@ -134,6 +134,77 @@ int main() { "eurc: a hex block number is still accepted"); } + // ── corroboration: multiple endpoints per chain ────────────────── + { + // The single-"rpc" shorthand still parses, and lands as one endpoint + // with min_confirmations=1 (the pre-corroboration behaviour). + const auto one = Server::ParseEurcChains(R"({"chains":[ + {"name":"base","rpc":"https://a.example", + "contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})"); + Check(one.has_value() && (*one)[0].rpcUrls.size() == 1 + && (*one)[0].rpcUrls[0] == "https://a.example" + && (*one)[0].minConfirmations == 1, + "eurc: the single-rpc shorthand still loads, one source, one confirmation"); + + // Two endpoints default to requiring agreement between both. + const auto two = Server::ParseEurcChains(R"({"chains":[ + {"name":"base","rpcs":["https://a.example","https://b.example"], + "contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})"); + Check(two.has_value() && (*two)[0].rpcUrls.size() == 2 + && (*two)[0].minConfirmations == 2, + "eurc: two endpoints default to two confirmations"); + + // Three endpoints still default to two — a quorum, not unanimity, so + // one node being down does not stop the shop settling. + const auto three = Server::ParseEurcChains(R"({"chains":[ + {"name":"base","rpcs":["https://a.example","https://b.example", + "https://c.example"], + "contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})"); + Check(three.has_value() && (*three)[0].minConfirmations == 2, + "eurc: three endpoints still need two agreeing, not all three"); + + // An explicit value is honoured. + const auto strict = Server::ParseEurcChains(R"({"chains":[ + {"name":"base","rpcs":["https://a.example","https://b.example", + "https://c.example"],"min_confirmations":3, + "contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})"); + Check(strict.has_value() && (*strict)[0].minConfirmations == 3, + "eurc: an explicit min_confirmations is honoured"); + } + + // One endpoint cannot corroborate itself, so a repeated URL is refused + // rather than counted twice toward the quorum. + Check(!Server::ParseEurcChains(R"({"chains":[ + {"name":"base","rpcs":["https://a.example","https://a.example"], + "contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})").has_value(), + "eurc: the same endpoint listed twice is refused"); + + // Asking for more agreement than there are sources would never settle + // anything — a silent never-pays shop, so it is a refusal at load. + Check(!Server::ParseEurcChains(R"({"chains":[ + {"name":"base","rpcs":["https://a.example","https://b.example"], + "min_confirmations":3, + "contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})").has_value(), + "eurc: more confirmations than endpoints is refused"); + Check(!Server::ParseEurcChains(R"({"chains":[ + {"name":"base","rpcs":["https://a.example"],"min_confirmations":0, + "contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})").has_value(), + "eurc: zero confirmations is refused"); + + // An empty rpcs list is not a chain that can be watched. + Check(!Server::ParseEurcChains(R"({"chains":[ + {"name":"base","rpcs":[], + "contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})").has_value(), + "eurc: an empty endpoint list is refused"); + Check(!Server::ParseEurcChains(R"({"chains":[ + {"name":"base","rpcs":["https://a.example",42], + "contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})").has_value(), + "eurc: a non-string endpoint is refused"); + Check(!Server::ParseEurcChains(R"({"chains":[ + {"name":"base","rpcs":["https://a.example","ftp://b.example"], + "contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})").has_value(), + "eurc: a non-http endpoint anywhere in the list is refused"); + if (failures != 0) { std::println(std::cerr, "{} check(s) failed", failures); return 1; diff --git a/tools/enable-eurc.sh b/tools/enable-eurc.sh index 849fbad..6100952 100755 --- a/tools/enable-eurc.sh +++ b/tools/enable-eurc.sh @@ -88,12 +88,27 @@ if [ -n "$CHAINS_SRC" ]; then [ -r "$CHAINS_SRC" ] || { echo "enable-eurc: cannot read chains file '$CHAINS_SRC'" >&2; exit 1; } cp "$CHAINS_SRC" "$WORK/chains.json" else + # Several endpoints per chain, from DIFFERENT operators, because a payment + # is confirmed only when min_confirmations of them independently agree that + # the money is there — no single node's word settles an order. Base gets + # three (Coinbase's own, Allnodes, Automata) so one being down still leaves + # a quorum; Ethereum gets two. All five were checked to answer + # eth_chainId AND a balanceOf eth_call at block_tag=finalized, which is + # what this rail actually asks of them (Cloudflare's endpoint refuses + # finalized eth_call, which is why it is not here). cat > "$WORK/chains.json" <<'JSON' {"chains": [ - {"name": "base", "rpc": "https://mainnet.base.org", + {"name": "base", + "rpcs": ["https://mainnet.base.org", + "https://base-rpc.publicnode.com", + "https://1rpc.io/base"], + "min_confirmations": 2, "contract": "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42", "chain_id": 8453, "note": "lowest network fees"}, - {"name": "ethereum", "rpc": "https://ethereum-rpc.publicnode.com", + {"name": "ethereum", + "rpcs": ["https://ethereum-rpc.publicnode.com", + "https://1rpc.io/eth"], + "min_confirmations": 2, "contract": "0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c", "chain_id": 1} ]} @@ -108,6 +123,8 @@ fi if [ "$ASSUME_YES" -eq 0 ]; then echo "About to install these chains ($COUNT addresses in the pool):" sed 's/^/ /' "$WORK/chains.json" + echo "Each chain lists several independent endpoints; a payment settles only" + echo "when min_confirmations of them agree, so no single node can fake one." echo "Verify every contract against Circle's list — the only source that counts:" echo " $CIRCLE_URL" printf 'Contracts verified? Type yes to continue: '