catcrafts.net/server/implementations/Catcrafts.Server-Eurc.cpp
Jorijn van der Graaf 749f525f83
All checks were successful
Deploy / build-deploy (push) Successful in 4m19s
tests and eurc
2026-08-15 00:54:05 +02:00

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