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; if (!v.IsObject()) return std::nullopt;
EurcChain c; EurcChain c;
c.name = std::string(v.Str("name")); 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.contract = LowerAscii(v.Str("contract"));
c.blockTag = std::string(v.Str("block_tag", "finalized")); c.blockTag = std::string(v.Str("block_tag", "finalized"));
if (const Json::Value* d = v.Find("decimals"); d && d->type == Json::Type::Number) { 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) { if (const Json::Value* d = v.Find("chain_id"); d && d->type == Json::Type::Number) {
c.chainId = static_cast<std::int64_t>(d->number); c.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")); c.note = std::string(v.Str("note"));
if (c.chainId < 0) return std::nullopt; 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 (!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 // 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 // cannot represent the invoice at all. The ceiling is NOT 18 (the ERC-20
// maximum) but what the arithmetic can actually carry: RequiredUnits // 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 // and inserting into a shared map from two threads is a race the
// per-chain locks could not see. // per-chain locks could not see.
for (const EurcChain& chain : chains_) { for (const EurcChain& chain : chains_) {
connLocks_.emplace(chain.name, std::make_unique<std::mutex>()); for (const std::string& url : chain.rpcUrls) {
clients_.emplace(chain.name, nullptr); connLocks_.emplace(url, std::make_unique<std::mutex>());
clients_.emplace(url, nullptr);
trust_.emplace(url, Trust::Unknown);
}
} }
cursor_ = ReadCursor(); cursor_ = ReadCursor();
// The cursor is an index into a SPECIFIC pool file, but nothing in it // 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 // NOT evidence of non-payment, so an unreachable chain poisons the whole
// answer to nullopt ("unknown, retry") rather than letting the reachable // answer to nullopt ("unknown, retry") rather than letting the reachable
// chains lapse an order that may well be paid on the silent one. // 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_) { for (const EurcChain& chain : chains_) {
const std::optional<std::int64_t> required = RequiredUnits(chain, expectedMinor); const std::optional<std::int64_t> required = RequiredUnits(chain, expectedMinor);
if (!required) { if (!required) {
std::println(std::cerr, "eurc: chain '{}' has an unusable scale", chain.name); std::println(std::cerr, "eurc: chain '{}' has an unusable scale", chain.name);
anyUnreachable = true; anyUnknown = true;
continue; continue;
} }
const std::optional<std::int64_t> balance = BalanceOf(chain, address); switch (AskChain(chain, address, *required, decisive)) {
if (!balance) { case ChainVerdict::Covered: {
anyUnreachable = true; // Full cover on ONE chain, agreed by minConfirmations of its
continue; // endpoints. Deliberately not a sum across chains: a total
} // assembled from partial transfers on several networks is not a
// Full cover on ONE chain. Deliberately not a sum across chains: a // payment this shop wants to accept automatically, and reading
// total assembled from partial transfers on several networks is not // it as one would let two unrelated dust sends settle an
// a payment this shop wants to accept automatically, and reading it // invoice.
// as one would let two unrelated dust sends settle an invoice.
if (*balance >= *required) {
PaidStatus out; PaidStatus out;
out.state = PayState::Paid; out.state = PayState::Paid;
out.method = "eurc-" + chain.name; out.method = "eurc-" + chain.name;
return out; 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 = if (decisive) {
std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
if (now >= deadline) {
// See the header: this is not "the money bounced". The address stays // 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 // ours, so a late payment still lands — which is why the address is
// shouted here rather than quietly dropped. // shouted here rather than quietly dropped.
@ -637,8 +736,79 @@ private:
return expectedMinor * *scale; 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, std::optional<std::int64_t> BalanceOf(const EurcChain& chain,
const std::string& url,
const std::string& address) { 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 // 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. // 32-byte ABI word: 24 zero bytes, then the 20 address bytes.
std::string data; std::string data;
@ -651,7 +821,7 @@ private:
std::string(R"({"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":")") std::string(R"({"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":")")
+ chain.contract + R"(","data":")" + data + R"("},")" + chain.blockTag + R"("]})"; + 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; if (!res) return std::nullopt;
// The response's id must be the one we sent. On a fresh connection per // 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 // 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 // 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 // own lock, per chain: two chains can be in flight at once, and neither
// blocks a buyer's checkout. // blocks a buyer's checkout.
std::optional<std::string> Call(const EurcChain& chain, const std::string& body) { std::optional<std::string> Call(const EurcChain& chain, const std::string& url,
const std::optional<Endpoint> ep = ParseEndpoint(chain.rpcUrl); const std::string& body) {
const std::optional<Endpoint> ep = ParseEndpoint(url);
if (!ep) return std::nullopt; 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); std::lock_guard conn(connLock);
try { try {
const auto slot = clients_.find(chain.name); const auto slot = clients_.find(url);
if (slot == clients_.end()) return std::nullopt; // not a loaded chain if (slot == clients_.end()) return std::nullopt; // not a loaded endpoint
std::unique_ptr<Crafter::ClientHTTP1>& client = slot->second; std::unique_ptr<Crafter::ClientHTTP1>& client = slot->second;
if (!client) { if (!client) {
client = ep->tls client = ep->tls
@ -714,13 +888,57 @@ private:
return res.body; return res.body;
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::println(std::cerr, "eurc: chain '{}' call failed: {}", chain.name, e.what()); 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 slot->second.reset(); // dial fresh next time
} }
return std::nullopt; 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() { bool LoadChains() {
std::ifstream in(cfg_.eurcChainsPath, std::ios::binary); std::ifstream in(cfg_.eurcChainsPath, std::ios::binary);
if (!in) { if (!in) {
@ -993,19 +1211,26 @@ private:
return p; return p;
} }
// One connection lock per chain, created at load and never rehashed after, // One connection lock per ENDPOINT, created at load and never rehashed
// so ConnLockFor needs no lock of its own. Sized from chains_ in Load. // after, so ConnLockFor needs no lock of its own.
std::mutex& ConnLockFor(const std::string& name) { std::mutex& ConnLockFor(const std::string& url) {
auto it = connLocks_.find(name); auto it = connLocks_.find(url);
// Every chain gets an entry in Load; a name that is not there cannot // Every configured endpoint gets an entry in Load; a URL that is not
// reach here, but falling back to the rail mutex is safer than a // there cannot reach here, but falling back to the rail mutex is safer
// dangling reference if that ever stops being true. // than a dangling reference if that ever stops being true.
return it == connLocks_.end() ? mutex_ : *it->second; 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_; RailConfig cfg_;
std::vector<EurcChain> chains_; std::vector<EurcChain> chains_;
std::map<std::string, std::unique_ptr<std::mutex>> connLocks_; 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::vector<std::string> pool_;
std::size_t cursor_ = 0; std::size_t cursor_ = 0;
std::mutex mutex_; std::mutex mutex_;

View file

@ -375,10 +375,22 @@ export namespace Catcrafts::Server {
// of them at once. // of them at once.
struct EurcChain { struct EurcChain {
std::string name; // ledger via suffix: "base" -> "eurc-base" 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 contract; // the EURC token contract on this chain
std::string blockTag = "finalized"; std::string blockTag = "finalized";
int decimals = 6; 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 // For the order page. chainId names the network in the EIP-681 wallet
// link (1 = Ethereum, 8453 = Base); 0 omits the link rather than // link (1 = Ethereum, 8453 = Base); 0 omits the link rather than
// guessing. note is a short display hint ("lowest fees") — copy is // guessing. note is a short display hint ("lowest fees") — copy is

View file

@ -134,6 +134,77 @@ int main() {
"eurc: a hex block number is still accepted"); "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) { if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures); std::println(std::cerr, "{} check(s) failed", failures);
return 1; return 1;

View file

@ -88,12 +88,27 @@ if [ -n "$CHAINS_SRC" ]; then
[ -r "$CHAINS_SRC" ] || { echo "enable-eurc: cannot read chains file '$CHAINS_SRC'" >&2; exit 1; } [ -r "$CHAINS_SRC" ] || { echo "enable-eurc: cannot read chains file '$CHAINS_SRC'" >&2; exit 1; }
cp "$CHAINS_SRC" "$WORK/chains.json" cp "$CHAINS_SRC" "$WORK/chains.json"
else 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' cat > "$WORK/chains.json" <<'JSON'
{"chains": [ {"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", "contract": "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42",
"chain_id": 8453, "note": "lowest network fees"}, "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", "contract": "0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c",
"chain_id": 1} "chain_id": 1}
]} ]}
@ -108,6 +123,8 @@ fi
if [ "$ASSUME_YES" -eq 0 ]; then if [ "$ASSUME_YES" -eq 0 ]; then
echo "About to install these chains ($COUNT addresses in the pool):" echo "About to install these chains ($COUNT addresses in the pool):"
sed 's/^/ /' "$WORK/chains.json" 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 "Verify every contract against Circle's list — the only source that counts:"
echo " $CIRCLE_URL" echo " $CIRCLE_URL"
printf 'Contracts verified? Type yes to continue: ' printf 'Contracts verified? Type yes to continue: '