This commit is contained in:
parent
2a4e2c1c85
commit
47d302a9a4
4 changed files with 364 additions and 39 deletions
|
|
@ -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_;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue