This commit is contained in:
parent
33c68c2f44
commit
749f525f83
44 changed files with 5380 additions and 3532 deletions
|
|
@ -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
|
||||
673
server/implementations/Catcrafts.Server-Eurc.cpp
Normal file
673
server/implementations/Catcrafts.Server-Eurc.cpp
Normal 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
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue