This commit is contained in:
parent
1d6b04c4f7
commit
2a4e2c1c85
8 changed files with 1381 additions and 41 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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_;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
288
tools/enable-eurc.sh
Executable 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
508
tools/gen-eurc-pool.sh
Executable file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue