catcrafts.net/tests/ShouldIssueEurcAddresses/main.cpp
Jorijn van der Graaf abbd616b40
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
donation item, shop soft open
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:04:03 +02:00

425 lines
21 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 rail's address pool: which address a stranger is told to send money
// to, and how many times that address may be told to anybody.
//
// There is no processor here to notice a mistake. Handing one address to two
// orders means the second buyer's EURC settles the FIRST buyer's order while
// the second lapses unpaid — real money at an address we control, and a
// support ticket only a human can close. So the burn-before-handing-out order,
// the persisted cursor, the resume after a restart, and every pool the rail
// refuses to start with are all pinned here, alongside the EIP-681 amount the
// buyer's wallet actually pre-fills.
//
// Nothing in this suite touches the network. The chains fixture points at
// unreachable endpoints on purpose, and CheckPaid is exercised ONLY on payIds
// that fail to split — the one branch that answers before an RPC is dialed.
import std;
import Catcrafts.Shared;
import Catcrafts.Server;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
void WriteFile(const std::filesystem::path& p, std::string_view content) {
std::ofstream(p, std::ios::binary) << content;
}
// "0x" + 40 hex digits, with a short tail naming the line it belongs to. Built
// rather than typed out: a 40-character literal is exactly where a miscount
// hides, and a pool line one digit short would exercise the refusal path
// instead of whatever the assertion meant to prove.
std::string Addr(std::string_view tail) {
std::string s = "0x";
s.append(40 - tail.size(), '0');
s.append(tail);
return s;
}
// The rail parks its high-water mark beside the pool, as "<pool>.cursor".
std::filesystem::path CursorOf(const std::filesystem::path& pool) {
return std::filesystem::path(pool.string() + ".cursor");
}
std::optional<std::size_t> CursorValue(const std::filesystem::path& pool) {
std::ifstream in(CursorOf(pool), std::ios::binary);
std::size_t v = 0;
if (!(in >> v)) return std::nullopt;
return v;
}
std::string Show(const std::optional<std::size_t>& v) {
return v ? std::to_string(*v) : std::string("(no cursor)");
}
// The payId is "<address>@<unix-deadline>"; both halves are asserted
// separately because they fail for different reasons.
std::string AddressOf(std::string_view payId) {
const std::size_t at = payId.rfind('@');
if (at == std::string_view::npos) return {};
return std::string(payId.substr(0, at));
}
std::int64_t DeadlineOf(std::string_view payId) {
const std::size_t at = payId.rfind('@');
if (at == std::string_view::npos) return 0;
const std::string_view digits = payId.substr(at + 1);
std::int64_t v = 0;
const auto [ptr, ec] =
std::from_chars(digits.data(), digits.data() + digits.size(), v);
if (ec != std::errc{} || ptr != digits.data() + digits.size()) return 0;
return v;
}
std::int64_t NowUnix() {
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
}
Server::RailConfig Config(const std::filesystem::path& chains,
const std::filesystem::path& pool,
int windowHours = 24) {
return Server::RailConfig{ .mode = "eurc",
.eurcChainsPath = chains,
.eurcPoolPath = pool,
.eurcWindowHours = windowHours };
}
// Two chains on purpose. The first carries a chain_id, so it renders a wallet
// link; the second omits it, so the "watched but not linkable" branch is real
// rather than hypothetical. Both endpoints are unreachable by design — a
// suite that accidentally dialed one would be a suite that fails on a train.
constexpr std::string_view kChains = R"({"chains":[
{"name":"base","rpc":"https://rpc.invalid/base",
"contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42",
"decimals":6,"chain_id":8453,"note":"lowest fees"},
{"name":"quiet","rpc":"https://rpc.invalid/quiet",
"contract":"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c"}]})";
// Whatever the checkout hands in as the redirect. This rail has no hosted
// page of its own, so this exact string is what must come back out.
const std::string kOrderPage = "https://catcrafts.net/order/tok";
} // namespace
int main() {
const std::filesystem::path root =
std::filesystem::temp_directory_path() / "catcrafts-eurc-pool";
std::error_code ec;
std::filesystem::remove_all(root, ec);
std::filesystem::create_directories(root, ec);
const std::filesystem::path chains = root / "chains.json";
WriteFile(chains, kChains);
// ── one address, one order ────────────────────────────────────────
{
const std::filesystem::path pool = root / "issue.txt";
// Line 1 is written in checksum case, the way a wallet exports it.
// Everything downstream — the pool, the payId, the covering check —
// compares the lowercase form, so that is what must come back out.
WriteFile(pool, Addr("A1") + "\n" + Addr("b2") + "\n" + Addr("c3") + "\n");
std::unique_ptr<Server::PaymentRail> rail =
Server::MakeEurcRail(Config(chains, pool));
Check(rail != nullptr, "pool: a valid chains file and a valid pool load");
if (rail) {
Check(rail->Name() == "eurc", "rail: names itself — the ledger via prefix");
Check(rail->PollInterval() == std::chrono::seconds(30),
"rail: sweeps at a finality-shaped cadence, not a busy one");
const std::int64_t before = NowUnix();
const std::optional<Server::PaymentLink> first =
rail->CreateLink(57043, "desc", kOrderPage);
const std::optional<Server::PaymentLink> second =
rail->CreateLink(57043, "desc", kOrderPage);
Check(first.has_value() && second.has_value(),
"issue: two checkouts each get a payment link");
if (first && second) {
Check(AddressOf(first->payId) == Addr("a1"),
"issue: the first order gets pool line 1, lowercased",
first->payId);
Check(AddressOf(second->payId) == Addr("b2"),
"issue: the second order gets pool line 2", second->payId);
// The assertion this whole rail exists to satisfy.
Check(AddressOf(first->payId) != AddressOf(second->payId),
"issue: never the same address twice");
// There is no hosted checkout to send the buyer to: the order
// page IS the payment page, so the caller's own URL comes back.
Check(first->payUrl == kOrderPage,
"issue: no provider page — the redirect is handed back",
first->payUrl);
// 24 configured hours = 86400 seconds past the moment of issue.
// Bracketed by readings taken either side of the call, so the
// bound is exact rather than a tolerance that could drift.
const std::int64_t deadline = DeadlineOf(first->payId);
Check(deadline >= before + 86400 && deadline <= NowUnix() + 86400,
"issue: the deadline is the moment of issue plus the window",
std::to_string(deadline - before));
}
// Refused before the pool is touched. A zero or negative total is a
// caller bug, and burning an address for one would spend the single
// resource this rail cannot regenerate on its own.
Check(!rail->CreateLink(0, "desc", kOrderPage).has_value(),
"issue: a zero total buys no address");
Check(!rail->CreateLink(-1, "desc", kOrderPage).has_value(),
"issue: a negative total buys no address");
}
// The cursor is on DISK, not merely in memory: it is the only thing
// standing between a restart and republishing line 1 to a new buyer.
Check(CursorValue(pool) == std::size_t{ 2 },
"cursor: two issued, two burned — and the refusals burned none",
Show(CursorValue(pool)));
// A fresh rail over the SAME two files must resume, never rewind.
std::unique_ptr<Server::PaymentRail> restarted =
Server::MakeEurcRail(Config(chains, pool));
Check(restarted != nullptr, "restart: a partly spent pool still loads");
if (restarted) {
const std::optional<Server::PaymentLink> third =
restarted->CreateLink(57043, "desc", kOrderPage);
Check(third.has_value() && AddressOf(third->payId) == Addr("c3"),
"restart: issues line 3, not line 1",
third ? third->payId : std::string("no link"));
}
Check(CursorValue(pool) == std::size_t{ 3 },
"cursor: the restarted rail advanced the same file",
Show(CursorValue(pool)));
}
// ── what the buyer's wallet is told to send ───────────────────────
{
const std::filesystem::path pool = root / "instructions.txt";
WriteFile(pool, Addr("d1") + "\n" + Addr("d2") + "\n");
std::unique_ptr<Server::PaymentRail> rail =
Server::MakeEurcRail(Config(chains, pool));
Check(rail != nullptr, "instructions: rail loads");
if (rail) {
const std::string payId = Addr("ab") + "@1800000000";
const std::optional<Server::PayInstructions> ins =
rail->Instructions(payId, 57043);
Check(ins.has_value(), "instructions: a well-formed payId renders");
if (ins) {
Check(ins->address == Addr("ab"),
"instructions: the address half is where the money goes",
ins->address);
// EURC is euro-denominated at par, so the token figure IS the
// euro figure: 57043 cents shown as 570.43. No rate, no quote.
Check(ins->amount == "570.43",
"instructions: the amount is the euro total at par",
ins->amount);
Check(ins->deadlineUnix == 1800000000,
"instructions: the deadline travels inside the id");
Check(ins->chains.size() == 2,
"instructions: every watched chain is offered, in file order",
std::to_string(ins->chains.size()));
}
if (ins && ins->chains.size() == 2) {
Check(ins->chains[0].name == "base"
&& ins->chains[0].note == "lowest fees",
"instructions: the first chain is the file's recommendation");
Check(ins->chains[0].contract
== "0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42",
"instructions: contract lowercased for the buyer to compare",
ins->chains[0].contract);
// The uint256 is the literal quantity the wallet sends:
// cents x 10^(decimals-2) = 57043 x 10^4 = 570430000 base
// units. A wrong scale charges 10,000x too much or too little,
// and the too-little case never satisfies the covering check in
// CheckPaid — so the order lapses with real money already sat
// at our address, which is the expensive direction.
const std::string expected =
"ethereum:0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42@8453"
"/transfer?address=" + Addr("ab") + "&uint256=570430000";
Check(ins->chains[0].link == expected,
"instructions: EIP-681 link, amount in token base units",
ins->chains[0].link);
// chain_id 0 means "watched, but we cannot name the network in
// a wallet link". The chain is still LISTED so the buyer can
// pay there by hand — an empty link, never a guessed one.
Check(ins->chains[1].name == "quiet" && ins->chains[1].link.empty(),
"instructions: a chain without a chain_id is listed, unlinked",
ins->chains[1].link);
}
// The same id in checksum case resolves to the same address: what
// is published, compared and paid is always the lowercase form.
const std::optional<Server::PayInstructions> upper =
rail->Instructions(Addr("AB") + "@1800000000", 57043);
Check(upper.has_value() && upper->address == Addr("ab"),
"instructions: a mixed-case payId normalises to one address");
Check(!rail->Instructions(payId, 0).has_value(),
"instructions: nothing to ask a buyer for at zero");
Check(!rail->Instructions("garbage", 57043).has_value(),
"instructions: an id that does not split renders nothing");
}
}
// ── an id that cannot identify a payment ──────────────────────────
//
// These are the ONLY CheckPaid inputs this suite may use: each fails to
// split, and SplitPayId runs before BalanceOf, so the answer arrives
// without a single RPC. A truncated or hand-edited ledger line must lapse
// its order — returning Pending here would leave it awaiting payment
// forever, and treating it as an address worth polling would ask a chain
// about a string we never issued.
{
const std::filesystem::path pool = root / "dead.txt";
WriteFile(pool, Addr("e1") + "\n");
std::unique_ptr<Server::PaymentRail> rail =
Server::MakeEurcRail(Config(chains, pool));
Check(rail != nullptr, "dead: rail loads");
if (rail) {
for (const std::string& id : { std::string("not-an-id"),
Addr("ab") + "@notanumber",
std::string("0xdeadbeef@1800000000"),
std::string("@1800000000"),
Addr("ab") + "@" }) {
const std::optional<Server::PaidStatus> st =
rail->CheckPaid(id, 57043);
Check(st.has_value() && st->state == Server::PayState::Dead
&& st->method.empty(),
"dead: a payId that does not split lapses the order", id);
}
}
}
// ── pools this shop refuses to start with ─────────────────────────
//
// Each of these is a STARTUP refusal rather than a runtime surprise. The
// failure a buyer would otherwise meet lands at the one moment they are
// already committed, so it is moved to the moment the operator is watching.
{
// Lines 2 and 4 are one address in two casings. A chain does not care
// about checksum case either, so a "different" line here is the
// address-reuse bug wearing a disguise.
const std::filesystem::path dup = root / "dup.txt";
WriteFile(dup, Addr("11") + "\n" + Addr("AB") + "\n" + Addr("33") + "\n"
+ Addr("ab") + "\n");
Check(Server::MakeEurcRail(Config(chains, dup)) == nullptr,
"refuse: a duplicate address, even in a different case");
// Fatal rather than skipped: a line that does not parse is as likely to
// be a mangled good address as a stray note, and skipping it would
// quietly shorten the list of places we can be paid.
const std::filesystem::path bad = root / "bad.txt";
WriteFile(bad, Addr("11") + "\n0xdeadbeef\n" + Addr("33") + "\n");
Check(Server::MakeEurcRail(Config(chains, bad)) == nullptr,
"refuse: a line that is not an address is fatal, never skipped");
const std::filesystem::path empty = root / "empty.txt";
WriteFile(empty, "");
Check(Server::MakeEurcRail(Config(chains, empty)) == nullptr,
"refuse: an empty pool has nothing to hand out");
Check(Server::MakeEurcRail(Config(chains, root / "absent.txt")) == nullptr,
"refuse: a pool file that does not exist");
const std::filesystem::path good = root / "good.txt";
WriteFile(good, Addr("f1") + "\n");
Check(Server::MakeEurcRail(Config(root / "absent.json", good)) == nullptr,
"refuse: a chains file that does not exist");
// Strict about addresses, not about tidiness: the comment lines, blank
// lines, indentation and CRLF a human produces while topping the pool
// up from the wallet must not be mistaken for a bad pool.
const std::filesystem::path messy = root / "messy.txt";
WriteFile(messy, "# topped up 2026-08-15 from the cold wallet\n"
"\n"
" " + Addr("d4") + " # first of the batch\n"
+ Addr("e5") + " \t\r\n"
"\n");
std::unique_ptr<Server::PaymentRail> tidy =
Server::MakeEurcRail(Config(chains, messy));
Check(tidy != nullptr, "accept: comments, blank lines, indent and trailing CR");
if (tidy) {
// Proves the stripping produced the ADDRESS and not the decoration
// around it — a loader that stored " 0x…d4" would still "load".
const std::optional<Server::PaymentLink> link =
tidy->CreateLink(1000, "desc", kOrderPage);
Check(link.has_value() && AddressOf(link->payId) == Addr("d4"),
"accept: the comment and the indent are stripped, not stored",
link ? link->payId : std::string("no link"));
}
// Exhausted is a refusal, not a wrap-around. Wrapping would reissue
// addresses already sitting in somebody's wallet app.
const std::filesystem::path used = root / "used.txt";
WriteFile(used, Addr("21") + "\n" + Addr("22") + "\n");
WriteFile(CursorOf(used), "2\n");
Check(Server::MakeEurcRail(Config(chains, used)) == nullptr,
"refuse: the cursor says every address in the pool is spent");
// An unreadable cursor reads as exhausted, never as zero. Rewinding to
// the top of a pool whose head is already published is the duplicate
// bug again, arriving through a corrupted file instead of a typo.
const std::filesystem::path garbled = root / "garbled.txt";
WriteFile(garbled, Addr("31") + "\n" + Addr("32") + "\n");
WriteFile(CursorOf(garbled), "x");
Check(Server::MakeEurcRail(Config(chains, garbled)) == nullptr,
"refuse: an unparseable cursor never rewinds to line 1");
}
// ── the payment window ────────────────────────────────────────────
{
const std::filesystem::path pool = root / "window.txt";
WriteFile(pool, Addr("91") + "\n" + Addr("92") + "\n");
// Zero hours is not "no window": there is no processor to expire
// anything here, so an unset value has to mean the generous default
// rather than a deadline that is already in the past at issue time.
std::unique_ptr<Server::PaymentRail> dflt =
Server::MakeEurcRail(Config(chains, pool, 0));
Check(dflt != nullptr, "window: the default-window rail loads");
if (dflt) {
const std::int64_t before = NowUnix();
const std::optional<Server::PaymentLink> link =
dflt->CreateLink(1000, "desc", kOrderPage);
Check(link.has_value()
&& DeadlineOf(link->payId) >= before + 24 * 3600
&& DeadlineOf(link->payId) <= NowUnix() + 24 * 3600,
"window: an unset window is 24 hours, not zero");
}
// The same pool, one address further along, with the hours set.
std::unique_ptr<Server::PaymentRail> hour =
Server::MakeEurcRail(Config(chains, pool, 1));
Check(hour != nullptr, "window: a one-hour rail loads on the same pool");
if (hour) {
const std::int64_t before = NowUnix();
const std::optional<Server::PaymentLink> link =
hour->CreateLink(1000, "desc", kOrderPage);
Check(link.has_value()
&& DeadlineOf(link->payId) >= before + 3600
&& DeadlineOf(link->payId) <= NowUnix() + 3600,
"window: the configured hours are what the id carries");
}
}
std::filesystem::remove_all(root, ec);
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}