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

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

View file

@ -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<RatePoint> gRecentCrypto;
std::unordered_map<std::string, std::deque<RatePoint>> 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<RatePoint>& seen) {
while (!seen.empty() && now - seen.front() > kRateWindow) seen.pop_front();
};
expire(gRecentCrypto);
if (gRecentCrypto.size() >= kMaxCryptoPerWindow) return false;
if (!peer.empty()) {
// Same leak-avoidance as the general limiter: expire every peer and
// drop the emptied entries rather than keeping a row per address that
// ever submitted.
std::erase_if(gRecentCryptoPerPeer, [&](auto& entry) {
expire(entry.second);
return entry.second.empty();
});
std::deque<RatePoint>& seen = gRecentCryptoPerPeer[std::string(peer)];
if (seen.size() >= kMaxCryptoPerPeer) return false;
seen.push_back(now);
}
gRecentCrypto.push_back(now);
return true;
}
// The inverse of NowIso8601, for the one caller that needs an order's real age:
// exactly "YYYY-MM-DDTHH:MM:SSZ", which is the only shape this codebase writes.
// nullopt for anything else — a ledger line from another tool, or a truncated
// write — so the caller can fall back rather than trust a half-parsed date.
// (std::chrono::parse would be the obvious tool and is not in this libc++.)
std::optional<std::chrono::sys_seconds> ParseIso8601Utc(std::string_view s) {
if (s.size() != 20 || s[4] != '-' || s[7] != '-' || s[10] != 'T'
|| s[13] != ':' || s[16] != ':' || s[19] != 'Z') {
return std::nullopt;
}
auto num = [&](std::size_t at, std::size_t len) -> std::optional<int> {
int v = 0;
const auto [end, ec] =
std::from_chars(s.data() + at, s.data() + at + len, v);
if (ec != std::errc{} || end != s.data() + at + len) return std::nullopt;
return v;
};
const auto y = num(0, 4), mo = num(5, 2), d = num(8, 2);
const auto h = num(11, 2), mi = num(14, 2), sec = num(17, 2);
if (!y || !mo || !d || !h || !mi || !sec) return std::nullopt;
if (*mo < 1 || *mo > 12 || *d < 1 || *d > 31) return std::nullopt;
if (*h > 23 || *mi > 59 || *sec > 60) return std::nullopt;
const std::chrono::year_month_day ymd{ std::chrono::year{ *y },
std::chrono::month{
static_cast<unsigned>(*mo) },
std::chrono::day{
static_cast<unsigned>(*d) } };
if (!ymd.ok()) return std::nullopt;
return std::chrono::sys_days{ ymd } + std::chrono::hours{ *h }
+ std::chrono::minutes{ *mi } + std::chrono::seconds{ *sec };
}
// RFC 3339 UTC. Recorded so the order log can be read chronologically
// 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<std::chrono::sys_seconds> placed =
ParseIso8601Utc(order.createdAt);
const auto age =
placed ? std::chrono::duration_cast<
std::chrono::steady_clock::duration>(
std::chrono::system_clock::now() - *placed)
: now - it->second.first;
if (age > hours(24 * 7)) continue;
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",