All checks were successful
Deploy / build-deploy (push) Successful in 4m19s
673 lines
30 KiB
C++
673 lines
30 KiB
C++
/*
|
|
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
|