tests and eurc
All checks were successful
Deploy / build-deploy (push) Successful in 4m19s

This commit is contained in:
Jorijn van der Graaf 2026-08-15 00:54:05 +02:00
commit 749f525f83
44 changed files with 5380 additions and 3532 deletions

View file

@ -1,286 +0,0 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The CoinGate payment rail — the crypto half of the checkout.
//
// Why a processor rather than a self-hosted node: accepting crypto for goods
// makes this shop a MERCHANT, not a crypto-asset service provider, under either
// arrangement. What differs is everything around it. CoinGate is MiCA-licensed
// (mandatory to serve EU clients since 1 July 2026) and settles EUR to the
// business account by SEPA at a locked rate, so the money that lands is the
// money the invoice says, the bookkeeping line is identical to Mollie's, and no
// coin ever sits on this balance sheet waiting to move in price. A self-hosted
// BTCPay would cost 1% less and a Bitcoin node's worth of operations, custody
// and per-payment revaluation — a trade worth making for sovereignty, not for a
// tail of international orders.
//
// Which is why receive_currency is EUR below and not DO_NOT_CONVERT: that one
// parameter is the whole difference between "a second Mollie" and "the shop now
// holds crypto". Changing it is a tax decision, not a code cleanup.
//
// The API is the same shape as Mollie's, so this client is the same shape as
// that one:
//
// POST /api/v2/orders {price_amount, price_currency, …} -> id + payment_url
// GET /api/v2/orders/{id} -> status, pay_currency
//
// Two differences from Mollie worth knowing. Requests are form-encoded, which
// is what every CoinGate example uses and what their v2 API is documented
// against — the responses are JSON either way, and JSON is the only direction
// that matters here, since it is the one carrying money. And their ids are JSON
// NUMBERS, not strings, so the parser renders them to decimal (see
// ParseCoingateOrder) and the ledger stores text like it does for every rail.
//
// Trust direction is the design rule and is unchanged: CoinGate can be told a
// callback_url and it is deliberately NOT given one. An order becomes paid only
// when an authenticated GET says status=paid over a covering EUR amount.
// Crypto invoices die fast — two hours before a coin is picked, twenty minutes
// after — so Dead is a state this rail reaches far more often than Mollie does,
// and the reconciler lapsing those orders is the normal case rather than an
// exception.
module;
module Catcrafts.Server;
import std;
import Catcrafts.Shared;
import Crafter.Network;
using namespace Crafter;
namespace Catcrafts::Server {
namespace {
// Percent-encode one form value. Unreserved characters pass; everything else
// becomes %XX, including the space (rather than '+', which is only correct in
// a query string and is one of those differences that works until it doesn't).
std::string FormEncode(std::string_view s) {
static constexpr std::string_view kHex = "0123456789ABCDEF";
std::string out;
out.reserve(s.size() + 8);
for (const char c : s) {
const bool unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
|| (c >= '0' && c <= '9')
|| c == '-' || c == '_' || c == '.' || c == '~';
if (unreserved) {
out += c;
} else {
const auto byte = static_cast<unsigned char>(c);
out += '%';
out += kHex[byte >> 4];
out += kHex[byte & 0x0f];
}
}
return out;
}
// Ticker symbols arrive uppercase ("BTC"); the ledger's via column is lowercase
// everywhere else ("ideal", "creditcard"), and a column that shouts in one row
// and whispers in the next is just noise to read past.
std::string LowerAscii(std::string_view s) {
std::string out(s);
for (char& c : out) {
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
}
return out;
}
// CoinGate caps title at 150 characters and description at 500. Both are built
// from the order reference here and come nowhere near either, but a truncating
// helper means a future longer description degrades to a shorter one rather
// than to a 422 at checkout — with the buyer already committed.
std::string Clamp(std::string_view s, std::size_t max) {
return std::string(s.substr(0, std::min(s.size(), max)));
}
} // namespace
std::optional<CoingateOrder> ParseCoingateOrder(std::string_view json) {
auto doc = Json::Parse(json);
if (!doc || !doc->IsObject()) return std::nullopt;
CoingateOrder o;
// The id arrives as a number. Accept a string too: costing a live checkout
// over a provider changing a field's JSON type would be an absurd way to
// lose a sale, and either spelling names the same order.
if (const Json::Value* id = doc->Find("id")) {
if (id->type == Json::Type::String) {
o.id = id->string;
} else if (id->type == Json::Type::Number) {
o.id = std::format("{}", static_cast<std::int64_t>(id->number));
}
}
o.status = std::string(doc->Str("status"));
o.payCurrency = std::string(doc->Str("pay_currency"));
o.payUrl = std::string(doc->Str("payment_url"));
if (o.id.empty() || o.status.empty()) return std::nullopt;
// Only euro-priced orders are ever created, so anything else failing to
// parse to zero is the safe outcome — a zero amount never satisfies an
// order total. Note this is price_amount (what the buyer owed) and not
// receive_amount (what lands after conversion and fee): the question being
// asked is whether the buyer paid their invoice, not what the shop nets.
if (doc->Str("price_currency") == "EUR") {
if (auto minor = ParseAmountToMinor(doc->Str("price_amount"))) {
o.priceMinor = *minor;
}
}
return o;
}
namespace {
class CoingateRail final : public PaymentRail {
public:
explicit CoingateRail(RailConfig cfg)
: cfg_(std::move(cfg)),
host_(cfg_.sandbox ? "api-sandbox.coingate.com" : "api.coingate.com") {}
std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
const std::string& description,
const std::string& redirectUrl) override {
std::lock_guard lock(mutex_);
// receive_currency=EUR is the settlement decision; see the header.
// No callback_url on purpose: state comes from the poll, never from
// something that arrives unbidden claiming an order was paid.
const std::string body = std::format(
"price_amount={}&price_currency=EUR&receive_currency=EUR"
"&title={}&description={}&order_id={}&success_url={}&cancel_url={}",
FormEncode(Money::FormatMinor(amountMinor)),
FormEncode(Clamp(description, 150)),
FormEncode(Clamp(description, 500)),
FormEncode(Clamp(description, 255)),
FormEncode(redirectUrl), FormEncode(redirectUrl));
const std::optional<std::string> res = Call("POST", "/api/v2/orders", body);
if (!res) return std::nullopt;
const auto order = ParseCoingateOrder(*res);
if (!order || order->payUrl.empty()) {
std::println(std::cerr, "coingate: create returned no payment url");
return std::nullopt;
}
PaymentLink link;
link.payId = order->id;
link.payUrl = order->payUrl;
return link;
}
std::optional<PaidStatus> CheckPaid(const std::string& payId,
std::int64_t expectedMinor) override {
std::lock_guard lock(mutex_);
// CoinGate ids are decimal integers. The id came from them, but it
// travels through our ledger — keep the path composition strict anyway.
if (payId.empty()) return PaidStatus{ PayState::Dead, {} };
for (const char c : payId) {
if (c < '0' || c > '9') return PaidStatus{ PayState::Dead, {} };
}
const std::optional<std::string> res =
Call("GET", "/api/v2/orders/" + payId, {});
if (!res) return std::nullopt;
const auto order = ParseCoingateOrder(*res);
if (!order) return std::nullopt;
PaidStatus out;
// What settled it, for the ledger's "via" column: the coin the shopper
// actually paid in ("BTC" -> "bitcoin" is their business, not ours —
// the ticker is the honest record). Empty until a coin is picked.
out.method = order->payCurrency.empty()
? std::string("crypto")
: LowerAscii(order->payCurrency);
const std::string_view status = order->status;
if (status == "paid" && order->priceMinor >= expectedMinor) {
out.state = PayState::Paid;
} else if (status == "new" || status == "pending" || status == "confirming") {
// Still in flight. "confirming" is the blockchain-confirmation
// wait: the money is visible but not final, and this rail does not
// treat visible as received.
out.state = PayState::Pending;
} else if (status == "refunded" || status == "partially_refunded") {
// Paid and then given back — which, seen from an order still
// awaiting payment, means the reconciler missed the entire paid
// window (a long outage) and the money has since left again. Lapse
// it rather than confirm an order whose payment was undone, and say
// so loudly: this is the one case where --mark-paid may be the
// right answer and only a human can tell.
std::println(std::cerr,
"coingate: order {} is {} — lapsing; confirm by hand if "
"the refund was partial and the goods still ship",
payId, status);
out.state = PayState::Dead;
} else if (status == "invalid" || status == "expired" || status == "canceled") {
out.state = PayState::Dead;
} else {
// An unknown status is not a licence to guess. Pending means "ask
// again", which is the only safe reading of a word we do not know.
std::println(std::cerr, "coingate: order {} has unknown status '{}'",
payId, status);
out.state = PayState::Pending;
}
return out;
}
std::string_view Name() const override { return "coingate"; }
// Slower than Mollie's ten seconds: a crypto payment waits on block
// confirmations, so there is nothing a faster sweep could learn. The
// buyer's own arrival at the order page still polls once immediately.
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(20); }
private:
// One HTTPS call; nullopt on transport failure or a non-2xx answer. The
// reconciler treats nullopt as "unknown, retry" — never as unpaid or dead.
std::optional<std::string> Call(std::string_view method, const std::string& path,
const std::string& body) {
try {
if (!client_) {
client_ = std::make_unique<Crafter::ClientHTTP1>(
host_, static_cast<std::uint16_t>(443),
Crafter::TLSClientCredentials{});
}
Crafter::HTTPRequest req;
req.method = std::string(method);
req.path = path;
req.authority = host_;
req.body = body;
// Not "Bearer": CoinGate's scheme word is literally "Token".
req.headers["authorization"] = "Token " + cfg_.apiKey;
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
req.headers["accept"] = "application/json";
if (!body.empty()) {
req.headers["content-type"] = "application/x-www-form-urlencoded";
}
const Crafter::HTTPResponse res = client_->Send(req);
if (res.status.size() != 3 || res.status[0] != '2') {
std::println(std::cerr, "coingate: {} {} -> {} {}", method, path,
res.status, res.body.substr(0, 200));
return std::nullopt;
}
return res.body;
} catch (const std::exception& e) {
std::println(std::cerr, "coingate: {} {} failed: {}", method, path, e.what());
client_.reset(); // dial fresh next time
return std::nullopt;
}
}
RailConfig cfg_;
std::string host_;
std::mutex mutex_;
std::unique_ptr<Crafter::ClientHTTP1> client_;
};
} // namespace
std::unique_ptr<PaymentRail> MakeCoingateRail(const RailConfig& config) {
return std::make_unique<CoingateRail>(config);
}
} // namespace Catcrafts::Server

