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

This commit is contained in:
Jorijn van der Graaf 2026-08-19 23:55:01 +02:00
commit 2a4e2c1c85
8 changed files with 1381 additions and 41 deletions

4
.gitignore vendored
View file

@ -26,3 +26,7 @@ orders.jsonl
# Local Claude Code permissions — dev-machine tooling config, not project code. # Local Claude Code permissions — dev-machine tooling config, not project code.
.claude/ .claude/
# generated EURC address pools: publishing one links every donation address
# together on-chain — the privacy design forbids exactly that
eurc-pool*.txt

View file

@ -61,6 +61,14 @@ No permission is granted to copy, modify, distribute, or create derivative works
// there is no cost to waiting when the destination is our own wallet. // there is no cost to waiting when the destination is our own wallet.
module; 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; module Catcrafts.Server;
import std; import std;
@ -71,6 +79,27 @@ using namespace Crafter;
namespace Catcrafts::Server { 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 { namespace {
// keccak256("balanceOf(address)")[0..4). A constant of the ERC-20 ABI, not a // keccak256("balanceOf(address)")[0..4). A constant of the ERC-20 ABI, not a
@ -169,12 +198,24 @@ std::optional<std::int64_t> Pow10(int n) {
} // namespace } // namespace
// A 32-byte uint256 hex word, as eth_call returns it, reduced to an int64. // A 32-byte uint256 hex word, as eth_call returns it, reduced to an int64.
// SATURATES rather than wraps: a balance larger than int64 can hold is still // A value that does not fit is nullopt ("could not determine"), never a
// unambiguously "covers any invoice this shop will ever issue", and saturating // saturated maximum: int64 base units is already far past EURC's whole supply,
// there keeps every comparison downstream in ordinary signed arithmetic. // 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 // 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 // ParseMolliePayment is driven — the HTTP around it is thin, the decoding is
// where a mistake would cost money. // 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) { std::optional<std::int64_t> ParseEthCallUint(std::string_view json) {
auto doc = Json::Parse(json); auto doc = Json::Parse(json);
if (!doc || !doc->IsObject()) return std::nullopt; if (!doc || !doc->IsObject()) return std::nullopt;
@ -200,9 +241,22 @@ std::optional<std::int64_t> ParseEthCallUint(std::string_view json) {
if (c >= '0' && c <= '9') digit = c - '0'; if (c >= '0' && c <= '9') digit = c - '0';
else if (c >= 'a' && c <= 'f') digit = c - 'a' + 10; else if (c >= 'a' && c <= 'f') digit = c - 'a' + 10;
else digit = c - 'A' + 10; else digit = c - 'A' + 10;
// Saturate on overflow instead of wrapping. // 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) { if (out > (std::numeric_limits<std::int64_t>::max() - digit) / 16) {
return std::numeric_limits<std::int64_t>::max(); 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; out = out * 16 + digit;
} }
@ -238,8 +292,36 @@ std::optional<std::vector<EurcChain>> ParseEurcChains(std::string_view json) {
if (!IsAddress(c.contract)) return std::nullopt; if (!IsAddress(c.contract)) return std::nullopt;
if (!ParseEndpoint(c.rpcUrl)) return std::nullopt; if (!ParseEndpoint(c.rpcUrl)) return std::nullopt;
// 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. // cannot represent the invoice at all. The ceiling is NOT 18 (the ERC-20
if (c.decimals < 2 || c.decimals > 18) return std::nullopt; // 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 // "latest" is accepted but is a foot-gun worth naming: it reports state
// that a reorg can still take back. // that a reorg can still take back.
if (c.blockTag == "latest") { if (c.blockTag == "latest") {
@ -247,6 +329,44 @@ std::optional<std::vector<EurcChain>> ParseEurcChains(std::string_view json) {
"eurc: chain '{}' watches block_tag=latest — a reorg can " "eurc: chain '{}' watches block_tag=latest — a reorg can "
"un-pay a settled order; prefer 'finalized'", c.name); "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)); out.push_back(std::move(c));
} }
if (out.empty()) return std::nullopt; if (out.empty()) return std::nullopt;
@ -286,7 +406,36 @@ public:
bool Load() { bool Load() {
if (!LoadChains()) return false; if (!LoadChains()) return false;
if (!LoadPool()) 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_) {
connLocks_.emplace(chain.name, std::make_unique<std::mutex>());
clients_.emplace(chain.name, nullptr);
}
cursor_ = ReadCursor(); 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()) { if (cursor_ >= pool_.size()) {
std::println(std::cerr, std::println(std::cerr,
"eurc: address pool is exhausted ({} of {} used) — top it " "eurc: address pool is exhausted ({} of {} used) — top it "
@ -356,8 +505,21 @@ public:
std::optional<PaidStatus> CheckPaid(const std::string& payId, std::optional<PaidStatus> CheckPaid(const std::string& payId,
std::int64_t expectedMinor) override { std::int64_t expectedMinor) override {
std::lock_guard lock(mutex_); // 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); const std::optional<PayIdParts> parts = SplitPayId(payId);
if (!parts) return PaidStatus{ PayState::Dead, {} }; if (!parts) return PaidStatus{ PayState::Dead, {} };
const std::string& address = parts->address; const std::string& address = parts->address;
@ -491,6 +653,17 @@ private:
const std::optional<std::string> res = Call(chain, body); const std::optional<std::string> res = Call(chain, 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
// 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); const std::optional<std::int64_t> units = ParseEthCallUint(*res);
if (!units) { if (!units) {
std::println(std::cerr, "eurc: chain '{}' returned an undecodable balance: {}", std::println(std::cerr, "eurc: chain '{}' returned an undecodable balance: {}",
@ -502,11 +675,19 @@ private:
// One JSON-RPC POST; nullopt on transport failure or a non-2xx answer. The // 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. // 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& body) { std::optional<std::string> Call(const EurcChain& chain, const std::string& body) {
const std::optional<Endpoint> ep = ParseEndpoint(chain.rpcUrl); const std::optional<Endpoint> ep = ParseEndpoint(chain.rpcUrl);
if (!ep) return std::nullopt; if (!ep) return std::nullopt;
std::mutex& connLock = ConnLockFor(chain.name);
std::lock_guard conn(connLock);
try { try {
std::unique_ptr<Crafter::ClientHTTP1>& client = clients_[chain.name]; const auto slot = clients_.find(chain.name);
if (slot == clients_.end()) return std::nullopt; // not a loaded chain
std::unique_ptr<Crafter::ClientHTTP1>& client = slot->second;
if (!client) { if (!client) {
client = ep->tls client = ep->tls
? std::make_unique<Crafter::ClientHTTP1>( ? std::make_unique<Crafter::ClientHTTP1>(
@ -533,7 +714,9 @@ 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());
clients_[chain.name].reset(); // dial fresh next time if (const auto slot = clients_.find(chain.name); slot != clients_.end()) {
slot->second.reset(); // dial fresh next time
}
return std::nullopt; return std::nullopt;
} }
} }
@ -615,14 +798,134 @@ private:
return true; 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 // 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 // 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. // silently rewinding to the start of a pool whose head is already published.
std::size_t ReadCursor() const { std::size_t ReadCursor() const {
std::ifstream in(CursorPath(), std::ios::binary); std::ifstream in(CursorPath(), std::ios::binary);
if (!in) return 0; 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; std::size_t value = 0;
if (!(in >> value)) { 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 " std::println(std::cerr, "eurc: cursor file '{}' is unreadable — treating "
"the pool as exhausted rather than reissuing", "the pool as exhausted rather than reissuing",
CursorPath().string()); CursorPath().string());
@ -632,8 +935,30 @@ private:
} }
bool WriteCursor(std::size_t value) const { bool WriteCursor(std::size_t value) const {
// Write-then-rename so a crash mid-write cannot leave a truncated // Write-then-rename AND fsync, in that order, because the two protect
// cursor that reads as a smaller number than the addresses already out. // 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(); std::filesystem::path tmp = CursorPath();
tmp += ".tmp"; tmp += ".tmp";
{ {
@ -643,9 +968,23 @@ private:
out.flush(); out.flush();
if (!out) return false; if (!out) return false;
} }
if (!FsyncPath(tmp, /*isDirectory=*/false)) return false;
std::error_code ec; std::error_code ec;
std::filesystem::rename(tmp, CursorPath(), ec); std::filesystem::rename(tmp, CursorPath(), ec);
return !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 CursorPath() const {
@ -654,8 +993,19 @@ private:
return p; 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.
return it == connLocks_.end() ? mutex_ : *it->second;
}
RailConfig cfg_; RailConfig cfg_;
std::vector<EurcChain> chains_; std::vector<EurcChain> chains_;
std::map<std::string, std::unique_ptr<std::mutex>> connLocks_;
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

@ -483,6 +483,26 @@ constexpr std::size_t kMaxSubmissionsPerPeer = 6;
constexpr std::size_t kMaxSubmissionsPerWindow = 240; constexpr std::size_t kMaxSubmissionsPerWindow = 240;
constexpr auto kRateWindow = std::chrono::minutes(10); constexpr auto kRateWindow = std::chrono::minutes(10);
// A SECOND, tighter budget, for crypto submissions only.
//
// Choosing the crypto rail spends a receiving address out of a finite pool
// that only an offline wallet ceremony can refill, and the address is spent
// per SUBMISSION rather than per payment — an order nobody ever pays has
// still consumed one. Under the general budget alone, a stranger needs no
// account, no card and no money to walk the pool to zero (six per peer is
// plenty when a default pool is a hundred addresses), and then no buyer can
// choose crypto until the owner is at a desk with paper.
//
// So crypto gets its own smaller allowance on the same window and the same
// peer key. A real buyer picks crypto once, maybe twice after a mistyped
// field; nobody legitimately opens six crypto orders in ten minutes. The
// global leg is the backstop against a spread-out flood, sized so a broad
// attack costs many addresses rather than the whole pool.
constexpr std::size_t kMaxCryptoPerPeer = 2;
constexpr std::size_t kMaxCryptoPerWindow = 20;
std::deque<RatePoint> gRecentCrypto;
std::unordered_map<std::string, std::deque<RatePoint>> gRecentCryptoPerPeer;
bool RateLimitAllows(std::string_view peer) { bool RateLimitAllows(std::string_view peer) {
const auto now = std::chrono::steady_clock::now(); const auto now = std::chrono::steady_clock::now();
std::lock_guard lock(gRateMutex); std::lock_guard lock(gRateMutex);
@ -511,6 +531,71 @@ bool RateLimitAllows(std::string_view peer) {
return true; return true;
} }
// The crypto leg of the same limiter, charged only when the buyer picked the
// rail that spends an address. Deliberately a separate budget rather than a
// smaller kMaxSubmissionsPerPeer: tightening the general limit would punish
// the ordinary buyer who fixes a form error, and it is not form errors that
// exhaust the pool.
bool CryptoRateLimitAllows(std::string_view peer) {
const auto now = std::chrono::steady_clock::now();
std::lock_guard lock(gRateMutex);
auto expire = [&](std::deque<RatePoint>& seen) {
while (!seen.empty() && now - seen.front() > kRateWindow) seen.pop_front();
};
expire(gRecentCrypto);
if (gRecentCrypto.size() >= kMaxCryptoPerWindow) return false;
if (!peer.empty()) {
// Same leak-avoidance as the general limiter: expire every peer and
// drop the emptied entries rather than keeping a row per address that
// ever submitted.
std::erase_if(gRecentCryptoPerPeer, [&](auto& entry) {
expire(entry.second);
return entry.second.empty();
});
std::deque<RatePoint>& seen = gRecentCryptoPerPeer[std::string(peer)];
if (seen.size() >= kMaxCryptoPerPeer) return false;
seen.push_back(now);
}
gRecentCrypto.push_back(now);
return true;
}
// The inverse of NowIso8601, for the one caller that needs an order's real age:
// exactly "YYYY-MM-DDTHH:MM:SSZ", which is the only shape this codebase writes.
// nullopt for anything else — a ledger line from another tool, or a truncated
// write — so the caller can fall back rather than trust a half-parsed date.
// (std::chrono::parse would be the obvious tool and is not in this libc++.)
std::optional<std::chrono::sys_seconds> ParseIso8601Utc(std::string_view s) {
if (s.size() != 20 || s[4] != '-' || s[7] != '-' || s[10] != 'T'
|| s[13] != ':' || s[16] != ':' || s[19] != 'Z') {
return std::nullopt;
}
auto num = [&](std::size_t at, std::size_t len) -> std::optional<int> {
int v = 0;
const auto [end, ec] =
std::from_chars(s.data() + at, s.data() + at + len, v);
if (ec != std::errc{} || end != s.data() + at + len) return std::nullopt;
return v;
};
const auto y = num(0, 4), mo = num(5, 2), d = num(8, 2);
const auto h = num(11, 2), mi = num(14, 2), sec = num(17, 2);
if (!y || !mo || !d || !h || !mi || !sec) return std::nullopt;
if (*mo < 1 || *mo > 12 || *d < 1 || *d > 31) return std::nullopt;
if (*h > 23 || *mi > 59 || *sec > 60) return std::nullopt;
const std::chrono::year_month_day ymd{ std::chrono::year{ *y },
std::chrono::month{
static_cast<unsigned>(*mo) },
std::chrono::day{
static_cast<unsigned>(*d) } };
if (!ymd.ok()) return std::nullopt;
return std::chrono::sys_days{ ymd } + std::chrono::hours{ *h }
+ std::chrono::minutes{ *mi } + std::chrono::seconds{ *sec };
}
// RFC 3339 UTC. Recorded so the order log can be read chronologically // RFC 3339 UTC. Recorded so the order log can be read chronologically
// without depending on file order. // without depending on file order.
std::string NowIso8601() { std::string NowIso8601() {
@ -646,6 +731,16 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
return reject({{ "", "Too many submissions just now — please try again shortly." }}, return reject({{ "", "Too many submissions just now — please try again shortly." }},
parsed.value, "429"); parsed.value, "429");
} }
// Crypto pays a second, tighter toll: this submission is about to spend a
// receiving address that only an offline wallet ceremony can replace. The
// charge happens here rather than at CreateLink so the general budget is
// already spent too — a peer probing the pool burns their ordinary
// checkout allowance at the same time.
if (wantsCrypto && !CryptoRateLimitAllows(peer)) {
return reject({{ "pay", "Too many crypto orders from here just now — please "
"try again shortly, or pick bank or card." }},
parsed.value, "429");
}
std::int64_t unitMinor = 0; std::int64_t unitMinor = 0;
Money::Totals totals; Money::Totals totals;
@ -977,7 +1072,21 @@ void ReconcilerLoop(const std::stop_token& stop) {
auto [it, inserted] = seen.try_emplace(order.token, Seen{ now, now }); auto [it, inserted] = seen.try_emplace(order.token, Seen{ now, now });
if (!inserted) { if (!inserted) {
using namespace std::chrono; using namespace std::chrono;
const auto age = now - it->second.first; // Age from the ORDER, not from when this process first saw it.
// Steady-clock first-seen restarts the seven days on every
// deploy, so a year-old awaiting order gets polled for another
// week after each one — wasted calls against both providers,
// growing with every abandoned order the ledger has ever held.
// The record's timestamp is the real age; a timestamp that will
// not parse falls back to the old behaviour rather than
// dropping an order that might be live.
const std::optional<std::chrono::sys_seconds> placed =
ParseIso8601Utc(order.createdAt);
const auto age =
placed ? std::chrono::duration_cast<
std::chrono::steady_clock::duration>(
std::chrono::system_clock::now() - *placed)
: now - it->second.first;
if (age > hours(24 * 7)) continue; if (age > hours(24 * 7)) continue;
const auto due = age > hours(2) const auto due = age > hours(2)
? seconds(minutes(10)) ? seconds(minutes(10))
@ -1180,7 +1289,14 @@ int Serve(std::uint16_t port) {
}); });
ListenerHTTP1 listener(port, std::move(routes), std::move(fallback)); ListenerHTTP1 listener(port, std::move(routes), std::move(fallback));
std::println("catcrafts-server: listening on 127.0.0.1:{} " // std::cerr like every other diagnostic, and not for consistency alone:
// under journald stdout is a pipe, so it is FULLY buffered — this line
// once sat invisible for hours (or died unflushed with the process) while
// deploy tooling polled the journal for it as a liveness signal. stderr
// is unbuffered; the one line that announces what the server IS must not
// arrive after the fact.
std::println(std::cerr,
"catcrafts-server: listening on 127.0.0.1:{} "
"({} projects, {} posts, payments: bank={} crypto={})", "({} projects, {} posts, payments: bank={} crypto={})",
port, gContent.projects.size(), gContent.posts.size(), port, gContent.projects.size(), gContent.posts.size(),
gRails.bank ? gRails.bank->Name() : "off", gRails.bank ? gRails.bank->Name() : "off",

View file

@ -327,22 +327,47 @@ int main(int argc, char** argv) {
out = Server::MakeRail(cfg); out = Server::MakeRail(cfg);
// "off" is a legitimate choice and yields no rail; a mode nobody // "off" is a legitimate choice and yields no rail; a mode nobody
// recognises silently would too, which is how a typo becomes a // recognises silently would too, which is how a typo becomes a
// shop that quietly stops taking one kind of money. // shop that quietly stops taking one kind of money. So an
// unrecognised mode is still a hard refusal — but a mode we DO
// recognise, failing on its runtime data, is not the same fault
// and must not be answered the same way (see below).
if (!out && mode != "off") { if (!out && mode != "off") {
// "eurc" is the one mode that constructs to nullptr for a static constexpr std::string_view kKnown[] = {
// reason other than a typo — its chains file or address pool "mollie", "eurc", "fake", "fake-crypto"
// did not load, and MakeEurcRail has already said which and };
// why. Repeating "unknown rail" over the top of that would const bool known = std::ranges::find(kKnown, mode) != std::end(kKnown);
// send the operator looking for a spelling mistake. if (!known) {
if (mode == "eurc") {
std::println(std::cerr,
"catcrafts-server: the eurc rail could not load its "
"chains file ({}) or address pool ({}) — see above",
eurcChainsPath.string(), eurcPoolPath.string());
} else {
std::println(std::cerr, "catcrafts-server: unknown rail '{}'", mode); std::println(std::cerr, "catcrafts-server: unknown rail '{}'", mode);
return false;
} }
return false; // A KNOWN rail that could not load its data — for "eurc",
// its chains file or address pool. MakeEurcRail has already
// said which and why, so this only names the files.
//
// This is a WARNING and not a refusal, and the reason is the
// blast radius. An exhausted address pool is a state a
// stranger can drive the shop into (every crypto checkout
// spends an address), and answering it with "the process
// refuses to boot" turns a spent pool into the entire website
// down — every page, the bank rail included — held down by
// Restart=always until a human runs an offline wallet
// ceremony. That trade is never right: this box already
// "serves the whole site minus checkout" when no credentials
// exist at all (see the slot notes above), and one rail's
// data going bad is strictly less than that.
//
// Silent degradation is the other failure to avoid, so the
// warning is loud, the listening line below reports
// crypto=off, and tools/enable-eurc.sh refuses to call an
// enable successful without the rail's own load line.
std::println(std::cerr,
"catcrafts-server: WARNING: the '{}' rail could not load "
"its chains file ({}) or address pool ({}) — see above. "
"CONTINUING WITHOUT IT: that payment choice is off and "
"the rest of the site is unaffected.",
mode, eurcChainsPath.string(), eurcPoolPath.string());
out.reset();
return true;
} }
return true; return true;
}; };

View file

@ -747,15 +747,15 @@ SafeHtml RenderPayFieldset(const Form::Checkout& prev, SafeHtml payError) {
R"(<legend>How you want to pay</legend>)" R"(<legend>How you want to pay</legend>)"
R"(<label class="pay-option">)" R"(<label class="pay-option">)"
R"(<input type="radio" name="pay"{}{}>)" R"(<input type="radio" name="pay"{}{}>)"
R"(<span><strong>Bank or card</strong> iDEAL, card, or a plain )" R"(<span><strong>Bank or card</strong><br>iDEAL, card, or a plain )"
R"(bank transfer. Handled by Mollie.</span></label>)" R"(bank transfer. Handled by Mollie.</span></label>)"
R"(<label class="pay-option">)" R"(<label class="pay-option">)"
R"(<input type="radio" name="pay"{}{}>)" R"(<input type="radio" name="pay"{}{}>)"
R"(<span><strong>Cryptocurrency</strong> EURC, a euro )" R"(<span><strong>Cryptocurrency</strong><br>EURC, a euro )"
R"(stablecoin, paid from your own wallet. The amount to send is the )" R"(stablecoin. The amount to send is the )"
R"(euro total exactly, no exchange rate; the receiving address and )" R"(euro total exactly, with no exchange rate; the receiving address )"
R"(the networks it takes appear on the order page, and stay reserved )" R"(and the networks it takes appear on the order page, and stay )"
R"(for about a day.</span></label>)" R"(reserved for about a day.</span></label>)"
R"({})" R"({})"
R"(</fieldset>)", R"(</fieldset>)",
Attr("value", std::string(Form::kPayBank)), Attr("value", std::string(Form::kPayBank)),
@ -974,7 +974,7 @@ SafeHtml RenderCheckoutForm(const Product& product,
// With the choice rendered below, the fieldset lists the methods and // With the choice rendered below, the fieldset lists the methods and
// the lede would only repeat half of them. // the lede would only repeat half of them.
offerCrypto ? SafeHtml{} offerCrypto ? SafeHtml{}
: Raw(": iDEAL, card, or a plain bank transfer, handled by Mollie"), : Raw(": iDEAL, card, or a bank transfer, handled by Mollie"),
Escape(Form::kShipsToMessage), Escape(Form::kShipsToMessage),
Escape(Form::kSanctionsMessage), Escape(Form::kSanctionsMessage),
CustomsNote(), CustomsNote(),

View file

@ -41,10 +41,18 @@ int main() {
"eurc: €570.43 as 6-decimal base units, full 32-byte word"); "eurc: €570.43 as 6-decimal base units, full 32-byte word");
Check(ParseEthCallUint(R"({"result":"0xFF"})") == 255, Check(ParseEthCallUint(R"({"result":"0xFF"})") == 255,
"eurc: uppercase hex accepted"); "eurc: uppercase hex accepted");
// 2^63 does not fit; the decoder must saturate, never wrap to negative. // 2^63 does not fit. It must not wrap to negative, and it must not
Check(ParseEthCallUint(R"({"result":"0x8000000000000000"})") // saturate to INT64_MAX either: a saturated maximum satisfies the covering
== std::numeric_limits<std::int64_t>::max(), // comparison in CheckPaid, so a node answering 0xffff…ff would mark any
"eurc: overflow saturates"); // order paid. int64 base units is already past EURC's entire supply, so an
// unrepresentable balance is a broken or lying node — "unknown", which
// neither settles nor lapses.
Check(!ParseEthCallUint(R"({"result":"0x8000000000000000"})").has_value(),
"eurc: an unrepresentable balance is unknown, not a covering maximum");
Check(!ParseEthCallUint(
R"({"result":"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"})")
.has_value(),
"eurc: a lying node's 2^256-1 does not read as paid");
Check(!ParseEthCallUint( Check(!ParseEthCallUint(
R"({"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"x"}})") R"({"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"x"}})")
.has_value(), .has_value(),
@ -85,6 +93,47 @@ int main() {
Check(!Server::ParseEurcChains("garbage").has_value(), Check(!Server::ParseEurcChains("garbage").has_value(),
"eurc: malformed chains file rejected"); "eurc: malformed chains file rejected");
// Two chains with one name would share a connection-map slot, so the
// second's requests would go to the first's host and one chain would never
// be watched at all.
Check(!Server::ParseEurcChains(R"({"chains":[
{"name":"base","rpc":"https://a.example",
"contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"},
{"name":"base","rpc":"https://b.example",
"contract":"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c"}]})").has_value(),
"eurc: duplicate chain names reject the whole file");
// 18 decimals is legal ERC-20 but not representable here: cents × 10^16
// overflows int64 above €9.22, and the overflow answers "unknown", which
// neither settles nor lapses an order.
Check(!Server::ParseEurcChains(R"({"chains":[
{"name":"base","rpc":"https://a.example","decimals":18,
"contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})").has_value(),
"eurc: decimals beyond what the arithmetic carries is refused");
// block_tag reaches the eth_call params array. A quote in it closed the
// JSON string and appended another param; a typo silenced the chain.
Check(!Server::ParseEurcChains(R"({"chains":[
{"name":"base","rpc":"https://a.example","block_tag":"latest\",\"0xdead",
"contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})").has_value(),
"eurc: a block_tag that injects JSON is refused");
Check(!Server::ParseEurcChains(R"({"chains":[
{"name":"base","rpc":"https://a.example","block_tag":"finalised",
"contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})").has_value(),
"eurc: a misspelled block_tag is refused, not silently never-settling");
Check(!Server::ParseEurcChains(R"({"chains":[
{"name":"base","rpc":"https://a.example","block_tag":"",
"contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})").has_value(),
"eurc: an empty block_tag is refused");
{
// A specific block number stays legitimate.
const auto ok = Server::ParseEurcChains(R"({"chains":[
{"name":"base","rpc":"https://a.example","block_tag":"0x1b4",
"contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})");
Check(ok.has_value() && (*ok)[0].blockTag == "0x1b4",
"eurc: a hex block number is still accepted");
}
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;

288
tools/enable-eurc.sh Executable file
View file

@ -0,0 +1,288 @@
#!/bin/sh
# Turn on the self-hosted EURC rail in production.
#
# tools/enable-eurc.sh POOL_FILE validate, install, restart, verify
# tools/enable-eurc.sh POOL_FILE --append top up an existing pool (append-only)
# --host NAME ssh destination (default: hetzner — root via ~/.ssh/config)
# --chains FILE use this chains JSON instead of the built-in mainnet pair
# (how you rehearse against Sepolia — see deploy/README.md)
# --yes skip the contract confirmation prompt
#
# POOL_FILE is the list your wallet generated at home: one receiving address
# per line, # comments allowed. COPY it from the wallet, never retype — the
# server cannot verify EIP-55 checksums (and neither can this script: that
# needs keccak-256, which nothing in a stock shell provides), so a mistyped
# but well-formed address would be accepted and published to real buyers.
#
# What this automates is deploy/README.md "The EURC rail": install the chains
# file and the address pool, add EURC_CHAINS= to payments.env (setting that
# variable IS selecting the rail), restart, and prove the journal now says
# crypto=eurc. If the restart refuses — the rail's loader treats a bad pool as
# a startup refusal, not a degraded mode — the env line is rolled back and the
# service restarted bank-only, so a botched enable never takes checkout down.
#
# The remote pool is APPEND-ONLY once live: <pool>.cursor is an index into it,
# so rewriting or reordering re-issues addresses already bound to old orders.
# That is why an existing pool is a refusal without --append, and why --append
# adds only addresses the pool does not already hold.
set -eu
UNIT=catcrafts-server
CIRCLE_URL="https://developers.circle.com/stablecoins/eurc-contract-addresses"
POOL_SRC=""
HOST=hetzner
CHAINS_SRC=""
APPEND=0
ASSUME_YES=0
while [ $# -gt 0 ]; do
case "$1" in
--host) HOST="${2:?--host needs a value}"; shift 2 ;;
--chains) CHAINS_SRC="${2:?--chains needs a value}"; shift 2 ;;
--append) APPEND=1; shift ;;
--yes) ASSUME_YES=1; shift ;;
-h|--help) sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
-*) echo "enable-eurc: unknown option $1 (try --help)" >&2; exit 1 ;;
*) [ -n "$POOL_SRC" ] && { echo "enable-eurc: one pool file only" >&2; exit 1; }
POOL_SRC="$1"; shift ;;
esac
done
[ -n "$POOL_SRC" ] || { echo "enable-eurc: usage: tools/enable-eurc.sh POOL_FILE [--host H] [--chains F] [--append] [--yes]" >&2; exit 1; }
[ -r "$POOL_SRC" ] || { echo "enable-eurc: cannot read pool file '$POOL_SRC'" >&2; exit 1; }
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT INT TERM
# ── validate the pool locally, by the server's own rules ─────────────────
#
# Mirror of EurcRail::LoadPool: strip # comments and whitespace, lowercase,
# require 0x + 40 hex, refuse duplicates (case-insensitively — the server
# lowercases before comparing, so "0xAB.." and "0xab.." are the same reuse
# bug). Refusing here means the service is never restarted into a refusal.
awk '
{ sub(/#.*/, ""); gsub(/^[ \t]+|[ \t\r]+$/, ""); if ($0 == "") next
addr = tolower($0)
if (addr !~ /^0x[0-9a-f]{40}$/) { printf "enable-eurc: pool line %d is not an address\n", NR > "/dev/stderr"; bad = 1; exit 1 }
if (addr in seen) { printf "enable-eurc: pool line %d duplicates an earlier address\n", NR > "/dev/stderr"; bad = 1; exit 1 }
seen[addr] = 1; print addr }
END { if (!bad && length(seen) == 0) { print "enable-eurc: pool file holds no addresses" > "/dev/stderr"; exit 1 } }
' "$POOL_SRC" > "$WORK/pool.txt"
COUNT=$(wc -l < "$WORK/pool.txt")
# The rail warns at 25 addresses left; starting anywhere near that is starting
# on the reserve tank.
if [ "$COUNT" -lt 50 ] && [ "$APPEND" -eq 0 ]; then
echo "enable-eurc: WARNING: only $COUNT addresses — the low-water warning fires at 25 left. Consider generating more before going live." >&2
fi
# ── the chains file ──────────────────────────────────────────────────────
#
# Built-in default is mainnet Base + Ethereum, Base first because file order
# is display order and its note is the fee nudge the buyer sees. Contracts
# must match Circle's list and nowhere else — matching the CONTRACT, not the
# ticker, is what makes a fake "EURC" worthless here — hence the prompt.
if [ -n "$CHAINS_SRC" ]; then
[ -r "$CHAINS_SRC" ] || { echo "enable-eurc: cannot read chains file '$CHAINS_SRC'" >&2; exit 1; }
cp "$CHAINS_SRC" "$WORK/chains.json"
else
cat > "$WORK/chains.json" <<'JSON'
{"chains": [
{"name": "base", "rpc": "https://mainnet.base.org",
"contract": "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42",
"chain_id": 8453, "note": "lowest network fees"},
{"name": "ethereum", "rpc": "https://ethereum-rpc.publicnode.com",
"contract": "0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c",
"chain_id": 1}
]}
JSON
fi
if command -v jq >/dev/null 2>&1; then
jq -e '.chains | length > 0' "$WORK/chains.json" >/dev/null \
|| { echo "enable-eurc: chains file is not valid chains JSON" >&2; exit 1; }
fi
if [ "$ASSUME_YES" -eq 0 ]; then
echo "About to install these chains ($COUNT addresses in the pool):"
sed 's/^/ /' "$WORK/chains.json"
echo "Verify every contract against Circle's list — the only source that counts:"
echo " $CIRCLE_URL"
printf 'Contracts verified? Type yes to continue: '
read -r answer
[ "$answer" = "yes" ] || { echo "enable-eurc: aborted — nothing was touched." >&2; exit 1; }
fi
# ── the remote apply script ──────────────────────────────────────────────
#
# Everything travels in ONE ssh connection (a tar of chains.json, pool.txt and
# this script, unpacked and run on the box) because the host firewalls ssh
# with `ufw limit 22/tcp`: a chatty multi-connection script trips the limiter
# and the failure looks like a network fault, not a firewall choice.
cat > "$WORK/apply.sh" <<'REMOTE'
#!/bin/sh
set -eu
UNIT=catcrafts-server
work="$(dirname "$0")"
# Derive paths from the unit itself rather than hardcoding: the unit is the
# authority on where the ledger and env file live.
ORDERS=$(systemctl cat "$UNIT" | sed -n 's/^[[:space:]]*--orders=\([^ \\]*\).*/\1/p' | head -1)
[ -n "$ORDERS" ] || ORDERS=/var/lib/catcrafts/orders.jsonl
ENVF=$(systemctl cat "$UNIT" | sed -n 's/^EnvironmentFile=-\{0,1\}\(.*\)/\1/p' | head -1)
[ -n "$ENVF" ] || ENVF=/etc/catcrafts/payments.env
[ -e "$ENVF" ] || { echo "apply: $ENVF does not exist — is the Mollie side even configured?" >&2; exit 1; }
# Respect an explicit EURC_POOL override if one is already configured;
# otherwise the server's default: the pool hangs off the orders path.
POOL=$(sed -n 's/^EURC_POOL=//p' "$ENVF" | head -1)
[ -n "$POOL" ] || POOL="$ORDERS.eurc-addresses"
CHAINS_DEST=/etc/catcrafts/eurc-chains.json
SVC_USER=$(systemctl cat "$UNIT" | sed -n 's/^User=//p' | head -1)
[ -n "$SVC_USER" ] || SVC_USER=catcrafts
if [ -e "$POOL" ] && [ "${APPEND:-0}" != 1 ]; then
echo "apply: $POOL already exists. The pool is append-only (the cursor is an index into it) — rerun with --append to top it up. Refusing to overwrite." >&2
exit 1
fi
if [ -e "$POOL" ]; then
# Append only genuinely new addresses: a duplicate in the pool is a
# startup refusal, so filtering here is what keeps --append rerunnable.
added=0
while IFS= read -r addr; do
if ! grep -qixF "$addr" "$POOL"; then
printf '%s\n' "$addr" >> "$POOL"
added=$((added + 1))
fi
done < "$work/pool.txt"
echo "apply: appended $added new address(es) to $POOL"
else
install -o "$SVC_USER" -g "$SVC_USER" -m 0600 "$work/pool.txt" "$POOL"
echo "apply: installed $(wc -l < "$POOL") addresses at $POOL"
fi
# World-readable is fine — chain names and Circle's public contracts are not
# secrets, and the service user must be able to read it.
install -m 0644 "$work/chains.json" "$CHAINS_DEST"
# Append the rail selection, newline-safely, and remember whether WE are the
# ones who added it.
#
# Two bugs lived in the one-liner this replaces. First, payments.env is
# hand-maintained, so its last line may have no trailing newline — and then a
# bare >> concatenated onto it, turning MOLLIE_API_KEY=live_abc into
# MOLLIE_API_KEY=live_abcEURC_CHAINS=/etc/... : both rails broken, and the
# rollback below could not even see it because the line no longer started with
# EURC_CHAINS. Second, the rollback deleted EVERY EURC_CHAINS= line, including
# one the operator had set themselves pointing at a different chains file — so
# a timeout during an --append top-up of an already-live rail switched crypto
# off on a host where it had been working.
ADDED_ENV_LINE=0
if grep -q '^EURC_CHAINS=' "$ENVF"; then
echo "apply: EURC_CHAINS is already set in $ENVF — leaving it as it is"
else
# A file that does not end in a newline gets one before the append.
if [ -s "$ENVF" ] && [ "$(tail -c1 "$ENVF" | od -An -c | tr -d ' \n')" != '\\n' ]; then
printf '\n' >> "$ENVF"
fi
printf 'EURC_CHAINS=%s\n' "$CHAINS_DEST" >> "$ENVF"
ADDED_ENV_LINE=1
fi
# Not a bare command: under `set -e` a non-zero restart would abort the script
# here and the rollback below would never run, leaving EURC_CHAINS set and the
# service down. Type=simple returns 0 even when the process dies immediately,
# so today this is defensive — but the unit type is not this script's to
# guarantee.
systemctl restart "$UNIT" || echo "apply: systemctl restart reported failure" >&2
# Success is evidence from THIS invocation, and never the stdout listening
# line: under journald stdout is fully buffered, so that line arrives minutes
# to hours late or dies unflushed with the process — polling for it rolled
# back two perfectly healthy enables. What is prompt and truthful:
# - the rail loader's stderr line ("eurc: N chains, ...") — printed only
# when EURC_CHAINS selected the rail AND the pool + chains loaded, and
# - /api/healthz answering — the server is actually serving.
# Newer binaries print the listening line to stderr too; accept it as a
# third, sufficient signal when it shows up.
INV=$(systemctl show -p InvocationID --value "$UNIT")
PORT=$(systemctl cat "$UNIT" | sed -n 's/^ExecStart=.*--serve \([0-9]*\).*/\1/p' | head -1)
[ -n "$PORT" ] || PORT=8081
echo "apply: waiting for the rail to prove itself (up to 60s)..."
eurc_line=""
healthy=0
tries=0
while [ "$tries" -lt 60 ]; do
[ "$(systemctl is-active "$UNIT" || true)" = failed ] && break
inv_log=$(journalctl "_SYSTEMD_INVOCATION_ID=$INV" --no-pager 2>/dev/null || true)
if printf '%s' "$inv_log" | grep -q 'crypto=eurc'; then
eurc_line=$(printf '%s' "$inv_log" | grep 'crypto=eurc' | tail -1)
healthy=1
break
fi
eurc_line=$(printf '%s' "$inv_log" | grep -E 'eurc: [0-9]+ chains' | tail -1 || true)
if [ -n "$eurc_line" ] && curl -sf --max-time 2 "http://127.0.0.1:$PORT/api/healthz" >/dev/null 2>&1; then
healthy=1
break
fi
sleep 1
tries=$((tries + 1))
done
if systemctl is-active --quiet "$UNIT" && [ "$healthy" -eq 1 ]; then
printf '%s\n' "$eurc_line"
echo "apply: healthz answers on :$PORT"
echo "apply: EURC rail is LIVE"
exit 0
fi
# The enable failed — put the shop back the way it was before saying so. The
# pool and chains files stay (harmless without the env line); only the rail
# selection is rolled back, so bank checkout is never collateral damage.
echo "apply: service did not come up with crypto=eurc — rolling back" >&2
if [ "$ADDED_ENV_LINE" = 1 ]; then
# Only the exact line this run appended, and only if we appended it.
sed -i "\\|^EURC_CHAINS=$CHAINS_DEST\$|d" "$ENVF"
else
echo "apply: EURC_CHAINS was already configured before this run — leaving it" >&2
echo "apply: alone. Crypto stays as the operator had it; only the pool and" >&2
echo "apply: chains files this run installed remain." >&2
fi
systemctl restart "$UNIT" || true
echo "apply: rolled back. The refusal:" >&2
journalctl -u "$UNIT" --since "-60 seconds" --no-pager | tail -15 >&2
exit 1
REMOTE
tar -cf "$WORK/payload.tar" -C "$WORK" chains.json pool.txt apply.sh
echo "enable-eurc: applying on $HOST (one ssh connection)..."
if ! ssh "$HOST" "work=\$(mktemp -d) && trap 'rm -rf \"\$work\"' EXIT && tar xf - -C \"\$work\" && APPEND=$APPEND sh \"\$work/apply.sh\"" < "$WORK/payload.tar"; then
# apply.sh narrates its own rollback when the restart refused; a failure
# before that (ssh, tar, an apply refusal) means nothing was ever touched.
# Claiming either state from here would be a guess, so point at the output.
echo "enable-eurc: FAILED — see above for how far it got. apply.sh rolls back the rail selection itself if the restart refused." >&2
exit 1
fi
# Public proof, informative only: the donation form should now offer the
# payment choice (it renders no radios when only one rail exists).
if [ "$HOST" = hetzner ]; then
if curl -s --max-time 10 https://catcrafts.net/shop/donation | grep -q 'name="pay"'; then
echo "enable-eurc: catcrafts.net/shop/donation now offers the payment choice."
else
echo "enable-eurc: WARNING: the live donation page does not show the pay choice yet — check by hand." >&2
fi
fi
cat <<'DONE'
enable-eurc: done. Next:
1. Smoke test with real money, smallest denomination: a 1 euro donation
paid in EURC on Base exercises the whole path for ~a cent of fees.
2. Add the pool (+.cursor) to whatever backs up orders.jsonl.
3. Decide the sweep cadence BEFORE the first real donation arrives —
the shop holds EURC until you sell it for euros at an exchange.
DONE

508
tools/gen-eurc-pool.sh Executable file

File diff suppressed because one or more lines are too long