This commit is contained in:
parent
1d6b04c4f7
commit
2a4e2c1c85
8 changed files with 1381 additions and 41 deletions
|
|
@ -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.
|
||||
|
||||
module;
|
||||
// The one place this codebase reaches past the standard library: durability.
|
||||
// std::ofstream::flush() reaches the kernel, not the disk, and there is no
|
||||
// portable "make this actually persistent" in C++ — so the cursor write below
|
||||
// needs fsync(2), and fsync needs a file descriptor. Included in the global
|
||||
// module fragment, which is what a module unit has instead of plain includes.
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
module Catcrafts.Server;
|
||||
|
||||
import std;
|
||||
|
|
@ -71,6 +79,27 @@ using namespace Crafter;
|
|||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
namespace {
|
||||
|
||||
// Flush one path all the way to the platter (or the drive's cache, which is as
|
||||
// far as fsync promises). Files and directories both, because a durable rename
|
||||
// needs the directory synced too, and only the directory case may be opened
|
||||
// read-only.
|
||||
bool FsyncPath(const std::filesystem::path& path, bool isDirectory) {
|
||||
const int fd = ::open(path.c_str(), isDirectory ? (O_RDONLY | O_DIRECTORY)
|
||||
: O_WRONLY);
|
||||
if (fd < 0) return false;
|
||||
const int rc = ::fsync(fd);
|
||||
// Report the fsync's verdict, not the close's, but still close: leaking a
|
||||
// descriptor per issued address would outlast any single order.
|
||||
const bool ok = rc == 0;
|
||||
::close(fd);
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
// keccak256("balanceOf(address)")[0..4). A constant of the ERC-20 ABI, not a
|
||||
|
|
@ -169,12 +198,24 @@ std::optional<std::int64_t> Pow10(int n) {
|
|||
} // namespace
|
||||
|
||||
// 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
|
||||
// unambiguously "covers any invoice this shop will ever issue", and saturating
|
||||
// there keeps every comparison downstream in ordinary signed arithmetic.
|
||||
// A value that does not fit is nullopt ("could not determine"), never a
|
||||
// saturated maximum: int64 base units is already far past EURC's whole supply,
|
||||
// so anything bigger is a broken or hostile node rather than a large balance,
|
||||
// and the one thing it must not do is satisfy the covering comparison.
|
||||
// Exported so the self-test can drive it with canned RPC bodies, the same way
|
||||
// ParseMolliePayment is driven — the HTTP around it is thin, the decoding is
|
||||
// where a mistake would cost money.
|
||||
// True when the reply carries exactly the numeric id we sent. Absent or
|
||||
// non-numeric is false: an answer that will not say which question it belongs
|
||||
// to is not evidence about a balance.
|
||||
bool JsonRpcIdIs(std::string_view json, std::int64_t want) {
|
||||
auto doc = Json::Parse(json);
|
||||
if (!doc || !doc->IsObject()) return false;
|
||||
const Json::Value* id = doc->Find("id");
|
||||
if (!id || id->type != Json::Type::Number) return false;
|
||||
return static_cast<std::int64_t>(id->number) == want;
|
||||
}
|
||||
|
||||
std::optional<std::int64_t> ParseEthCallUint(std::string_view json) {
|
||||
auto doc = Json::Parse(json);
|
||||
if (!doc || !doc->IsObject()) return std::nullopt;
|
||||
|
|
@ -200,9 +241,22 @@ std::optional<std::int64_t> ParseEthCallUint(std::string_view json) {
|
|||
if (c >= '0' && c <= '9') digit = c - '0';
|
||||
else if (c >= 'a' && c <= 'f') 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) {
|
||||
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;
|
||||
}
|
||||
|
|
@ -238,8 +292,36 @@ std::optional<std::vector<EurcChain>> ParseEurcChains(std::string_view json) {
|
|||
if (!IsAddress(c.contract)) return std::nullopt;
|
||||
if (!ParseEndpoint(c.rpcUrl)) return std::nullopt;
|
||||
// 2 is the floor because amounts arrive as cents; anything below it
|
||||
// cannot represent the invoice at all.
|
||||
if (c.decimals < 2 || c.decimals > 18) return std::nullopt;
|
||||
// cannot represent the invoice at all. The ceiling is NOT 18 (the ERC-20
|
||||
// maximum) but what the arithmetic can actually carry: RequiredUnits
|
||||
// multiplies cents by 10^(decimals-2), so at 18 decimals any invoice
|
||||
// over €9.22 overflows int64 and returns nullopt — and nullopt means
|
||||
// "unknown, retry", so the order would never settle AND never lapse,
|
||||
// silently, forever. A limit the maths cannot honour is not a limit.
|
||||
// 12 leaves room for every invoice this shop can issue (10^10 cents,
|
||||
// a hundred million euro) against every real EURC deployment, which is
|
||||
// 6 everywhere Circle has issued it.
|
||||
if (c.decimals < 2 || c.decimals > 12) return std::nullopt;
|
||||
// The block tag is interpolated into the eth_call params array, so it
|
||||
// is the one field that must be an allowlist rather than a shape check.
|
||||
// Left unvalidated it took anything: a typo silenced the chain
|
||||
// permanently (an unknown tag makes every call fail, which is nullopt
|
||||
// forever — the same never-settles-never-lapses trap as above), and a
|
||||
// value containing a quote closed the JSON string and appended further
|
||||
// params, reaching the state-override slot on nodes that implement it.
|
||||
static constexpr std::string_view kTags[] = {
|
||||
"finalized", "safe", "latest", "earliest", "pending"
|
||||
};
|
||||
const bool namedTag = std::ranges::find(kTags, c.blockTag) != std::end(kTags);
|
||||
// A specific block number is legitimate and is hex-quantity shaped.
|
||||
const bool hexTag = c.blockTag.size() > 2 && c.blockTag.size() <= 18
|
||||
&& c.blockTag.starts_with("0x")
|
||||
&& std::ranges::all_of(
|
||||
std::string_view(c.blockTag).substr(2),
|
||||
[](unsigned char ch) {
|
||||
return std::isxdigit(ch) != 0;
|
||||
});
|
||||
if (!namedTag && !hexTag) return std::nullopt;
|
||||
// "latest" is accepted but is a foot-gun worth naming: it reports state
|
||||
// that a reorg can still take back.
|
||||
if (c.blockTag == "latest") {
|
||||
|
|
@ -247,6 +329,44 @@ std::optional<std::vector<EurcChain>> ParseEurcChains(std::string_view json) {
|
|||
"eurc: chain '{}' watches block_tag=latest — a reorg can "
|
||||
"un-pay a settled order; prefer 'finalized'", c.name);
|
||||
}
|
||||
// Circle's own EURC deployments, compiled in. NOT a refusal: Circle can
|
||||
// deploy to a new chain, and a shop that cannot be pointed at one until
|
||||
// this file is edited is worse than one that warns. But a contract that
|
||||
// merely LOOKS like an address is otherwise checked by nobody —
|
||||
// IsAddress accepts any 40 hex digits, EIP-55 is deliberately not
|
||||
// verified, and asking balanceOf of the wrong token means a dust
|
||||
// balance of something else can cover an invoice. So when the chain is
|
||||
// one we know, say so loudly.
|
||||
struct KnownContract { std::string_view chain; std::string_view contract; };
|
||||
static constexpr KnownContract kCircle[] = {
|
||||
{ "base", "0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42" },
|
||||
{ "ethereum", "0x1abaea1f7c830bd89acc67ec4af516284b1bc33c" },
|
||||
};
|
||||
for (const KnownContract& known : kCircle) {
|
||||
if (known.chain == c.name && known.contract != c.contract) {
|
||||
std::println(std::cerr,
|
||||
"eurc: WARNING: chain '{}' points at contract {} but "
|
||||
"Circle's EURC on that chain is {} — a wrong contract "
|
||||
"means watching the wrong token. Verify against "
|
||||
"developers.circle.com/stablecoins/eurc-contract-addresses",
|
||||
c.name, c.contract, known.contract);
|
||||
}
|
||||
}
|
||||
// Two chains sharing a name is not a naming nit: the HTTP clients are
|
||||
// held in a map keyed by name, so the second entry silently reuses the
|
||||
// first one's connection and its requests go to the FIRST host. One
|
||||
// chain then goes unwatched, and during a testnet rehearsal a testnet
|
||||
// balance could settle a mainnet order. The pool loader already refuses
|
||||
// duplicate addresses for the same class of reason.
|
||||
for (const EurcChain& seen : out) {
|
||||
if (seen.name == c.name) {
|
||||
std::println(std::cerr,
|
||||
"eurc: two chains are both named '{}' — names key the "
|
||||
"connection map, so one of them would never be queried",
|
||||
c.name);
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
out.push_back(std::move(c));
|
||||
}
|
||||
if (out.empty()) return std::nullopt;
|
||||
|
|
@ -286,7 +406,36 @@ public:
|
|||
bool Load() {
|
||||
if (!LoadChains()) return false;
|
||||
if (!LoadPool()) return false;
|
||||
// One lock and one (initially empty) connection slot per chain, both
|
||||
// created here so neither map is ever structurally modified again.
|
||||
// That is what makes it safe for two chains to be in Call at the same
|
||||
// time under different locks: operator[] on a missing key would insert,
|
||||
// and inserting into a shared map from two threads is a race the
|
||||
// per-chain locks could not see.
|
||||
for (const EurcChain& chain : chains_) {
|
||||
connLocks_.emplace(chain.name, std::make_unique<std::mutex>());
|
||||
clients_.emplace(chain.name, nullptr);
|
||||
}
|
||||
cursor_ = ReadCursor();
|
||||
// The cursor is an index into a SPECIFIC pool file, but nothing in it
|
||||
// ever said which — so a cursor and a pool that do not belong together
|
||||
// used to load silently. Two routine operator actions produce exactly
|
||||
// that: restoring an older ledger backup (the closing advice in
|
||||
// tools/enable-eurc.sh has the operator back the cursor up alongside
|
||||
// orders.jsonl, and restoring rewinds it), and replacing the pool with
|
||||
// one from a different seed (the stale cursor then skips the new
|
||||
// pool's head while every old order's index resolves to a different
|
||||
// address, so the reconciler watches the wrong place and those orders
|
||||
// never settle).
|
||||
//
|
||||
// A stamp file next to the cursor closes both. It records how many
|
||||
// lines the pool had and a digest of the addresses the cursor has
|
||||
// ALREADY issued — the prefix that must never change, since those are
|
||||
// published. A pool that still starts with the same issued prefix and
|
||||
// has only grown is a legitimate append; anything else is a refusal
|
||||
// with the reason spelled out, because guessing here reissues live
|
||||
// addresses.
|
||||
if (!CheckPoolStamp()) return false;
|
||||
if (cursor_ >= pool_.size()) {
|
||||
std::println(std::cerr,
|
||||
"eurc: address pool is exhausted ({} of {} used) — top it "
|
||||
|
|
@ -356,8 +505,21 @@ public:
|
|||
|
||||
std::optional<PaidStatus> CheckPaid(const std::string& payId,
|
||||
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);
|
||||
if (!parts) return PaidStatus{ PayState::Dead, {} };
|
||||
const std::string& address = parts->address;
|
||||
|
|
@ -491,6 +653,17 @@ private:
|
|||
|
||||
const std::optional<std::string> res = Call(chain, body);
|
||||
if (!res) return std::nullopt;
|
||||
// The response's id must be the one we sent. On a fresh connection per
|
||||
// call this is belt-and-braces, but the client keeps connections alive
|
||||
// between polls, and a pipelined or mismatched reply read as this
|
||||
// address's balance is the one decoding mistake that could settle the
|
||||
// wrong order. Cheap to check, so check it.
|
||||
if (!JsonRpcIdIs(*res, 1)) {
|
||||
std::println(std::cerr,
|
||||
"eurc: chain '{}' answered with a different request id — "
|
||||
"discarding rather than reading it as this balance", chain.name);
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::optional<std::int64_t> units = ParseEthCallUint(*res);
|
||||
if (!units) {
|
||||
std::println(std::cerr, "eurc: chain '{}' returned an undecodable balance: {}",
|
||||
|
|
@ -502,11 +675,19 @@ private:
|
|||
|
||||
// One JSON-RPC POST; nullopt on transport failure or a non-2xx answer. The
|
||||
// reconciler treats nullopt as "unknown, retry" — never as unpaid or dead.
|
||||
// Called WITHOUT the rail mutex held — see the note on CheckPaid. What it
|
||||
// needs instead is exclusive use of this chain's connection, which is its
|
||||
// own lock, per chain: two chains can be in flight at once, and neither
|
||||
// blocks a buyer's checkout.
|
||||
std::optional<std::string> Call(const EurcChain& chain, const std::string& body) {
|
||||
const std::optional<Endpoint> ep = ParseEndpoint(chain.rpcUrl);
|
||||
if (!ep) return std::nullopt;
|
||||
std::mutex& connLock = ConnLockFor(chain.name);
|
||||
std::lock_guard conn(connLock);
|
||||
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) {
|
||||
client = ep->tls
|
||||
? std::make_unique<Crafter::ClientHTTP1>(
|
||||
|
|
@ -533,7 +714,9 @@ private:
|
|||
return res.body;
|
||||
} catch (const std::exception& e) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -615,14 +798,134 @@ private:
|
|||
return true;
|
||||
}
|
||||
|
||||
std::filesystem::path StampPath() const {
|
||||
std::filesystem::path p = cfg_.eurcPoolPath;
|
||||
p += ".issued";
|
||||
return p;
|
||||
}
|
||||
|
||||
// A cheap, dependency-free digest of the issued prefix. Not a security
|
||||
// hash and not trying to be: the threat is an operator mistake — a
|
||||
// restored backup, a swapped pool — not someone forging a stamp they
|
||||
// already have write access to. FNV-1a over the issued addresses in order
|
||||
// catches every reordering, substitution and truncation that matters.
|
||||
std::string IssuedDigest(std::size_t upTo) const {
|
||||
std::uint64_t h = 0xcbf29ce484222325ULL;
|
||||
for (std::size_t i = 0; i < upTo && i < pool_.size(); ++i) {
|
||||
for (const unsigned char c : pool_[i]) {
|
||||
h = (h ^ c) * 0x100000001b3ULL;
|
||||
}
|
||||
h = (h ^ '\n') * 0x100000001b3ULL;
|
||||
}
|
||||
return std::format("{:016x}", h);
|
||||
}
|
||||
|
||||
// Verify the cursor belongs to this pool, then record the new stamp.
|
||||
// Missing stamp with a zero cursor is a fresh pool; missing stamp with a
|
||||
// non-zero cursor is a pool from before stamping existed, which is
|
||||
// accepted once (there is nothing to compare against) and stamped now.
|
||||
bool CheckPoolStamp() {
|
||||
if (cursor_ == std::numeric_limits<std::size_t>::max()) return true; // already refusing
|
||||
|
||||
std::ifstream in(StampPath(), std::ios::binary);
|
||||
if (in) {
|
||||
std::size_t stampedCount = 0;
|
||||
std::size_t stampedCursor = 0;
|
||||
std::string stampedDigest;
|
||||
if (!(in >> stampedCount >> stampedCursor >> stampedDigest)) {
|
||||
std::println(std::cerr,
|
||||
"eurc: pool stamp '{}' is unreadable — refusing rather "
|
||||
"than risk reissuing a published address. Delete it only "
|
||||
"if you are certain the cursor matches the pool.",
|
||||
StampPath().string());
|
||||
return false;
|
||||
}
|
||||
if (stampedCursor > cursor_) {
|
||||
std::println(std::cerr,
|
||||
"eurc: the cursor went BACKWARDS ({} now, {} before) — "
|
||||
"a restored backup or a reverted write. Refusing: the "
|
||||
"addresses between the two are already published and "
|
||||
"reissuing one would settle two orders on one payment. "
|
||||
"To recover, set the cursor file to at least {} once you "
|
||||
"have confirmed against the order ledger which addresses "
|
||||
"really went out.",
|
||||
cursor_, stampedCursor, stampedCursor);
|
||||
return false;
|
||||
}
|
||||
if (pool_.size() < stampedCount) {
|
||||
std::println(std::cerr,
|
||||
"eurc: the pool SHRANK ({} lines now, {} before) — it is "
|
||||
"append-only. Refusing rather than reindexing addresses "
|
||||
"already bound to live orders.",
|
||||
pool_.size(), stampedCount);
|
||||
return false;
|
||||
}
|
||||
if (stampedDigest != IssuedDigest(stampedCursor)) {
|
||||
std::println(std::cerr,
|
||||
"eurc: the pool's first {} addresses — the ones already "
|
||||
"issued — are not the ones this cursor was written "
|
||||
"against. This is a different pool (a new seed?) with an "
|
||||
"old cursor. Refusing: every existing order's address "
|
||||
"would resolve somewhere else.",
|
||||
stampedCursor);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Record where we are now. A write failure is a warning, not a
|
||||
// refusal: the check is a safety net over the cursor, and refusing to
|
||||
// start over an un-writable net would be its own outage.
|
||||
if (!WriteStamp(cursor_)) {
|
||||
std::println(std::cerr, "eurc: WARNING: could not write the pool stamp '{}'",
|
||||
StampPath().string());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Written BEFORE the cursor it describes, deliberately. If the machine dies
|
||||
// between the two, the stamp is ahead of the cursor and the next load sees
|
||||
// "the cursor went backwards" and refuses — which is the outcome we want,
|
||||
// because the address for that index is already out. The reverse order
|
||||
// would leave the rewind invisible and hand the address out twice.
|
||||
bool WriteStamp(std::size_t value) const {
|
||||
std::filesystem::path tmp = StampPath();
|
||||
tmp += ".tmp";
|
||||
{
|
||||
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
|
||||
if (!out) return false;
|
||||
out << pool_.size() << ' ' << value << ' ' << IssuedDigest(value) << '\n';
|
||||
out.flush();
|
||||
if (!out) return false;
|
||||
}
|
||||
if (!FsyncPath(tmp, /*isDirectory=*/false)) return false;
|
||||
std::error_code ec;
|
||||
std::filesystem::rename(tmp, StampPath(), ec);
|
||||
return !ec;
|
||||
}
|
||||
|
||||
// The cursor is the high-water mark of addresses ever issued. Missing reads
|
||||
// as zero (a fresh pool); anything unparseable is fatal at load rather than
|
||||
// silently rewinding to the start of a pool whose head is already published.
|
||||
std::size_t ReadCursor() const {
|
||||
std::ifstream in(CursorPath(), std::ios::binary);
|
||||
if (!in) return 0;
|
||||
// Read the WHOLE file and parse it strictly. `in >> value` stops at the
|
||||
// first non-digit, so it accepted "5 GARBAGE" as 5, "3.9" as 3 and "+4"
|
||||
// as 4 — a cursor file corrupted into any of those shapes would have
|
||||
// been believed, and believing a too-small cursor reissues addresses
|
||||
// that are already published against live orders.
|
||||
std::string text{ std::istreambuf_iterator<char>(in),
|
||||
std::istreambuf_iterator<char>() };
|
||||
std::string_view body = text;
|
||||
while (!body.empty() && (body.back() == '\n' || body.back() == '\r'
|
||||
|| body.back() == ' ' || body.back() == '\t')) {
|
||||
body.remove_suffix(1);
|
||||
}
|
||||
std::size_t value = 0;
|
||||
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 "
|
||||
"the pool as exhausted rather than reissuing",
|
||||
CursorPath().string());
|
||||
|
|
@ -632,8 +935,30 @@ private:
|
|||
}
|
||||
|
||||
bool WriteCursor(std::size_t value) const {
|
||||
// Write-then-rename so a crash mid-write cannot leave a truncated
|
||||
// cursor that reads as a smaller number than the addresses already out.
|
||||
// Write-then-rename AND fsync, in that order, because the two protect
|
||||
// against different crashes and only one of them was here before.
|
||||
//
|
||||
// Rename alone survives a process crash: a reader sees either the old
|
||||
// cursor or the new one, never a half-written one. It does NOT survive
|
||||
// a machine crash — without fsync the bytes may still be in the page
|
||||
// cache when the power goes, and the rename can be durable while the
|
||||
// data it points at is not. Both post-crash outcomes are the money bug
|
||||
// this file's header calls unrecoverable: a cursor that rewinds hands
|
||||
// the next order an address already published against a live one (two
|
||||
// buyers, one address, and CheckPaid compares the address's TOTAL
|
||||
// balance, so one payment settles both), and a cursor that lands empty
|
||||
// reads as unparseable and refuses the rail.
|
||||
//
|
||||
// So: fsync the temp file, rename, then fsync the DIRECTORY, which is
|
||||
// what makes the rename itself durable. This costs one flush per
|
||||
// issued address, on a path that issues at most one per checkout.
|
||||
// Stamp first — see WriteStamp for why this order is the safe one.
|
||||
if (!WriteStamp(value)) {
|
||||
std::println(std::cerr,
|
||||
"eurc: WARNING: could not write the pool stamp '{}' — a power "
|
||||
"cut from here could rewind the cursor undetected",
|
||||
StampPath().string());
|
||||
}
|
||||
std::filesystem::path tmp = CursorPath();
|
||||
tmp += ".tmp";
|
||||
{
|
||||
|
|
@ -643,9 +968,23 @@ private:
|
|||
out.flush();
|
||||
if (!out) return false;
|
||||
}
|
||||
if (!FsyncPath(tmp, /*isDirectory=*/false)) return false;
|
||||
std::error_code 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 {
|
||||
|
|
@ -654,8 +993,19 @@ private:
|
|||
return p;
|
||||
}
|
||||
|
||||
// One connection lock per chain, created at load and never rehashed after,
|
||||
// so ConnLockFor needs no lock of its own. Sized from chains_ in Load.
|
||||
std::mutex& ConnLockFor(const std::string& name) {
|
||||
auto it = connLocks_.find(name);
|
||||
// Every chain gets an entry in Load; a name that is not there cannot
|
||||
// reach here, but falling back to the rail mutex is safer than a
|
||||
// dangling reference if that ever stops being true.
|
||||
return it == connLocks_.end() ? mutex_ : *it->second;
|
||||
}
|
||||
|
||||
RailConfig cfg_;
|
||||
std::vector<EurcChain> chains_;
|
||||
std::map<std::string, std::unique_ptr<std::mutex>> connLocks_;
|
||||
std::vector<std::string> pool_;
|
||||
std::size_t cursor_ = 0;
|
||||
std::mutex mutex_;
|
||||
|
|
|
|||
Loading…
Reference in a new issue