All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
425 lines
21 KiB
C++
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;
|
|
}
|