diff --git a/.gitignore b/.gitignore index b18d24c..3f5b9d1 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ orders.jsonl # Local Claude Code permissions — dev-machine tooling config, not project code. .claude/ + +# generated EURC address pools: publishing one links every donation address +# together on-chain — the privacy design forbids exactly that +eurc-pool*.txt diff --git a/server/implementations/Catcrafts.Server-Eurc.cpp b/server/implementations/Catcrafts.Server-Eurc.cpp index 7c84eca..9204b32 100644 --- a/server/implementations/Catcrafts.Server-Eurc.cpp +++ b/server/implementations/Catcrafts.Server-Eurc.cpp @@ -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 +#include + 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 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(id->number) == want; +} + std::optional ParseEthCallUint(std::string_view json) { auto doc = Json::Parse(json); if (!doc || !doc->IsObject()) return std::nullopt; @@ -200,9 +241,22 @@ std::optional 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::max() - digit) / 16) { - return std::numeric_limits::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> 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> 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()); + 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 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 parts = SplitPayId(payId); if (!parts) return PaidStatus{ PayState::Dead, {} }; const std::string& address = parts->address; @@ -491,6 +653,17 @@ private: const std::optional 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 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 Call(const EurcChain& chain, const std::string& body) { const std::optional ep = ParseEndpoint(chain.rpcUrl); if (!ep) return std::nullopt; + std::mutex& connLock = ConnLockFor(chain.name); + std::lock_guard conn(connLock); try { - std::unique_ptr& 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& client = slot->second; if (!client) { client = ep->tls ? std::make_unique( @@ -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::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(in), + std::istreambuf_iterator() }; + 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 chains_; + std::map> connLocks_; std::vector pool_; std::size_t cursor_ = 0; std::mutex mutex_; diff --git a/server/implementations/Catcrafts.Server-Http.cpp b/server/implementations/Catcrafts.Server-Http.cpp index 5e4080c..8177a10 100644 --- a/server/implementations/Catcrafts.Server-Http.cpp +++ b/server/implementations/Catcrafts.Server-Http.cpp @@ -483,6 +483,26 @@ constexpr std::size_t kMaxSubmissionsPerPeer = 6; constexpr std::size_t kMaxSubmissionsPerWindow = 240; 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 gRecentCrypto; +std::unordered_map> gRecentCryptoPerPeer; + bool RateLimitAllows(std::string_view peer) { const auto now = std::chrono::steady_clock::now(); std::lock_guard lock(gRateMutex); @@ -511,6 +531,71 @@ bool RateLimitAllows(std::string_view peer) { 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& 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& 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 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 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(*mo) }, + std::chrono::day{ + static_cast(*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 // without depending on file order. 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." }}, 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; 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 }); if (!inserted) { 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 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; const auto due = age > hours(2) ? seconds(minutes(10)) @@ -1180,7 +1289,14 @@ int Serve(std::uint16_t port) { }); 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={})", port, gContent.projects.size(), gContent.posts.size(), gRails.bank ? gRails.bank->Name() : "off", diff --git a/server/implementations/main.cpp b/server/implementations/main.cpp index e078065..1dc0153 100644 --- a/server/implementations/main.cpp +++ b/server/implementations/main.cpp @@ -327,22 +327,47 @@ int main(int argc, char** argv) { out = Server::MakeRail(cfg); // "off" is a legitimate choice and yields no rail; a mode nobody // 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") { - // "eurc" is the one mode that constructs to nullptr for a - // reason other than a typo — its chains file or address pool - // did not load, and MakeEurcRail has already said which and - // why. Repeating "unknown rail" over the top of that would - // send the operator looking for a spelling mistake. - 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 { + static constexpr std::string_view kKnown[] = { + "mollie", "eurc", "fake", "fake-crypto" + }; + const bool known = std::ranges::find(kKnown, mode) != std::end(kKnown); + if (!known) { 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; }; diff --git a/shared/interfaces/Catcrafts.Shared-Views.cppm b/shared/interfaces/Catcrafts.Shared-Views.cppm index b2a6306..8e5e6b5 100644 --- a/shared/interfaces/Catcrafts.Shared-Views.cppm +++ b/shared/interfaces/Catcrafts.Shared-Views.cppm @@ -747,15 +747,15 @@ SafeHtml RenderPayFieldset(const Form::Checkout& prev, SafeHtml payError) { R"(How you want to pay)" R"()" R"()" + R"(Cryptocurrency
EURC, a euro )" + R"(stablecoin. The amount to send is the )" + R"(euro total exactly, with no exchange rate; the receiving address )" + R"(and the networks it takes appear on the order page, and stay )" + R"(reserved for about a day.
)" R"({})" R"()", 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 // the lede would only repeat half of them. 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::kSanctionsMessage), CustomsNote(), diff --git a/tests/ShouldParseEurcChains/main.cpp b/tests/ShouldParseEurcChains/main.cpp index 19969b9..4e16e9f 100644 --- a/tests/ShouldParseEurcChains/main.cpp +++ b/tests/ShouldParseEurcChains/main.cpp @@ -41,10 +41,18 @@ int main() { "eurc: €570.43 as 6-decimal base units, full 32-byte word"); Check(ParseEthCallUint(R"({"result":"0xFF"})") == 255, "eurc: uppercase hex accepted"); - // 2^63 does not fit; the decoder must saturate, never wrap to negative. - Check(ParseEthCallUint(R"({"result":"0x8000000000000000"})") - == std::numeric_limits::max(), - "eurc: overflow saturates"); + // 2^63 does not fit. It must not wrap to negative, and it must not + // saturate to INT64_MAX either: a saturated maximum satisfies the covering + // comparison in CheckPaid, so a node answering 0xffff…ff would mark any + // 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( R"({"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"x"}})") .has_value(), @@ -85,6 +93,47 @@ int main() { Check(!Server::ParseEurcChains("garbage").has_value(), "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) { std::println(std::cerr, "{} check(s) failed", failures); return 1; diff --git a/tools/enable-eurc.sh b/tools/enable-eurc.sh new file mode 100755 index 0000000..849fbad --- /dev/null +++ b/tools/enable-eurc.sh @@ -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: .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 diff --git a/tools/gen-eurc-pool.sh b/tools/gen-eurc-pool.sh new file mode 100755 index 0000000..b40d20f --- /dev/null +++ b/tools/gen-eurc-pool.sh @@ -0,0 +1,508 @@ +#!/bin/sh +# Generate the EURC receiving-address pool — and the wallet behind it. +# +# tools/gen-eurc-pool.sh [--count N] [--out FILE] generate (default 100, +# eurc-pool.txt) +# tools/gen-eurc-pool.sh verify FILE prove a paper backup +# reproduces FILE +# tools/gen-eurc-pool.sh derive --count N --out F [--start I] +# re-derive addresses from +# the paper words: recovery, +# and same-seed top-ups +# +# What this is: a BIP-39 wallet generator that never stores the wallet. It +# rolls 24 words (256-bit entropy from the OS), shows them ONCE for you to +# write on paper, quizzes three back, wipes the screen, and writes only the +# derived addresses (m/44'/60'/0'/0/i — the path every wallet speaks) to disk. +# The words on paper ARE the money: any BIP-39 wallet, hardware included, +# recovers every address and can sweep what arrived. Nothing here needs to +# stay on this machine, and nothing secret does. +# +# Pure Python stdlib — no pip, no packages, no network — so it runs on a +# machine with the cable pulled, which is how you should run it. It refuses +# to start with the network up unless you insist (--online-anyway), and it +# refuses to emit anything unless its own crypto first reproduces the +# published BIP-39/BIP-32/Keccak test vectors AND the canonical wordlist +# fingerprint: hand-rolled derivation is only tolerable because it proves +# itself against the record on every single run. (Cross-validated against +# eth-account at build time: 120 random-seed addresses, byte-identical.) +# +# This seed starting life on a networked computer is the accepted tradeoff +# for launching without hardware. The upgrade costs nothing later: the pool +# is append-only, so when a hardware wallet arrives, append ITS addresses +# (enable-eurc.sh --append), sweep the old ones, retire this seed. + +set -eu + +MODE=gen +COUNT=100 +OUT=eurc-pool.txt +ONLINE_OK=0 +START=0 + +case "${1:-}" in + verify) + MODE=verify; shift + # the pool file, then fall through to the option loop (--online-anyway + # applies here too: typing the words back in deserves the same air-gap) + [ $# -ge 1 ] && [ "${1#-}" = "$1" ] || { echo "gen-eurc-pool: verify needs a pool file" >&2; exit 1; } + OUT="$1"; shift ;; + derive) + MODE=derive; shift ;; + selftest) + MODE=selftest; shift ;; +esac +while [ $# -gt 0 ]; do + case "$1" in + --count) COUNT="${2:?--count needs a value}"; shift 2 ;; + --out) OUT="${2:?--out needs a value}"; shift 2 ;; + --start) START="${2:?--start needs a value}"; shift 2 ;; + --online-anyway) ONLINE_OK=1; shift ;; + -h|--help) sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "gen-eurc-pool: unknown argument $1 (try --help)" >&2; exit 1 ;; + esac +done + +case "$COUNT" in *[!0-9]*|'') echo "gen-eurc-pool: --count '$COUNT' is not a number" >&2; exit 1 ;; esac +case "$START" in *[!0-9]*|'') echo "gen-eurc-pool: --start '$START' is not a number" >&2; exit 1 ;; esac +if [ "$MODE" != derive ] && [ "$START" != 0 ]; then + echo "gen-eurc-pool: --start only applies to 'derive'" >&2; exit 1 +fi +[ "$COUNT" -ge 1 ] && [ "$COUNT" -le 10000 ] || { echo "gen-eurc-pool: --count $COUNT is not sane" >&2; exit 1; } + +# Generation wants an air-gap: the words exist in this process's memory and +# on this screen, and "the machine was offline" is the difference between +# trusting the OS and trusting the OS plus everything it is talking to. +# Verify mode types the words back in, so it deserves the same gate. +if [ "$MODE" != selftest ] && [ "$ONLINE_OK" -eq 0 ]; then + # Ask "is there a route off this machine", not "does an interface report + # carrier". operstate was the wrong question: WireGuard, OpenVPN tun and + # USB tethering never set carrier and sit at "unknown" forever, so a + # machine whose ONLY route was a live VPN tunnel passed the gate and + # reported itself air-gapped. (The dev box this was written on has exactly + # such an interface.) A missing /sys also read as air-gapped. + # + # A default route is the honest test, and it fails closed: if none of the + # tools below exist, the gate blocks rather than assumes. + route="" + if command -v ip >/dev/null 2>&1; then + route=$(ip route show default 2>/dev/null | head -3) + # A route via a tunnel counts; so does any default route at all. + [ -z "$route" ] && route=$(ip -6 route show default 2>/dev/null | head -3) + elif command -v route >/dev/null 2>&1; then + route=$(route -n 2>/dev/null | awk '$1 == "0.0.0.0" { print }' | head -3) + else + echo "gen-eurc-pool: cannot tell whether this machine is online (no ip(8)" >&2 + echo " or route(8) found), and guessing 'offline' is not safe for a wallet." >&2 + echo " Disconnect and pass --online-anyway if you are certain." >&2 + exit 1 + fi + if [ -n "$route" ]; then + echo "gen-eurc-pool: this machine still has a route to the internet:" >&2 + printf ' %s\n' "$route" >&2 + echo " Pull the cable, drop the wifi, and take down any VPN tunnel — or" >&2 + echo " pass --online-anyway to accept generating wallet words online." >&2 + exit 1 + fi +fi + +PY=$(cat <<'PYSRC' +# Pure-stdlib BIP-39/BIP-32 EVM address derivation. No third-party imports — +# the whole point is running air-gapped. Every primitive is checked against +# published vectors in self_test() before anything is generated. +import hashlib, hmac, secrets, sys, unicodedata + +# ── keccak-256 (original Keccak padding 0x01, NOT NIST SHA-3) ───────── +_RC = [0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000, + 0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009, + 0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A, + 0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003, + 0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A, + 0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008] +_ROT = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61], + [28, 55, 25, 21, 56], [27, 20, 39, 8, 14]] # r[x][y] +_M = (1 << 64) - 1 + +def _rotl(v, s): + return ((v << s) | (v >> (64 - s))) & _M + +def _keccak_f(lanes): # lanes[x + 5y] + for rnd in range(24): + # theta + C = [lanes[x] ^ lanes[x + 5] ^ lanes[x + 10] ^ lanes[x + 15] ^ lanes[x + 20] + for x in range(5)] + D = [C[(x - 1) % 5] ^ _rotl(C[(x + 1) % 5], 1) for x in range(5)] + for x in range(5): + for y in range(5): + lanes[x + 5 * y] ^= D[x] + # rho + pi + B = [0] * 25 + for x in range(5): + for y in range(5): + B[y + 5 * ((2 * x + 3 * y) % 5)] = _rotl(lanes[x + 5 * y], _ROT[x][y]) + # chi + for x in range(5): + for y in range(5): + lanes[x + 5 * y] = B[x + 5 * y] ^ ((~B[(x + 1) % 5 + 5 * y]) & B[(x + 2) % 5 + 5 * y]) & _M + # iota + lanes[0] ^= _RC[rnd] + return lanes + +def keccak256(data: bytes) -> bytes: + rate = 136 + lanes = [0] * 25 + # multi-rate padding: 0x01 ... 0x80, collapsing to a single 0x81 when + # exactly one pad byte fits + q = rate - (len(data) % rate) + padded = data + (b"\x81" if q == 1 else b"\x01" + b"\x00" * (q - 2) + b"\x80") + for off in range(0, len(padded), rate): + block = padded[off:off + rate] + for i in range(rate // 8): + lanes[i] ^= int.from_bytes(block[8 * i:8 * i + 8], "little") + _keccak_f(lanes) + out = b"".join(lanes[i].to_bytes(8, "little") for i in range(4)) + return out[:32] + +# ── secp256k1 ───────────────────────────────────────────────────────── +_P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F +N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 +_G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, + 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8) + +def _pt_add(a, b): + if a is None: return b + if b is None: return a + if a[0] == b[0] and (a[1] + b[1]) % _P == 0: return None + if a == b: + lam = (3 * a[0] * a[0]) * pow(2 * a[1], -1, _P) % _P + else: + lam = (b[1] - a[1]) * pow(b[0] - a[0], -1, _P) % _P + x = (lam * lam - a[0] - b[0]) % _P + return (x, (lam * (a[0] - x) - a[1]) % _P) + +def _pt_mul(k, pt=_G): + acc = None + while k: + if k & 1: acc = _pt_add(acc, pt) + pt = _pt_add(pt, pt) + k >>= 1 + return acc + +def _compress(pt): + return bytes([2 + (pt[1] & 1)]) + pt[0].to_bytes(32, "big") + +# ── BIP-39 ──────────────────────────────────────────────────────────── +WORDS = ['abandon', 'ability', 'able', 'about', 'above', 'absent', 'absorb', 'abstract', 'absurd', 'abuse', 'access', 'accident', 'account', 'accuse', 'achieve', 'acid', 'acoustic', 'acquire', 'across', 'act', 'action', 'actor', 'actress', 'actual', 'adapt', 'add', 'addict', 'address', 'adjust', 'admit', 'adult', 'advance', 'advice', 'aerobic', 'affair', 'afford', 'afraid', 'again', 'age', 'agent', 'agree', 'ahead', 'aim', 'air', 'airport', 'aisle', 'alarm', 'album', 'alcohol', 'alert', 'alien', 'all', 'alley', 'allow', 'almost', 'alone', 'alpha', 'already', 'also', 'alter', 'always', 'amateur', 'amazing', 'among', 'amount', 'amused', 'analyst', 'anchor', 'ancient', 'anger', 'angle', 'angry', 'animal', 'ankle', 'announce', 'annual', 'another', 'answer', 'antenna', 'antique', 'anxiety', 'any', 'apart', 'apology', 'appear', 'apple', 'approve', 'april', 'arch', 'arctic', 'area', 'arena', 'argue', 'arm', 'armed', 'armor', 'army', 'around', 'arrange', 'arrest', 'arrive', 'arrow', 'art', 'artefact', 'artist', 'artwork', 'ask', 'aspect', 'assault', 'asset', 'assist', 'assume', 'asthma', 'athlete', 'atom', 'attack', 'attend', 'attitude', 'attract', 'auction', 'audit', 'august', 'aunt', 'author', 'auto', 'autumn', 'average', 'avocado', 'avoid', 'awake', 'aware', 'away', 'awesome', 'awful', 'awkward', 'axis', 'baby', 'bachelor', 'bacon', 'badge', 'bag', 'balance', 'balcony', 'ball', 'bamboo', 'banana', 'banner', 'bar', 'barely', 'bargain', 'barrel', 'base', 'basic', 'basket', 'battle', 'beach', 'bean', 'beauty', 'because', 'become', 'beef', 'before', 'begin', 'behave', 'behind', 'believe', 'below', 'belt', 'bench', 'benefit', 'best', 'betray', 'better', 'between', 'beyond', 'bicycle', 'bid', 'bike', 'bind', 'biology', 'bird', 'birth', 'bitter', 'black', 'blade', 'blame', 'blanket', 'blast', 'bleak', 'bless', 'blind', 'blood', 'blossom', 'blouse', 'blue', 'blur', 'blush', 'board', 'boat', 'body', 'boil', 'bomb', 'bone', 'bonus', 'book', 'boost', 'border', 'boring', 'borrow', 'boss', 'bottom', 'bounce', 'box', 'boy', 'bracket', 'brain', 'brand', 'brass', 'brave', 'bread', 'breeze', 'brick', 'bridge', 'brief', 'bright', 'bring', 'brisk', 'broccoli', 'broken', 'bronze', 'broom', 'brother', 'brown', 'brush', 'bubble', 'buddy', 'budget', 'buffalo', 'build', 'bulb', 'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'busy', 'butter', 'buyer', 'buzz', 'cabbage', 'cabin', 'cable', 'cactus', 'cage', 'cake', 'call', 'calm', 'camera', 'camp', 'can', 'canal', 'cancel', 'candy', 'cannon', 'canoe', 'canvas', 'canyon', 'capable', 'capital', 'captain', 'car', 'carbon', 'card', 'cargo', 'carpet', 'carry', 'cart', 'case', 'cash', 'casino', 'castle', 'casual', 'cat', 'catalog', 'catch', 'category', 'cattle', 'caught', 'cause', 'caution', 'cave', 'ceiling', 'celery', 'cement', 'census', 'century', 'cereal', 'certain', 'chair', 'chalk', 'champion', 'change', 'chaos', 'chapter', 'charge', 'chase', 'chat', 'cheap', 'check', 'cheese', 'chef', 'cherry', 'chest', 'chicken', 'chief', 'child', 'chimney', 'choice', 'choose', 'chronic', 'chuckle', 'chunk', 'churn', 'cigar', 'cinnamon', 'circle', 'citizen', 'city', 'civil', 'claim', 'clap', 'clarify', 'claw', 'clay', 'clean', 'clerk', 'clever', 'click', 'client', 'cliff', 'climb', 'clinic', 'clip', 'clock', 'clog', 'close', 'cloth', 'cloud', 'clown', 'club', 'clump', 'cluster', 'clutch', 'coach', 'coast', 'coconut', 'code', 'coffee', 'coil', 'coin', 'collect', 'color', 'column', 'combine', 'come', 'comfort', 'comic', 'common', 'company', 'concert', 'conduct', 'confirm', 'congress', 'connect', 'consider', 'control', 'convince', 'cook', 'cool', 'copper', 'copy', 'coral', 'core', 'corn', 'correct', 'cost', 'cotton', 'couch', 'country', 'couple', 'course', 'cousin', 'cover', 'coyote', 'crack', 'cradle', 'craft', 'cram', 'crane', 'crash', 'crater', 'crawl', 'crazy', 'cream', 'credit', 'creek', 'crew', 'cricket', 'crime', 'crisp', 'critic', 'crop', 'cross', 'crouch', 'crowd', 'crucial', 'cruel', 'cruise', 'crumble', 'crunch', 'crush', 'cry', 'crystal', 'cube', 'culture', 'cup', 'cupboard', 'curious', 'current', 'curtain', 'curve', 'cushion', 'custom', 'cute', 'cycle', 'dad', 'damage', 'damp', 'dance', 'danger', 'daring', 'dash', 'daughter', 'dawn', 'day', 'deal', 'debate', 'debris', 'decade', 'december', 'decide', 'decline', 'decorate', 'decrease', 'deer', 'defense', 'define', 'defy', 'degree', 'delay', 'deliver', 'demand', 'demise', 'denial', 'dentist', 'deny', 'depart', 'depend', 'deposit', 'depth', 'deputy', 'derive', 'describe', 'desert', 'design', 'desk', 'despair', 'destroy', 'detail', 'detect', 'develop', 'device', 'devote', 'diagram', 'dial', 'diamond', 'diary', 'dice', 'diesel', 'diet', 'differ', 'digital', 'dignity', 'dilemma', 'dinner', 'dinosaur', 'direct', 'dirt', 'disagree', 'discover', 'disease', 'dish', 'dismiss', 'disorder', 'display', 'distance', 'divert', 'divide', 'divorce', 'dizzy', 'doctor', 'document', 'dog', 'doll', 'dolphin', 'domain', 'donate', 'donkey', 'donor', 'door', 'dose', 'double', 'dove', 'draft', 'dragon', 'drama', 'drastic', 'draw', 'dream', 'dress', 'drift', 'drill', 'drink', 'drip', 'drive', 'drop', 'drum', 'dry', 'duck', 'dumb', 'dune', 'during', 'dust', 'dutch', 'duty', 'dwarf', 'dynamic', 'eager', 'eagle', 'early', 'earn', 'earth', 'easily', 'east', 'easy', 'echo', 'ecology', 'economy', 'edge', 'edit', 'educate', 'effort', 'egg', 'eight', 'either', 'elbow', 'elder', 'electric', 'elegant', 'element', 'elephant', 'elevator', 'elite', 'else', 'embark', 'embody', 'embrace', 'emerge', 'emotion', 'employ', 'empower', 'empty', 'enable', 'enact', 'end', 'endless', 'endorse', 'enemy', 'energy', 'enforce', 'engage', 'engine', 'enhance', 'enjoy', 'enlist', 'enough', 'enrich', 'enroll', 'ensure', 'enter', 'entire', 'entry', 'envelope', 'episode', 'equal', 'equip', 'era', 'erase', 'erode', 'erosion', 'error', 'erupt', 'escape', 'essay', 'essence', 'estate', 'eternal', 'ethics', 'evidence', 'evil', 'evoke', 'evolve', 'exact', 'example', 'excess', 'exchange', 'excite', 'exclude', 'excuse', 'execute', 'exercise', 'exhaust', 'exhibit', 'exile', 'exist', 'exit', 'exotic', 'expand', 'expect', 'expire', 'explain', 'expose', 'express', 'extend', 'extra', 'eye', 'eyebrow', 'fabric', 'face', 'faculty', 'fade', 'faint', 'faith', 'fall', 'false', 'fame', 'family', 'famous', 'fan', 'fancy', 'fantasy', 'farm', 'fashion', 'fat', 'fatal', 'father', 'fatigue', 'fault', 'favorite', 'feature', 'february', 'federal', 'fee', 'feed', 'feel', 'female', 'fence', 'festival', 'fetch', 'fever', 'few', 'fiber', 'fiction', 'field', 'figure', 'file', 'film', 'filter', 'final', 'find', 'fine', 'finger', 'finish', 'fire', 'firm', 'first', 'fiscal', 'fish', 'fit', 'fitness', 'fix', 'flag', 'flame', 'flash', 'flat', 'flavor', 'flee', 'flight', 'flip', 'float', 'flock', 'floor', 'flower', 'fluid', 'flush', 'fly', 'foam', 'focus', 'fog', 'foil', 'fold', 'follow', 'food', 'foot', 'force', 'forest', 'forget', 'fork', 'fortune', 'forum', 'forward', 'fossil', 'foster', 'found', 'fox', 'fragile', 'frame', 'frequent', 'fresh', 'friend', 'fringe', 'frog', 'front', 'frost', 'frown', 'frozen', 'fruit', 'fuel', 'fun', 'funny', 'furnace', 'fury', 'future', 'gadget', 'gain', 'galaxy', 'gallery', 'game', 'gap', 'garage', 'garbage', 'garden', 'garlic', 'garment', 'gas', 'gasp', 'gate', 'gather', 'gauge', 'gaze', 'general', 'genius', 'genre', 'gentle', 'genuine', 'gesture', 'ghost', 'giant', 'gift', 'giggle', 'ginger', 'giraffe', 'girl', 'give', 'glad', 'glance', 'glare', 'glass', 'glide', 'glimpse', 'globe', 'gloom', 'glory', 'glove', 'glow', 'glue', 'goat', 'goddess', 'gold', 'good', 'goose', 'gorilla', 'gospel', 'gossip', 'govern', 'gown', 'grab', 'grace', 'grain', 'grant', 'grape', 'grass', 'gravity', 'great', 'green', 'grid', 'grief', 'grit', 'grocery', 'group', 'grow', 'grunt', 'guard', 'guess', 'guide', 'guilt', 'guitar', 'gun', 'gym', 'habit', 'hair', 'half', 'hammer', 'hamster', 'hand', 'happy', 'harbor', 'hard', 'harsh', 'harvest', 'hat', 'have', 'hawk', 'hazard', 'head', 'health', 'heart', 'heavy', 'hedgehog', 'height', 'hello', 'helmet', 'help', 'hen', 'hero', 'hidden', 'high', 'hill', 'hint', 'hip', 'hire', 'history', 'hobby', 'hockey', 'hold', 'hole', 'holiday', 'hollow', 'home', 'honey', 'hood', 'hope', 'horn', 'horror', 'horse', 'hospital', 'host', 'hotel', 'hour', 'hover', 'hub', 'huge', 'human', 'humble', 'humor', 'hundred', 'hungry', 'hunt', 'hurdle', 'hurry', 'hurt', 'husband', 'hybrid', 'ice', 'icon', 'idea', 'identify', 'idle', 'ignore', 'ill', 'illegal', 'illness', 'image', 'imitate', 'immense', 'immune', 'impact', 'impose', 'improve', 'impulse', 'inch', 'include', 'income', 'increase', 'index', 'indicate', 'indoor', 'industry', 'infant', 'inflict', 'inform', 'inhale', 'inherit', 'initial', 'inject', 'injury', 'inmate', 'inner', 'innocent', 'input', 'inquiry', 'insane', 'insect', 'inside', 'inspire', 'install', 'intact', 'interest', 'into', 'invest', 'invite', 'involve', 'iron', 'island', 'isolate', 'issue', 'item', 'ivory', 'jacket', 'jaguar', 'jar', 'jazz', 'jealous', 'jeans', 'jelly', 'jewel', 'job', 'join', 'joke', 'journey', 'joy', 'judge', 'juice', 'jump', 'jungle', 'junior', 'junk', 'just', 'kangaroo', 'keen', 'keep', 'ketchup', 'key', 'kick', 'kid', 'kidney', 'kind', 'kingdom', 'kiss', 'kit', 'kitchen', 'kite', 'kitten', 'kiwi', 'knee', 'knife', 'knock', 'know', 'lab', 'label', 'labor', 'ladder', 'lady', 'lake', 'lamp', 'language', 'laptop', 'large', 'later', 'latin', 'laugh', 'laundry', 'lava', 'law', 'lawn', 'lawsuit', 'layer', 'lazy', 'leader', 'leaf', 'learn', 'leave', 'lecture', 'left', 'leg', 'legal', 'legend', 'leisure', 'lemon', 'lend', 'length', 'lens', 'leopard', 'lesson', 'letter', 'level', 'liar', 'liberty', 'library', 'license', 'life', 'lift', 'light', 'like', 'limb', 'limit', 'link', 'lion', 'liquid', 'list', 'little', 'live', 'lizard', 'load', 'loan', 'lobster', 'local', 'lock', 'logic', 'lonely', 'long', 'loop', 'lottery', 'loud', 'lounge', 'love', 'loyal', 'lucky', 'luggage', 'lumber', 'lunar', 'lunch', 'luxury', 'lyrics', 'machine', 'mad', 'magic', 'magnet', 'maid', 'mail', 'main', 'major', 'make', 'mammal', 'man', 'manage', 'mandate', 'mango', 'mansion', 'manual', 'maple', 'marble', 'march', 'margin', 'marine', 'market', 'marriage', 'mask', 'mass', 'master', 'match', 'material', 'math', 'matrix', 'matter', 'maximum', 'maze', 'meadow', 'mean', 'measure', 'meat', 'mechanic', 'medal', 'media', 'melody', 'melt', 'member', 'memory', 'mention', 'menu', 'mercy', 'merge', 'merit', 'merry', 'mesh', 'message', 'metal', 'method', 'middle', 'midnight', 'milk', 'million', 'mimic', 'mind', 'minimum', 'minor', 'minute', 'miracle', 'mirror', 'misery', 'miss', 'mistake', 'mix', 'mixed', 'mixture', 'mobile', 'model', 'modify', 'mom', 'moment', 'monitor', 'monkey', 'monster', 'month', 'moon', 'moral', 'more', 'morning', 'mosquito', 'mother', 'motion', 'motor', 'mountain', 'mouse', 'move', 'movie', 'much', 'muffin', 'mule', 'multiply', 'muscle', 'museum', 'mushroom', 'music', 'must', 'mutual', 'myself', 'mystery', 'myth', 'naive', 'name', 'napkin', 'narrow', 'nasty', 'nation', 'nature', 'near', 'neck', 'need', 'negative', 'neglect', 'neither', 'nephew', 'nerve', 'nest', 'net', 'network', 'neutral', 'never', 'news', 'next', 'nice', 'night', 'noble', 'noise', 'nominee', 'noodle', 'normal', 'north', 'nose', 'notable', 'note', 'nothing', 'notice', 'novel', 'now', 'nuclear', 'number', 'nurse', 'nut', 'oak', 'obey', 'object', 'oblige', 'obscure', 'observe', 'obtain', 'obvious', 'occur', 'ocean', 'october', 'odor', 'off', 'offer', 'office', 'often', 'oil', 'okay', 'old', 'olive', 'olympic', 'omit', 'once', 'one', 'onion', 'online', 'only', 'open', 'opera', 'opinion', 'oppose', 'option', 'orange', 'orbit', 'orchard', 'order', 'ordinary', 'organ', 'orient', 'original', 'orphan', 'ostrich', 'other', 'outdoor', 'outer', 'output', 'outside', 'oval', 'oven', 'over', 'own', 'owner', 'oxygen', 'oyster', 'ozone', 'pact', 'paddle', 'page', 'pair', 'palace', 'palm', 'panda', 'panel', 'panic', 'panther', 'paper', 'parade', 'parent', 'park', 'parrot', 'party', 'pass', 'patch', 'path', 'patient', 'patrol', 'pattern', 'pause', 'pave', 'payment', 'peace', 'peanut', 'pear', 'peasant', 'pelican', 'pen', 'penalty', 'pencil', 'people', 'pepper', 'perfect', 'permit', 'person', 'pet', 'phone', 'photo', 'phrase', 'physical', 'piano', 'picnic', 'picture', 'piece', 'pig', 'pigeon', 'pill', 'pilot', 'pink', 'pioneer', 'pipe', 'pistol', 'pitch', 'pizza', 'place', 'planet', 'plastic', 'plate', 'play', 'please', 'pledge', 'pluck', 'plug', 'plunge', 'poem', 'poet', 'point', 'polar', 'pole', 'police', 'pond', 'pony', 'pool', 'popular', 'portion', 'position', 'possible', 'post', 'potato', 'pottery', 'poverty', 'powder', 'power', 'practice', 'praise', 'predict', 'prefer', 'prepare', 'present', 'pretty', 'prevent', 'price', 'pride', 'primary', 'print', 'priority', 'prison', 'private', 'prize', 'problem', 'process', 'produce', 'profit', 'program', 'project', 'promote', 'proof', 'property', 'prosper', 'protect', 'proud', 'provide', 'public', 'pudding', 'pull', 'pulp', 'pulse', 'pumpkin', 'punch', 'pupil', 'puppy', 'purchase', 'purity', 'purpose', 'purse', 'push', 'put', 'puzzle', 'pyramid', 'quality', 'quantum', 'quarter', 'question', 'quick', 'quit', 'quiz', 'quote', 'rabbit', 'raccoon', 'race', 'rack', 'radar', 'radio', 'rail', 'rain', 'raise', 'rally', 'ramp', 'ranch', 'random', 'range', 'rapid', 'rare', 'rate', 'rather', 'raven', 'raw', 'razor', 'ready', 'real', 'reason', 'rebel', 'rebuild', 'recall', 'receive', 'recipe', 'record', 'recycle', 'reduce', 'reflect', 'reform', 'refuse', 'region', 'regret', 'regular', 'reject', 'relax', 'release', 'relief', 'rely', 'remain', 'remember', 'remind', 'remove', 'render', 'renew', 'rent', 'reopen', 'repair', 'repeat', 'replace', 'report', 'require', 'rescue', 'resemble', 'resist', 'resource', 'response', 'result', 'retire', 'retreat', 'return', 'reunion', 'reveal', 'review', 'reward', 'rhythm', 'rib', 'ribbon', 'rice', 'rich', 'ride', 'ridge', 'rifle', 'right', 'rigid', 'ring', 'riot', 'ripple', 'risk', 'ritual', 'rival', 'river', 'road', 'roast', 'robot', 'robust', 'rocket', 'romance', 'roof', 'rookie', 'room', 'rose', 'rotate', 'rough', 'round', 'route', 'royal', 'rubber', 'rude', 'rug', 'rule', 'run', 'runway', 'rural', 'sad', 'saddle', 'sadness', 'safe', 'sail', 'salad', 'salmon', 'salon', 'salt', 'salute', 'same', 'sample', 'sand', 'satisfy', 'satoshi', 'sauce', 'sausage', 'save', 'say', 'scale', 'scan', 'scare', 'scatter', 'scene', 'scheme', 'school', 'science', 'scissors', 'scorpion', 'scout', 'scrap', 'screen', 'script', 'scrub', 'sea', 'search', 'season', 'seat', 'second', 'secret', 'section', 'security', 'seed', 'seek', 'segment', 'select', 'sell', 'seminar', 'senior', 'sense', 'sentence', 'series', 'service', 'session', 'settle', 'setup', 'seven', 'shadow', 'shaft', 'shallow', 'share', 'shed', 'shell', 'sheriff', 'shield', 'shift', 'shine', 'ship', 'shiver', 'shock', 'shoe', 'shoot', 'shop', 'short', 'shoulder', 'shove', 'shrimp', 'shrug', 'shuffle', 'shy', 'sibling', 'sick', 'side', 'siege', 'sight', 'sign', 'silent', 'silk', 'silly', 'silver', 'similar', 'simple', 'since', 'sing', 'siren', 'sister', 'situate', 'six', 'size', 'skate', 'sketch', 'ski', 'skill', 'skin', 'skirt', 'skull', 'slab', 'slam', 'sleep', 'slender', 'slice', 'slide', 'slight', 'slim', 'slogan', 'slot', 'slow', 'slush', 'small', 'smart', 'smile', 'smoke', 'smooth', 'snack', 'snake', 'snap', 'sniff', 'snow', 'soap', 'soccer', 'social', 'sock', 'soda', 'soft', 'solar', 'soldier', 'solid', 'solution', 'solve', 'someone', 'song', 'soon', 'sorry', 'sort', 'soul', 'sound', 'soup', 'source', 'south', 'space', 'spare', 'spatial', 'spawn', 'speak', 'special', 'speed', 'spell', 'spend', 'sphere', 'spice', 'spider', 'spike', 'spin', 'spirit', 'split', 'spoil', 'sponsor', 'spoon', 'sport', 'spot', 'spray', 'spread', 'spring', 'spy', 'square', 'squeeze', 'squirrel', 'stable', 'stadium', 'staff', 'stage', 'stairs', 'stamp', 'stand', 'start', 'state', 'stay', 'steak', 'steel', 'stem', 'step', 'stereo', 'stick', 'still', 'sting', 'stock', 'stomach', 'stone', 'stool', 'story', 'stove', 'strategy', 'street', 'strike', 'strong', 'struggle', 'student', 'stuff', 'stumble', 'style', 'subject', 'submit', 'subway', 'success', 'such', 'sudden', 'suffer', 'sugar', 'suggest', 'suit', 'summer', 'sun', 'sunny', 'sunset', 'super', 'supply', 'supreme', 'sure', 'surface', 'surge', 'surprise', 'surround', 'survey', 'suspect', 'sustain', 'swallow', 'swamp', 'swap', 'swarm', 'swear', 'sweet', 'swift', 'swim', 'swing', 'switch', 'sword', 'symbol', 'symptom', 'syrup', 'system', 'table', 'tackle', 'tag', 'tail', 'talent', 'talk', 'tank', 'tape', 'target', 'task', 'taste', 'tattoo', 'taxi', 'teach', 'team', 'tell', 'ten', 'tenant', 'tennis', 'tent', 'term', 'test', 'text', 'thank', 'that', 'theme', 'then', 'theory', 'there', 'they', 'thing', 'this', 'thought', 'three', 'thrive', 'throw', 'thumb', 'thunder', 'ticket', 'tide', 'tiger', 'tilt', 'timber', 'time', 'tiny', 'tip', 'tired', 'tissue', 'title', 'toast', 'tobacco', 'today', 'toddler', 'toe', 'together', 'toilet', 'token', 'tomato', 'tomorrow', 'tone', 'tongue', 'tonight', 'tool', 'tooth', 'top', 'topic', 'topple', 'torch', 'tornado', 'tortoise', 'toss', 'total', 'tourist', 'toward', 'tower', 'town', 'toy', 'track', 'trade', 'traffic', 'tragic', 'train', 'transfer', 'trap', 'trash', 'travel', 'tray', 'treat', 'tree', 'trend', 'trial', 'tribe', 'trick', 'trigger', 'trim', 'trip', 'trophy', 'trouble', 'truck', 'true', 'truly', 'trumpet', 'trust', 'truth', 'try', 'tube', 'tuition', 'tumble', 'tuna', 'tunnel', 'turkey', 'turn', 'turtle', 'twelve', 'twenty', 'twice', 'twin', 'twist', 'two', 'type', 'typical', 'ugly', 'umbrella', 'unable', 'unaware', 'uncle', 'uncover', 'under', 'undo', 'unfair', 'unfold', 'unhappy', 'uniform', 'unique', 'unit', 'universe', 'unknown', 'unlock', 'until', 'unusual', 'unveil', 'update', 'upgrade', 'uphold', 'upon', 'upper', 'upset', 'urban', 'urge', 'usage', 'use', 'used', 'useful', 'useless', 'usual', 'utility', 'vacant', 'vacuum', 'vague', 'valid', 'valley', 'valve', 'van', 'vanish', 'vapor', 'various', 'vast', 'vault', 'vehicle', 'velvet', 'vendor', 'venture', 'venue', 'verb', 'verify', 'version', 'very', 'vessel', 'veteran', 'viable', 'vibrant', 'vicious', 'victory', 'video', 'view', 'village', 'vintage', 'violin', 'virtual', 'virus', 'visa', 'visit', 'visual', 'vital', 'vivid', 'vocal', 'voice', 'void', 'volcano', 'volume', 'vote', 'voyage', 'wage', 'wagon', 'wait', 'walk', 'wall', 'walnut', 'want', 'warfare', 'warm', 'warrior', 'wash', 'wasp', 'waste', 'water', 'wave', 'way', 'wealth', 'weapon', 'wear', 'weasel', 'weather', 'web', 'wedding', 'weekend', 'weird', 'welcome', 'west', 'wet', 'whale', 'what', 'wheat', 'wheel', 'when', 'where', 'whip', 'whisper', 'wide', 'width', 'wife', 'wild', 'will', 'win', 'window', 'wine', 'wing', 'wink', 'winner', 'winter', 'wire', 'wisdom', 'wise', 'wish', 'witness', 'wolf', 'woman', 'wonder', 'wood', 'wool', 'word', 'work', 'world', 'worry', 'worth', 'wrap', 'wreck', 'wrestle', 'wrist', 'write', 'wrong', 'yard', 'year', 'yellow', 'you', 'young', 'youth', 'zebra', 'zero', 'zone', 'zoo'] +_INDEX = {w: i for i, w in enumerate(WORDS)} + +def entropy_to_mnemonic(entropy: bytes) -> str: + cs_bits = len(entropy) * 8 // 32 + checksum = hashlib.sha256(entropy).digest() + bits = int.from_bytes(entropy, "big") << cs_bits | (checksum[0] >> (8 - cs_bits)) + total = len(entropy) * 8 + cs_bits + return " ".join(WORDS[(bits >> (total - 11 * (i + 1))) & 0x7FF] + for i in range(total // 11)) + +def mnemonic_to_entropy(mnemonic: str) -> bytes: + words = unicodedata.normalize("NFKD", mnemonic).strip().lower().split() + if len(words) not in (12, 15, 18, 21, 24): + raise ValueError(f"{len(words)} words — a mnemonic is 12/15/18/21/24") + bits = 0 + for w in words: + if w not in _INDEX: + raise ValueError(f"'{w}' is not a BIP-39 word") + bits = bits << 11 | _INDEX[w] + cs_bits = len(words) * 11 // 33 + ent_bits = len(words) * 11 - cs_bits + entropy = (bits >> cs_bits).to_bytes(ent_bits // 8, "big") + if bits & ((1 << cs_bits) - 1) != hashlib.sha256(entropy).digest()[0] >> (8 - cs_bits): + raise ValueError("checksum mismatch — a word is wrong or out of order") + return entropy + +def mnemonic_to_seed(mnemonic: str, passphrase: str = "") -> bytes: + m = unicodedata.normalize("NFKD", " ".join(mnemonic.strip().lower().split())) + s = unicodedata.normalize("NFKD", "mnemonic" + passphrase) + return hashlib.pbkdf2_hmac("sha512", m.encode(), s.encode(), 2048, 64) + +# ── BIP-32 / addresses ──────────────────────────────────────────────── +def _ckd(k: int, c: bytes, i: int): + if i >= 0x80000000: + data = b"\x00" + k.to_bytes(32, "big") + i.to_bytes(4, "big") + else: + data = _compress(_pt_mul(k)) + i.to_bytes(4, "big") + I = hmac.new(c, data, hashlib.sha512).digest() + il = int.from_bytes(I[:32], "big") + child = (il + k) % N + if il >= N or child == 0: + raise ValueError("invalid child key (astronomically unlikely) — reroll") + return child, I[32:] + +def derive_addresses(mnemonic: str, count: int, start: int = 0): + mnemonic_to_entropy(mnemonic) # validates words + checksum + seed = mnemonic_to_seed(mnemonic) + I = hmac.new(b"Bitcoin seed", seed, hashlib.sha512).digest() + k, c = int.from_bytes(I[:32], "big"), I[32:] + H = 0x80000000 + for step in (44 + H, 60 + H, 0 + H, 0): # m/44'/60'/0'/0 + k, c = _ckd(k, c, step) + out = [] + # Indices at or above 2^31 are the HARDENED half of the path. _ckd would + # derive them happily, but they are a different key space: another wallet + # asked for m/44'/60'/0'/0/i with that i would disagree, so a pool derived + # there could not be recovered from the paper words. Unreachable from the + # CLI today — a refusal so it stays that way if --start ever appears. + if start < 0 or count < 1 or start + count > 0x80000000: + raise ValueError("address index outside the non-hardened range") + for i in range(start, start + count): + ck, _ = _ckd(k, c, i) + pt = _pt_mul(ck) + raw = keccak256(pt[0].to_bytes(32, "big") + pt[1].to_bytes(32, "big"))[12:] + out.append(to_eip55(raw)) + return out + +def to_eip55(raw20: bytes) -> str: + h = keccak256(raw20.hex().encode()).hex() + return "0x" + "".join(ch.upper() if int(h[i], 16) >= 8 else ch + for i, ch in enumerate(raw20.hex())) + +# ── self-test: refuse to run if any primitive disagrees with the record ── +def self_test(): + assert keccak256(b"").hex() == \ + "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", "keccak('')" + assert keccak256(b"abc").hex() == \ + "4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45", "keccak('abc')" + # The padding edges and the multi-block path, which the two vectors above + # never reach: 135 bytes is the single-0x81 pad collapse, 136 is exactly one + # rate block, 137 spills into a second. A wrong pad typically makes inputs + # across the boundary collide, so distinctness is the check that catches it; + # the values themselves were cross-checked against pycryptodome. + assert keccak256(b"a" * 135).hex() == \ + "34367dc248bbd832f4e3e69dfaac2f92638bd0bbd18f2912ba4ef454919cf446", "keccak 135" + assert keccak256(b"a" * 136).hex() == \ + "a6c4d403279fe3e0af03729caada8374b5ca54d8065329a3ebcaeb4b60aa386e", "keccak 136" + assert keccak256(b"a" * 137).hex() == \ + "d869f639c7046b4929fc92a4d988a8b22c55fbadb802c0c66ebcd484f1915f39", "keccak 137" + wl = "\n".join(WORDS) + "\n" + assert hashlib.sha256(wl.encode()).hexdigest() == \ + "2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda", "wordlist" + assert entropy_to_mnemonic(b"\x00" * 16) == "abandon " * 11 + "about", "bip39-12w" + assert entropy_to_mnemonic(b"\x00" * 32) == "abandon " * 23 + "art", "bip39-24w" + tm = "abandon " * 11 + "about" + assert mnemonic_to_entropy(tm) == b"\x00" * 16, "bip39 roundtrip" + assert mnemonic_to_seed(tm, "TREZOR").hex() == \ + "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553" \ + "1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04", "bip39 seed" + assert to_eip55(bytes.fromhex("5aaeb6053f3e94c9b9a09f33669435e7ef1beaed")) == \ + "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "eip55" + addrs = derive_addresses(tm, 2) + assert addrs[0] == "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", "end-to-end index 0" + assert addrs[1] == "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0", "end-to-end index 1" + + +# ── operator flows ──────────────────────────────────────────────────── +import os + +def _die(msg): + print(f"gen-eurc-pool: {msg}", file=sys.stderr) + sys.exit(1) + +# Clear screen AND scrollback (the 3J is the scrollback half; most terminals +# honour it). Only meaningful on a terminal — callers that show secrets check +# isatty first. +def _wipe(): + if sys.stdout.isatty(): + print("\033[2J\033[3J\033[H", end="", flush=True) + +def cmd_gen(count, out): + if os.path.exists(out): + _die(f"'{out}' already exists — refusing to overwrite an address file. " + "Choose --out, or if this is a top-up, generate to a new file and " + "feed it to enable-eurc.sh --append.") + # Prove the file can be written BEFORE any words exist. Discovering an + # unwritable directory after the screen is wiped strands the operator with + # paper this tool cannot turn back into addresses. + probe = out + ".probe" + try: + os.close(os.open(probe, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)) + os.unlink(probe) + except OSError as e: + _die(f"cannot write next to '{out}': {e.strerror}. Fix that before " + "generating — the words must not exist until they can be saved.") + entropy = secrets.token_bytes(32) + mnemonic = entropy_to_mnemonic(entropy) + words = mnemonic.split() + + # A pipe, a redirect, tee, script(1), tmux pipe-pane: there the wipe escape + # below is inert text and the words persist in whatever captured them. + if not sys.stdout.isatty(): + _die("stdout is not a terminal, so the 24 words would land in whatever " + "is capturing this — a file, a pipe, a tmux log — where the screen " + "wipe cannot reach them. Run this straight in a terminal, with no " + "redirect, tee or script(1).") + print() + print("These 24 words ARE the wallet. Every donation address derives from") + print("them, and anyone holding them holds the money. Write them on paper") + print("TWICE, in order, numbered. Never photograph them, never type them") + print("into anything that isn't recovering this wallet.") + print() + for row in range(6): + print(" " + "".join(f"{4*row+col+1:>3}. {words[4*row+col]:<12}" + for col in range(4))) + print() + input("Press Enter once BOTH paper copies are written... ") + + # Quiz three positions from the paper copy — this catches the classic + # losses (skipped word, swapped neighbours, misread handwriting) while + # the screen copy still exists to fix them against. + rng = secrets.SystemRandom() + for pos in sorted(rng.sample(range(24), 3)): + while True: + got = input(f"From your PAPER copy, word #{pos+1}: ").strip().lower() + if got == words[pos]: + break + print(" That does not match — check the paper copy against the screen.") + + # The words must not outlive this prompt in a buffer somebody scrolls back + # through tomorrow. + _wipe() + + addrs = derive_addresses(mnemonic, count) + fd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as f: + f.write(f"# EURC receiving pool — {count} addresses, path m/44'/60'/0'/0/i\n") + f.write("# Recoverable in any BIP-39 wallet from the 24 words on paper.\n") + f.write("# APPEND-ONLY once live; see tools/enable-eurc.sh.\n") + for a in addrs: + f.write(a + "\n") + print(f"gen-eurc-pool: wrote {count} addresses to {out} (mode 0600)") + print() + print("Next:") + print(f" 1. tools/gen-eurc-pool.sh verify {out} — retype the words from") + print(" PAPER (not memory, not the screen you just cleared) to prove the") + print(" backup actually reproduces these addresses. Do it for both copies.") + print(f" 2. tools/enable-eurc.sh {out} — take the rail live.") + +def cmd_derive(count, out, start): + """Re-derive addresses from a mnemonic the operator already holds. + + Two jobs the tool could not do before. One: recover after a failure at or + after the screen wipe — the words are on paper but nothing could turn them + into a pool file, so the ceremony had to be redone with a NEW seed. Two: + top up the pool from the SAME seed, which is what the append-only pool + actually wants — appending a second seed's addresses works, but then two + seeds must be kept safe forever instead of one. + + --start is what makes a top-up correct: pass the number of addresses the + existing pool already holds, so the new file continues the same derivation + path instead of re-emitting addresses that are already published. + """ + if os.path.exists(out): + _die(f"'{out}' already exists — choose --out; this never overwrites an " + "address file.") + probe = out + ".probe" + try: + os.close(os.open(probe, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)) + os.unlink(probe) + except OSError as e: + _die(f"cannot write next to '{out}': {e.strerror}") + import getpass + print("Retype the 24 words from PAPER, spaces between (typing is hidden).") + mnemonic = getpass.getpass("words: ") + try: + addrs = derive_addresses(mnemonic, count, start) + except ValueError as e: + kind = str(e) + safe = ("a word is not in the BIP-39 list" if "not a BIP-39 word" in kind + else "the checksum does not match — a word is wrong or out of order" + if "checksum" in kind + else "the word count is wrong (a mnemonic is 12/15/18/21/24 words)" + if "words" in kind + else "it does not parse as a mnemonic") + _wipe() + _die(f"that is not a valid mnemonic: {safe}. Nothing you typed is shown " + "or stored.") + _wipe() + fd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as f: + f.write(f"# EURC receiving pool — {count} addresses from index {start}, " + f"path m/44'/60'/0'/0/i\n") + f.write("# Re-derived from the paper mnemonic; same wallet as the rest.\n") + f.write("# APPEND-ONLY once live; see tools/enable-eurc.sh.\n") + for a in addrs: + f.write(a + "\n") + print(f"gen-eurc-pool: wrote {count} addresses " + f"(indices {start}..{start + count - 1}) to {out}") + if start == 0: + print("Verify against the existing pool file if you have one — the first") + print("addresses must match it exactly, or this is a different seed.") + else: + print(f"Top-up: feed it to tools/enable-eurc.sh {out} --append") + + +def cmd_verify(poolfile): + try: + lines = open(poolfile).read().splitlines() + except OSError as e: + _die(f"cannot read '{poolfile}': {e.strerror}") + addrs = [] + for ln in lines: + ln = ln.split("#")[0].strip().lower() + if ln: + addrs.append(ln) + if not addrs: + _die(f"'{poolfile}' holds no addresses") + import getpass + print(f"Retype the 24 words from PAPER, spaces between (typing is hidden).") + mnemonic = getpass.getpass("words: ") + try: + derived = [a.lower() for a in derive_addresses(mnemonic, len(addrs))] + except ValueError as e: + # NEVER echo the exception text: mnemonic_to_entropy names the offending + # token, and a typo is usually a REAL word of the seed with a stray + # character ("unaware." for "unaware"). getpass hid the typing, so the + # operator has every reason to believe nothing they typed is displayed — + # printing it put a genuine seed word into permanent scrollback. + kind = str(e) + safe = ("a word is not in the BIP-39 list" if "not a BIP-39 word" in kind + else "the checksum does not match — a word is wrong or out of order" + if "checksum" in kind + else "the word count is wrong (a mnemonic is 12/15/18/21/24 words)" + if "words" in kind + else "it does not parse as a mnemonic") + _wipe() + _die(f"that is not a valid mnemonic: {safe}. Nothing about what you " + "typed is shown or stored; check the paper copy and try again.") + if derived == addrs: + # Wipe before reporting: the words were typed into this terminal, and + # while getpass kept them off the screen, a wipe here also clears + # anything the operator pasted or mistyped in view earlier. + _wipe() + print(f"MATCH — the paper backup reproduces all {len(addrs)} addresses.") + return + bad = [i for i, (d, a) in enumerate(zip(derived, addrs)) if d != a] + print(f"MISMATCH — {len(bad)} of {len(addrs)} addresses differ " + f"(first at line index {bad[0] if bad else '?'}).", file=sys.stderr) + print("Either a word was miscopied to paper, or this pool holds appended", file=sys.stderr) + print("addresses from a different seed (mismatches only in the tail).", file=sys.stderr) + sys.exit(1) + +try: + self_test() +except AssertionError as e: + print(f"gen-eurc-pool: SELF-TEST FAILED ({e}) — refusing to generate " + "anything. The maths must prove itself before it may touch money.", + file=sys.stderr) + sys.exit(3) + +mode = sys.argv[1] +if mode == "selftest": + print("gen-eurc-pool: self-test OK") +elif mode == "gen": + cmd_gen(int(sys.argv[2]), sys.argv[3]) +elif mode == "verify": + cmd_verify(sys.argv[3]) # argv layout is MODE COUNT FILE START +elif mode == "derive": + cmd_derive(int(sys.argv[2]), sys.argv[3], int(sys.argv[4])) +PYSRC +) +exec python3 -c "$PY" "$MODE" "$COUNT" "$OUT" "$START"