View file

@ -0,0 +1,673 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The EURC payment rail — the crypto half of the checkout, with no processor.
//
// Accepting crypto for goods makes this shop a MERCHANT and not a crypto-asset
// service provider, with or without a processor in between, so the licence was
// never the question; what a processor would buy is EUR settlement, and what it
// would cost is a KYB gate standing between the shop and its own checkout.
// Here nobody sits in the payment path at all: the buyer sends EURC to an
// address this shop already owns, and the server's only role is to notice.
//
// Why EURC and not a coin: EURC is euro-denominated at par, so there is no rate
// to quote, no quote to expire, no revaluation at year end, and no exchange-rate
// line in the books. €563.30 owed is 563300000 EURC base units owed, forever.
// That collapses the entire pricing problem to integer arithmetic, which is the
// same arithmetic every other amount in this codebase already uses.
//
// Why an address POOL and not xpub derivation. Deriving addresses on demand
// would need BIP32, secp256k1 and Keccak-256 in this process, and would put an
// extended public key on the internet-facing box. A pool needs none of it: the
// addresses are generated once, offline, by the wallet that holds the keys, and
// arrive here as a plain list. This process can therefore only ever LEARN an
// address it was given — it cannot derive the next one, cannot recognise a
// sibling, and has nothing on disk that is worth stealing. It is the same rule
// the bunq key follows, taken one step further.
//
// Why balanceOf and not log scanning. One eth_call answers "how much EURC does
// this address hold", which is the entire question. Asking it AT A FINALIZED
// BLOCK makes reorg handling somebody else's problem rather than a confirmation
// counter this code would have to get right. The function selector is the first
// four bytes of keccak256("balanceOf(address)") — a constant since 2015, spelled
// out below, which is why no Keccak implementation is needed here either.
//
// Why one address covers several chains. An EVM address is derived from a public
// key and is not chain-specific, so the SAME address is valid on Ethereum, Base
// and Avalanche at once. One assignment therefore covers every chain we watch,
// the buyer pays on whichever is cheapest for them, and the classic "sent it on
// the wrong network" support ticket becomes a payment we were watching for
// anyway. The chain that settles it is recorded as the ledger's via column
// ("eurc-base"), because which chain the money arrived on is a fact worth
// keeping.
//
// Trust direction is unchanged and, for once, trivially so: there is no provider
// to send a callback, so there is nothing to ignore. An order becomes paid when
// an RPC we chose to call reports a covering balance at a finalized block.
//
// ONE HAZARD A PROCESSOR WOULD NOT HAVE, stated plainly because it will
// eventually happen: Dead here does NOT mean the money bounced. A processor's
// invoice that expires is dead in the sense that no money can arrive against it.
// An address is ours forever, so a buyer who pays after the window still sends
// real EURC to a real address we control. The order lapses; the money arrives
// regardless. That is why lapsing logs the address rather than dropping it, why
// the address stays bound to the order in the ledger, and why the window
// defaults to a generous 24 hours instead of a processor's twenty minutes —
// there is no cost to waiting when the destination is our own wallet.
module;
module Catcrafts.Server;
import std;
import Catcrafts.Shared;
import Crafter.Network;
using namespace Crafter;
namespace Catcrafts::Server {
namespace {
// keccak256("balanceOf(address)")[0..4). A constant of the ERC-20 ABI, not a
// value we compute — which is the whole reason this unit needs no Keccak.
constexpr std::string_view kBalanceOfSelector = "0x70a08231";
// EURC carries 6 decimals on every chain Circle deploys it to. Amounts in this
// codebase are EUR cents (2 decimals), so a covering balance is
// cents * 10^(decimals-2). Kept per chain anyway: a future token with a
// different scale should be a config line, not a patch.
constexpr int kDefaultDecimals = 6;
bool IsHexDigit(char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
}
std::string LowerAscii(std::string_view s) {
std::string out(s);
for (char& c : out) {
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
}
return out;
}
// "0x" followed by exactly 40 hex digits. Deliberately NOT an EIP-55 checksum
// check: verifying the mixed-case checksum would need Keccak, which this unit
// does not carry. The consequence is operational and is documented at the pool
// loader — addresses must be COPIED from the wallet that generated them, never
// retyped, because a typo that stays hex will not be caught here.
bool IsAddress(std::string_view s) {
if (s.size() != 42) return false;
if (s[0] != '0' || (s[1] != 'x' && s[1] != 'X')) return false;
for (std::size_t i = 2; i < s.size(); ++i) {
if (!IsHexDigit(s[i])) return false;
}
return true;
}
// Split an RPC endpoint into the pieces Crafter::ClientHTTP1 wants. Plain http
// is accepted so a node on the home LAN can be used later without a certificate;
// anything else is a configuration error rather than a silent default.
struct Endpoint {
std::string host;
std::string path = "/";
std::uint16_t port = 443;
bool tls = true;
};
std::optional<Endpoint> ParseEndpoint(std::string_view url) {
Endpoint ep;
if (url.starts_with("https://")) {
url.remove_prefix(8);
} else if (url.starts_with("http://")) {
ep.tls = false;
ep.port = 80;
url.remove_prefix(7);
} else {
return std::nullopt;
}
if (url.empty()) return std::nullopt;
const std::size_t slash = url.find('/');
std::string_view authority = slash == std::string_view::npos ? url : url.substr(0, slash);
if (slash != std::string_view::npos) ep.path = std::string(url.substr(slash));
if (authority.empty()) return std::nullopt;
// A colon here is a port, not IPv6-in-a-URL: those are bracketed, and an
// RPC endpoint spelled with a bare IPv6 literal is not a case worth
// guessing at.
if (const std::size_t colon = authority.rfind(':'); colon != std::string_view::npos) {
std::uint32_t parsed = 0;
const std::string_view digits = authority.substr(colon + 1);
const auto [ptr, ec] =
std::from_chars(digits.data(), digits.data() + digits.size(), parsed);
if (ec != std::errc{} || ptr != digits.data() + digits.size() || parsed == 0
|| parsed > 65535) {
return std::nullopt;
}
ep.port = static_cast<std::uint16_t>(parsed);
authority = authority.substr(0, colon);
}
if (authority.empty()) return std::nullopt;
ep.host = std::string(authority);
return ep;
}
// 10^n as an integer, saturating rather than wrapping. n is small and config-
// bounded, but this is money arithmetic and a silent wrap is the wrong failure.
std::optional<std::int64_t> Pow10(int n) {
if (n < 0 || n > 18) return std::nullopt;
std::int64_t out = 1;
for (int i = 0; i < n; ++i) out *= 10;
return out;
}
} // 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.
// 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.
std::optional<std::int64_t> ParseEthCallUint(std::string_view json) {
auto doc = Json::Parse(json);
if (!doc || !doc->IsObject()) return std::nullopt;
// A JSON-RPC error is a real answer and must not read as a zero balance:
// "the node refused" and "the buyer has not paid" are different facts and
// only one of them should ever lapse an order.
if (const Json::Value* err = doc->Find("error"); err && err->type != Json::Type::Null) {
return std::nullopt;
}
const Json::Value* res = doc->Find("result");
if (!res || res->type != Json::Type::String) return std::nullopt;
std::string_view hex = res->string;
if (!hex.starts_with("0x") && !hex.starts_with("0X")) return std::nullopt;
hex.remove_prefix(2);
if (hex.empty() || hex.size() > 64) return std::nullopt;
std::int64_t out = 0;
for (const char c : hex) {
if (!IsHexDigit(c)) return std::nullopt;
int digit = 0;
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.
if (out > (std::numeric_limits<std::int64_t>::max() - digit) / 16) {
return std::numeric_limits<std::int64_t>::max();
}
out = out * 16 + digit;
}
return out;
}
// Parse the chains file. Refuses partial success on purpose: a chain list where
// one entry silently dropped is a shop that quietly stops noticing payments on
// that chain, which is indistinguishable from a buyer who never paid.
std::optional<std::vector<EurcChain>> ParseEurcChains(std::string_view json) {
auto doc = Json::Parse(json);
if (!doc || !doc->IsObject()) return std::nullopt;
const Json::Value* arr = doc->Find("chains");
if (!arr || !arr->IsArray()) return std::nullopt;
std::vector<EurcChain> out;
for (const Json::Value& v : arr->array) {
if (!v.IsObject()) return std::nullopt;
EurcChain c;
c.name = std::string(v.Str("name"));
c.rpcUrl = std::string(v.Str("rpc"));
c.contract = LowerAscii(v.Str("contract"));
c.blockTag = std::string(v.Str("block_tag", "finalized"));
if (const Json::Value* d = v.Find("decimals"); d && d->type == Json::Type::Number) {
c.decimals = static_cast<int>(d->number);
}
if (const Json::Value* d = v.Find("chain_id"); d && d->type == Json::Type::Number) {
c.chainId = static_cast<std::int64_t>(d->number);
}
c.note = std::string(v.Str("note"));
if (c.chainId < 0) return std::nullopt;
if (c.name.empty() || c.rpcUrl.empty()) return std::nullopt;
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;
// "latest" is accepted but is a foot-gun worth naming: it reports state
// that a reorg can still take back.
if (c.blockTag == "latest") {
std::println(std::cerr,
"eurc: chain '{}' watches block_tag=latest — a reorg can "
"un-pay a settled order; prefer 'finalized'", c.name);
}
out.push_back(std::move(c));
}
if (out.empty()) return std::nullopt;
return out;
}
namespace {
// The two halves of this rail's payId ("<address>@<unix-deadline>"), or
// nullopt for anything that does not parse — which CheckPaid reads as Dead
// (the id came from us; a mangled one identifies no payment) and Instructions
// reads as "nothing to render".
struct PayIdParts {
std::string address;
std::int64_t deadline = 0;
};
std::optional<PayIdParts> SplitPayId(std::string_view payId) {
const auto at = payId.rfind('@');
if (at == std::string_view::npos) return std::nullopt;
PayIdParts parts;
parts.address = LowerAscii(payId.substr(0, at));
if (!IsAddress(parts.address)) return std::nullopt;
const std::string_view digits = payId.substr(at + 1);
const auto [ptr, ec] =
std::from_chars(digits.data(), digits.data() + digits.size(), parts.deadline);
if (ec != std::errc{} || ptr != digits.data() + digits.size()) return std::nullopt;
return parts;
}
class EurcRail final : public PaymentRail {
public:
explicit EurcRail(RailConfig cfg) : cfg_(std::move(cfg)) {}
// Loading is separate from construction so a bad pool or chain file is a
// startup refusal with a reason, not a rail that constructs fine and then
// fails at the one moment a buyer is committed.
bool Load() {
if (!LoadChains()) return false;
if (!LoadPool()) return false;
cursor_ = ReadCursor();
if (cursor_ >= pool_.size()) {
std::println(std::cerr,
"eurc: address pool is exhausted ({} of {} used) — top it "
"up from the wallet before enabling the crypto rail",
cursor_, pool_.size());
return false;
}
const std::size_t left = pool_.size() - cursor_;
std::println(std::cerr, "eurc: {} chains, {} addresses left of {}",
chains_.size(), left, pool_.size());
if (left < kLowWaterMark) {
std::println(std::cerr,
"eurc: WARNING only {} addresses left — top up the pool", left);
}
return true;
}
std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
const std::string& description,
const std::string& redirectUrl) override {
std::lock_guard lock(mutex_);
(void)description; // nothing off-box to label; the ledger holds it
if (amountMinor <= 0) return std::nullopt;
if (cursor_ >= pool_.size()) {
std::println(std::cerr,
"eurc: refusing checkout — address pool exhausted");
return std::nullopt;
}
// Burn the address BEFORE handing it out. A crash between these two
// points wastes one address; the opposite order would hand the same
// address to two orders, and the second buyer's payment would appear to
// settle the first. Wasting is recoverable, reuse is not.
const std::string address = pool_[cursor_];
if (!WriteCursor(cursor_ + 1)) {
std::println(std::cerr,
"eurc: could not persist the address cursor — refusing "
"checkout rather than risk reusing {}", address);
return std::nullopt;
}
++cursor_;
const std::int64_t deadline =
std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count()
+ static_cast<std::int64_t>(WindowSeconds());
PaymentLink link;
// The id carries the deadline because CheckPaid is given nothing but the
// id and the amount, and this rail — unlike a processor's — has to know
// on its own when a window closed. Both halves are worth keeping in the
// ledger anyway: the address is the audit trail, the deadline explains
// why an order lapsed when it did.
link.payId = address + "@" + std::to_string(deadline);
// There is no hosted checkout to send the buyer to. The order page is
// the payment page: it already knows the order, and the address is in
// the ledger next to it.
link.payUrl = redirectUrl;
if (pool_.size() - cursor_ < kLowWaterMark) {
std::println(std::cerr, "eurc: WARNING {} addresses left after issuing {}",
pool_.size() - cursor_, address);
}
return link;
}
std::optional<PaidStatus> CheckPaid(const std::string& payId,
std::int64_t expectedMinor) override {
std::lock_guard lock(mutex_);
const std::optional<PayIdParts> parts = SplitPayId(payId);
if (!parts) return PaidStatus{ PayState::Dead, {} };
const std::string& address = parts->address;
const std::int64_t deadline = parts->deadline;
// Ask every chain before judging. A transport failure on one chain is
// NOT evidence of non-payment, so an unreachable chain poisons the whole
// answer to nullopt ("unknown, retry") rather than letting the reachable
// chains lapse an order that may well be paid on the silent one.
bool anyUnreachable = false;
for (const EurcChain& chain : chains_) {
const std::optional<std::int64_t> required = RequiredUnits(chain, expectedMinor);
if (!required) {
std::println(std::cerr, "eurc: chain '{}' has an unusable scale", chain.name);
anyUnreachable = true;
continue;
}
const std::optional<std::int64_t> balance = BalanceOf(chain, address);
if (!balance) {
anyUnreachable = true;
continue;
}
// Full cover on ONE chain. Deliberately not a sum across chains: a
// total assembled from partial transfers on several networks is not
// a payment this shop wants to accept automatically, and reading it
// as one would let two unrelated dust sends settle an invoice.
if (*balance >= *required) {
PaidStatus out;
out.state = PayState::Paid;
out.method = "eurc-" + chain.name;
return out;
}
}
if (anyUnreachable) return std::nullopt;
const std::int64_t now =
std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
if (now >= deadline) {
// See the header: this is not "the money bounced". The address stays
// ours, so a late payment still lands — which is why the address is
// shouted here rather than quietly dropped.
std::println(std::cerr,
"eurc: order at {} lapsed unpaid after its window — the "
"address remains ours, so a late payment will still "
"arrive there and needs settling by hand", address);
return PaidStatus{ PayState::Dead, {} };
}
return PaidStatus{ PayState::Pending, {} };
}
// What the order page renders in place of a hosted-checkout button. Reads
// only chains_ and the payId, both fixed after load — no lock, per the
// interface contract, so a slow RPC poll can never stall page rendering.
std::optional<PayInstructions> Instructions(const std::string& payId,
std::int64_t totalMinor) const override {
const std::optional<PayIdParts> parts = SplitPayId(payId);
if (!parts || totalMinor <= 0) return std::nullopt;
PayInstructions out;
out.address = parts->address;
out.deadlineUnix = parts->deadline;
// EURC is euro-denominated at par, so the token amount IS the euro
// total — same digits, different unit label. The one place that fact
// is relied on for display, and the reason there is no rate line.
out.amount = Money::FormatMinor(totalMinor);
for (const EurcChain& chain : chains_) {
PayChainOption opt;
opt.name = chain.name;
opt.contract = chain.contract;
opt.note = chain.note;
// EIP-681: a URI wallets open with token, network, recipient and
// amount pre-filled — the buyer cannot mistype what they never
// type. Base units, so the same scaling as the covering check;
// skipped when it cannot be represented, never approximated.
if (chain.chainId > 0) {
if (const auto units = RequiredUnits(chain, totalMinor)) {
opt.link = std::format("ethereum:{}@{}/transfer?address={}&uint256={}",
chain.contract, chain.chainId,
parts->address, *units);
}
}
out.chains.push_back(std::move(opt));
}
if (out.chains.empty()) return std::nullopt;
return out;
}
std::string_view Name() const override { return "eurc"; }
// Finality is minutes on every chain here, so a faster sweep would only
// spend somebody's RPC quota learning nothing. The buyer's own arrival at
// the order page still triggers one immediate poll.
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(30); }
private:
static constexpr std::size_t kLowWaterMark = 25;
std::size_t WindowSeconds() const {
return cfg_.eurcWindowHours > 0
? static_cast<std::size_t>(cfg_.eurcWindowHours) * 3600u
: 24u * 3600u;
}
// cents -> token base units, saturating. Both halves are bounded by config
// and by the catalogue, but this is the number an order is judged against.
std::optional<std::int64_t> RequiredUnits(const EurcChain& chain,
std::int64_t expectedMinor) const {
if (expectedMinor <= 0) return std::nullopt;
const std::optional<std::int64_t> scale = Pow10(chain.decimals - 2);
if (!scale) return std::nullopt;
if (expectedMinor > std::numeric_limits<std::int64_t>::max() / *scale) {
return std::nullopt;
}
return expectedMinor * *scale;
}
std::optional<std::int64_t> BalanceOf(const EurcChain& chain,
const std::string& address) {
// eth_call to the token contract. The address is left-padded into a
// 32-byte ABI word: 24 zero bytes, then the 20 address bytes.
std::string data;
data.reserve(2 + 8 + 64);
data += kBalanceOfSelector;
data.append(24 * 2, '0');
data += address.substr(2);
const std::string body =
std::string(R"({"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":")")
+ chain.contract + R"(","data":")" + data + R"("},")" + chain.blockTag + R"("]})";
const std::optional<std::string> res = Call(chain, body);
if (!res) return std::nullopt;
const std::optional<std::int64_t> units = ParseEthCallUint(*res);
if (!units) {
std::println(std::cerr, "eurc: chain '{}' returned an undecodable balance: {}",
chain.name, res->substr(0, 200));
return std::nullopt;
}
return units;
}
// 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.
std::optional<std::string> Call(const EurcChain& chain, const std::string& body) {
const std::optional<Endpoint> ep = ParseEndpoint(chain.rpcUrl);
if (!ep) return std::nullopt;
try {
std::unique_ptr<Crafter::ClientHTTP1>& client = clients_[chain.name];
if (!client) {
client = ep->tls
? std::make_unique<Crafter::ClientHTTP1>(
ep->host, ep->port, Crafter::TLSClientCredentials{})
: std::make_unique<Crafter::ClientHTTP1>(ep->host, ep->port);
}
Crafter::HTTPRequest req;
req.method = "POST";
req.path = ep->path;
req.authority = ep->host;
req.body = body;
req.headers["content-type"] = "application/json";
req.headers["accept"] = "application/json";
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
const Crafter::HTTPResponse res = client->Send(req);
if (res.status.size() != 3 || res.status[0] != '2') {
// The RPC URL can carry a key in its path; log the chain, never
// the endpoint.
std::println(std::cerr, "eurc: chain '{}' -> {} {}", chain.name,
res.status, res.body.substr(0, 200));
return std::nullopt;
}
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
return std::nullopt;
}
}
bool LoadChains() {
std::ifstream in(cfg_.eurcChainsPath, std::ios::binary);
if (!in) {
std::println(std::cerr, "eurc: cannot read chains file '{}'",
cfg_.eurcChainsPath.string());
return false;
}
const std::string text((std::istreambuf_iterator<char>(in)),
std::istreambuf_iterator<char>());
std::optional<std::vector<EurcChain>> parsed = ParseEurcChains(text);
if (!parsed) {
std::println(std::cerr,
"eurc: chains file '{}' is malformed — every entry needs a "
"name, an http(s) rpc, and a 20-byte contract address",
cfg_.eurcChainsPath.string());
return false;
}
chains_ = std::move(*parsed);
return true;
}
// One address per line; '#' comments and blank lines ignored. A malformed
// line is fatal rather than skipped: the pool is the list of places this
// shop will tell strangers to send money, and a line that does not parse is
// as likely to be a mangled good address as a stray note.
//
// Addresses must be COPIED from the wallet that generated them. The checksum
// case cannot be verified here (see IsAddress), so a hand-retyped address
// that stays hex will be accepted, published to a buyer, and paid to a place
// nobody holds a key for.
bool LoadPool() {
std::ifstream in(cfg_.eurcPoolPath, std::ios::binary);
if (!in) {
std::println(std::cerr, "eurc: cannot read address pool '{}'",
cfg_.eurcPoolPath.string());
return false;
}
std::set<std::string> seen;
std::string line;
std::size_t lineNo = 0;
while (std::getline(in, line)) {
++lineNo;
if (const std::size_t hash = line.find('#'); hash != std::string::npos) {
line.erase(hash);
}
while (!line.empty() && (line.back() == ' ' || line.back() == '\t'
|| line.back() == '\r')) {
line.pop_back();
}
std::size_t start = 0;
while (start < line.size() && (line[start] == ' ' || line[start] == '\t')) {
++start;
}
const std::string entry = LowerAscii(std::string_view(line).substr(start));
if (entry.empty()) continue;
if (!IsAddress(entry)) {
std::println(std::cerr, "eurc: address pool line {} is not an address",
lineNo);
return false;
}
// A duplicate in the pool is the reuse bug wearing a different hat.
if (!seen.insert(entry).second) {
std::println(std::cerr,
"eurc: address pool line {} repeats an earlier address",
lineNo);
return false;
}
pool_.push_back(entry);
}
if (pool_.empty()) {
std::println(std::cerr, "eurc: address pool '{}' is empty",
cfg_.eurcPoolPath.string());
return false;
}
return true;
}
// 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;
std::size_t value = 0;
if (!(in >> value)) {
std::println(std::cerr, "eurc: cursor file '{}' is unreadable — treating "
"the pool as exhausted rather than reissuing",
CursorPath().string());
return std::numeric_limits<std::size_t>::max();
}
return value;
}
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.
std::filesystem::path tmp = CursorPath();
tmp += ".tmp";
{
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
if (!out) return false;
out << value << '\n';
out.flush();
if (!out) return false;
}
std::error_code ec;
std::filesystem::rename(tmp, CursorPath(), ec);
return !ec;
}
std::filesystem::path CursorPath() const {
std::filesystem::path p = cfg_.eurcPoolPath;
p += ".cursor";
return p;
}
RailConfig cfg_;
std::vector<EurcChain> chains_;
std::vector<std::string> pool_;
std::size_t cursor_ = 0;
std::mutex mutex_;
std::map<std::string, std::unique_ptr<Crafter::ClientHTTP1>> clients_;
};
} // namespace
std::unique_ptr<PaymentRail> MakeEurcRail(const RailConfig& config) {
auto rail = std::make_unique<EurcRail>(config);
if (!rail->Load()) return nullptr;
return rail;
}
} // namespace Catcrafts::Server

