All checks were successful
Deploy / build-deploy (push) Successful in 2m26s
1248 lines
61 KiB
C++
1248 lines
61 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::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 address is left-padded into a
|
|
// 32-byte ABI word: 24 zero bytes, then the 20 address bytes.
|
|
std::string data;
|
|
data.reserve(2 + 8 + 64);
|
|
data += kBalanceOfSelector;
|
|
data.append(24 * 2, '0');
|
|
data += address.substr(2);
|
|
|
|
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
|