View file

@ -304,6 +304,29 @@ HTTPResponse RenderPage(std::string_view target) {
view.colorLabel = order->color;
}
// Self-hosted rails have no provider page to resume at — the order
// page IS the payment page, so ask the rail what to render. Hosted
// rails return nullopt and keep their button. Only while awaiting: a
// paid page repeating "send money here" would read as a second ask.
if (order->status == "awaiting_payment") {
if (const PaymentRail* rail = gRails.For(order->payChoice)) {
if (auto instr = rail->Instructions(order->payId, order->totalMinor)) {
OrderCryptoPay pay;
pay.address = instr->address;
pay.amount = instr->amount;
const std::int64_t now =
std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
pay.minutesLeft = (instr->deadlineUnix - now) / 60;
for (auto& c : instr->chains) {
pay.chains.push_back({ std::move(c.name), std::move(c.contract),
std::move(c.link), std::move(c.note) });
}
view.cryptoPay = std::move(pay);
}
}
}
// The indicative national-currency line: ECB reference rates baked in
// at build time, converted to whole units, labelled with the rate
// date. Purely informative — the euro amount is the charge.
@ -893,7 +916,7 @@ namespace {
// configured rail's cadence, because that rail's orders deserve it, so
// "poll on every pass" would silently poll the slower provider at the faster
// one's rate — with two rails that is no longer a rounding error but double
// the request volume CoinGate was promised. `first` drives the age backoff,
// the request volume the slower one was promised. `first` drives the age backoff,
// `last` enforces the interval; keeping them apart also retires the modulo
// pacing that used to approximate this with one.
void ReconcilerLoop(const std::stop_token& stop) {

View file

@ -269,12 +269,9 @@ private:
} // namespace
// The roster has one home, here; the CoinGate constructor is declared by its
// own unit. An unrecognised mode is "off" rather than an error, and the caller
// (main) is what refuses to start on a mode it did not expect — a rail that
// silently half-exists would be worse than either.
std::unique_ptr<PaymentRail> MakeCoingateRail(const RailConfig& config);
// The roster has one home, here. An unrecognised mode is "off" rather than an
// error, and the caller (main) is what refuses to start on a mode it did not
// expect — a rail that silently half-exists would be worse than either.
std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config) {
// The fake rail keeps the slot's own name so the ledger, the startup line
// and the logs still say which half of the checkout ran in a test.
@ -284,8 +281,12 @@ std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config) {
if (config.mode == "fake-crypto") {
return std::make_unique<FakeRail>(config.statePath, "fake-crypto");
}
if (config.mode == "mollie") return std::make_unique<MollieRail>(config);
if (config.mode == "coingate") return MakeCoingateRail(config);
if (config.mode == "mollie") return std::make_unique<MollieRail>(config);
// "eurc" is the one mode that can fail to construct for a reason other than
// a typo: its chains file or address pool may not load. It returns nullptr
// there, which main reports as an unknown rail — see the note in main about
// why that message names the files.
if (config.mode == "eurc") return MakeEurcRail(config);
return nullptr; // "off"
}

View file

@ -25,8 +25,8 @@ No permission is granted to copy, modify, distribute, or create derivative works
// at the last known prices, and a hand-placed cache file is how dev and e2e
// get a table with no account at all.
//
// Like the CoinGate rail, this code has not run against the real API — no
// credentials existed at build time. ParseSendcloudMethods is exercised by the
// This code has not run against the real API — no credentials existed at
// build time. ParseSendcloudMethods is exercised by the
// self-test against a canned response; the fetch around it is thin. The one
// thing to verify against a live payload is the weight fields: this reads
// `min_weight`/`max_weight` as kilogram strings, which is what the v2 docs

File diff suppressed because it is too large Load diff

View file

@ -69,9 +69,10 @@ export namespace Catcrafts::Server {
// rail issued the link, and so which one
// may confirm it. Always set: checkout
// normalises before writing.
std::string payUrl; // the provider's hosted checkout link
std::string payUrl; // the provider's hosted checkout link;
// for the EURC rail, the order page itself
std::string payId; // provider payment id ("tr_…" at Mollie,
// a decimal order id at CoinGate)
// "<address>@<deadline>" at the EURC rail)
std::string paidVia; // method that settled it ("ideal", "bitcoin")
std::string paidAt; // ISO 8601 of the FIRST paid event; empty =
// never paid. A later cancel (a refund)
@ -239,7 +240,7 @@ export namespace Catcrafts::Server {
// The registered business identity — on every invoice and at the foot of
// every order email. One definition, like the rest of the compiled-in
// authored content; the selftest pins the values.
// authored content; the ShouldBuildInvoices test pins the values.
inline constexpr std::string_view kSellerName = "Catcrafts";
inline constexpr std::string_view kSellerStreet = "Chico Mendesring 256";
inline constexpr std::string_view kSellerCity = "3315NN Dordrecht";
@ -326,16 +327,35 @@ export namespace Catcrafts::Server {
};
// What a poll learned about one payment. Pending and Dead are different
// answers on purpose: both providers EXPIRE unpaid orders — Mollie after
// its own window, CoinGate after two hours (twenty minutes once a coin is
// picked) — and an order whose payment can never arrive should lapse
// rather than sit "awaiting" forever.
// answers on purpose: an unpaid order does not stay payable forever —
// Mollie expires its payments after its own window, and the EURC rail
// closes its own (24 hours by default) — and an order whose payment can
// never arrive should lapse rather than sit "awaiting" forever.
enum class PayState { Pending, Paid, Dead };
struct PaidStatus {
PayState state = PayState::Pending;
std::string method; // "ideal" | "creditcard" | "bitcoin" | …
};
// Self-hosted payment instructions for the order page. A hosted rail sends
// the buyer to the provider's checkout and returns nullopt here; a
// self-hosted rail has no such page, so the order page must itself say
// where the money goes. One chain option per network the rail watches, in
// the rail's configured order — the file order IS the display order, which
// is how "the cheap chain first" stays configuration.
struct PayChainOption {
std::string name; // "base" — also the ledger via suffix
std::string contract; // token contract, for the buyer to verify
std::string link; // EIP-681 URI a wallet can open; may be empty
std::string note; // optional display hint ("lowest fees")
};
struct PayInstructions {
std::string address; // where the money goes
std::string amount; // decimal token amount ("570.43")
std::int64_t deadlineUnix = 0;
std::vector<PayChainOption> chains;
};
class PaymentRail {
public:
virtual ~PaymentRail() = default;
@ -349,6 +369,15 @@ export namespace Catcrafts::Server {
// expired/canceled/failed kills an order.
virtual std::optional<PaidStatus> CheckPaid(const std::string& payId,
std::int64_t expectedMinor) = 0;
// What the order page should tell the buyer to do, for rails without a
// hosted checkout. Default nullopt: rails with a provider page keep
// sending the buyer there. Const and lock-free by contract — it reads
// only configuration fixed at load.
virtual std::optional<PayInstructions> Instructions(const std::string& payId,
std::int64_t totalMinor) const {
(void)payId; (void)totalMinor;
return std::nullopt;
}
virtual std::string_view Name() const = 0;
// How often the reconciler sweeps. The fake rail returns something
// tiny so tests are fast; the real providers get a respectful cadence.
@ -356,11 +385,19 @@ export namespace Catcrafts::Server {
};
struct RailConfig {
std::string mode; // "off" | "fake" | "mollie" | "coingate"
std::string apiKey; // mollie: live_… or test_…; coingate: its token
bool sandbox = false; // coingate: api-sandbox.coingate.com
std::string mode; // "off" | "fake" | "mollie" | "eurc"
std::string apiKey; // mollie: live_… or test_…
std::filesystem::path statePath; // fake: the paid marker
std::string redirectBase = "https://catcrafts.net";
// eurc: the self-hosted rail holds no credential at all — what it needs
// instead is a list of chains to watch and a list of addresses it is
// allowed to hand out. Both are files rather than environment values
// because both are lists, and the pool in particular is edited by a
// human topping it up from the wallet.
std::filesystem::path eurcChainsPath;
std::filesystem::path eurcPoolPath;
int eurcWindowHours = 24; // 0 or less means the 24h default
};
// nullptr for mode "off" — that slot then offers no payment choice.
@ -372,7 +409,7 @@ export namespace Catcrafts::Server {
// advertise a way to pay the server would then refuse.
struct PaymentRails {
std::unique_ptr<PaymentRail> bank; // Mollie: iDEAL, cards, transfer
std::unique_ptr<PaymentRail> crypto; // CoinGate: on-chain and Lightning
std::unique_ptr<PaymentRail> crypto; // EURC: self-hosted, on-chain
bool Any() const { return bank != nullptr || crypto != nullptr; }
// The rail that owns a stored order, by its recorded choice. Total on
@ -399,28 +436,47 @@ export namespace Catcrafts::Server {
};
std::optional<MolliePayment> ParseMolliePayment(std::string_view json);
// Parsed essentials of a CoinGate /api/v2/orders object. Same shape and
// same reason as MolliePayment: the parser is the part worth testing.
//
// `id` is a JSON NUMBER on the wire ("id":538) rather than a string, so it
// is rendered to decimal here and travels through the ledger as text like
// every other payment id.
struct CoingateOrder {
std::string id;
std::string status; // new|pending|confirming|paid|invalid|
// expired|canceled|refunded|partially_refunded
std::string payCurrency; // the coin the shopper picked; empty until then
std::string payUrl; // the hosted invoice, present while payable
std::int64_t priceMinor = 0; // price_amount, and only when EUR
};
std::optional<CoingateOrder> ParseCoingateOrder(std::string_view json);
// Exact decimal-string-to-minor-units parser for the amounts both provider
// APIs quote as strings ("614.00" -> 61400). Rejects anything that is not
// Exact decimal-string-to-minor-units parser for the amounts Mollie's API
// quotes as strings ("614.00" -> 61400). Rejects anything that is not
// a plain non-negative decimal with at most two fraction digits — no
// floats touch money on the way in either. Exported for the self-test.
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s);
// One chain the EURC rail watches. Every field is configuration because
// every field is a fact about the world rather than about this shop:
// Circle deploys to a new chain, an RPC endpoint moves, a token is
// redeployed. See Catcrafts.Server-Eurc.cpp for why one address covers all
// of them at once.
struct EurcChain {
std::string name; // ledger via suffix: "base" -> "eurc-base"
std::string rpcUrl;
std::string contract; // the EURC token contract on this chain
std::string blockTag = "finalized";
int decimals = 6;
// For the order page. chainId names the network in the EIP-681 wallet
// link (1 = Ethereum, 8453 = Base); 0 omits the link rather than
// guessing. note is a short display hint ("lowest fees") — copy is
// configuration here because fee facts change without a deploy.
std::int64_t chainId = 0;
std::string note;
};
// nullopt for a malformed file — partial success is refused, because a
// chain that silently dropped out of the list is a chain whose payments
// stop being noticed while the shop still advertises it.
std::optional<std::vector<EurcChain>> ParseEurcChains(std::string_view json);
// A uint256 hex word as eth_call returns it, reduced to an int64 and
// saturating rather than wrapping. nullopt covers a JSON-RPC error object
// too: "the node refused" must never read as "the balance is zero".
// Exported for the self-test — the decoding is where a mistake costs money,
// the HTTP around it is thin.
std::optional<std::int64_t> ParseEthCallUint(std::string_view json);
// nullptr when the chains file or the address pool will not load. The rail
// holds no key and no credential; it can only ever hand out an address it
// was given.
std::unique_ptr<PaymentRail> MakeEurcRail(const RailConfig& config);
// ── shipping rates ────────────────────────────────────────────────
//
// Live per-country, per-weight-bracket rates from Sendcloud's