payment tests
Some checks failed
Deploy / build-deploy (push) Failing after 4m58s

This commit is contained in:
Jorijn van der Graaf 2026-08-20 01:36:42 +02:00
commit 45992c4f91
10 changed files with 670 additions and 13 deletions

View file

@ -15,6 +15,35 @@ jobs:
build-deploy:
runs-on: arch-latest
steps:
- name: Check live payment test credentials
# The live-provider suites (ShouldCreateMollieTestPayments,
# ShouldSettleEurcOnTestnet) are mandatory deploy gates, so a missing
# or malformed secret fails HERE, before any build time is spent —
# never as a silent skip. Secrets live in Forgejo: repo Settings ->
# Actions -> Secrets. See deploy/README.md "Live payment suites in CI"
# for what each one is and how to fund the testnet wallet.
env:
MOLLIE_TEST_API_KEY: ${{ secrets.MOLLIE_TEST_API_KEY }}
EURC_E2E_PRIVATE_KEY: ${{ secrets.EURC_E2E_PRIVATE_KEY }}
run: |
set -eu
ok=1
if [ -z "${MOLLIE_TEST_API_KEY:-}" ]; then
echo "ERROR: MOLLIE_TEST_API_KEY secret is not set (Mollie dashboard -> Developers -> API keys, the test_ one)." >&2
ok=0
else
case "$MOLLIE_TEST_API_KEY" in
test_*) ;;
*) echo "ERROR: MOLLIE_TEST_API_KEY is not a test_ key — the suite refuses to create payments on a live key." >&2; ok=0 ;;
esac
fi
if [ -z "${EURC_E2E_PRIVATE_KEY:-}" ]; then
echo "ERROR: EURC_E2E_PRIVATE_KEY secret is not set (a Base Sepolia key holding testnet EURC + gas)." >&2
ok=0
fi
[ "$ok" = 1 ] || exit 1
echo "Live payment test credentials are present."
- name: Install build dependencies
run: |
# Same keyring bootstrap the Crafter.Build CI does: the slim
@ -63,6 +92,19 @@ jobs:
cp -r /tmp/cb/share/crafter-build /usr/share/
crafter-build --version || true
- name: Install cast (Foundry)
# The ShouldSettleEurcOnTestnet gate pays its own test order with a
# real ERC-20 transfer on Base Sepolia, and cast is the only tool a
# stock shell has that can sign one — the server deliberately cannot,
# that is the EURC rail's design. Extracted alone: forge/anvil/chisel
# are dead weight here.
run: |
set -eux
url="https://github.com/foundry-rs/foundry/releases/download/stable/foundry_stable_linux_amd64.tar.gz"
curl -fsSL "$url" -o /tmp/foundry.tar.gz
tar -xzf /tmp/foundry.tar.gz -C /usr/local/bin cast
cast --version
- name: Checkout
uses: actions/checkout@v4
@ -144,11 +186,19 @@ jobs:
# E2E_MEDIA_DIR points ShouldServePostPages at the mirror mount, where
# the media files actually are.
#
# The two payment secrets feed the live-provider suites, which run as
# part of this same gate: a deploy that cannot create a Mollie test
# payment or settle a testnet EURC transfer does not ship. If the
# EURC suite fails at `cast send`, the testnet wallet is empty — see
# deploy/README.md "Live payment suites in CI" for the faucets.
#
# Same refuse-to-guess rule as the wasm bundle below: a variant
# directory embeds a config hash, so more than one match means the tree
# is ambiguous and picking the first would deploy an arbitrary build.
env:
E2E_MEDIA_DIR: ${{ steps.media.outputs.dir }}
MOLLIE_TEST_API_KEY: ${{ secrets.MOLLIE_TEST_API_KEY }}
EURC_E2E_PRIVATE_KEY: ${{ secrets.EURC_E2E_PRIVATE_KEY }}
run: |
set -eux
crafter-build --product=server

View file

@ -387,6 +387,48 @@ Mechanics worth knowing:
* VAT is unchanged by payment method: the sale is priced and invoiced in euro
and taxed on the euro value, whichever rail settled it.
### Live payment suites in CI
Every deploy runs both rails against their real counterparts, as a mandatory
gate — a push that cannot create a Mollie test payment or settle a real EURC
transfer on Ethereum Sepolia does not ship. That is a deliberate
caution-over-convenience trade: a public RPC hiccup can fail a deploy (re-run
the workflow), but the rail code can never drift from the live providers
unnoticed. The suites are `ShouldCreateMollieTestPayments` (create + poll on a
test-mode key; Mollie has no API to mark a test payment paid, so the paid flip
stays with the fake-rail suite and the pre-launch click-through) and
`ShouldSettleEurcOnTestnet` (a €1 donation paid with 1 real testnet EURC,
settled by the same two-endpoint quorum as mainnet). Locally both skip unless
their variable is exported; in CI a missing secret is a failure, checked as
the workflow's first step.
Two Forgejo secrets feed them (repo → Settings → Actions → Secrets):
* `MOLLIE_TEST_API_KEY` — the `test_` key from the Mollie dashboard
(Developers → API keys). The suite refuses anything not starting `test_`,
so the live key can never be pasted in by mistake without the gate saying so.
* `EURC_E2E_PRIVATE_KEY` — a throwaway Ethereum Sepolia key
(`cast wallet new`), used ONLY for this: it holds worthless testnet tokens,
never mainnet funds. Fund it with testnet EURC at
<https://faucet.circle.com> — **network set to Ethereum Sepolia**, one claim
is ~20 EURC — and Sepolia ETH for gas from the pk910 PoW faucet
(<https://sepolia-faucet.pk910.de>, no account, it mines in a browser tab;
one ~0.05 ETH claim covers hundreds of transfers). **Each deploy spends
1 EURC plus gas**, and the suite's receiving addresses are random — the
EURC is gone after the run. When the wallet runs dry the EURC suite fails
at `cast send` with the faucet pointers in its output; top up and re-run.
The workflow installs Foundry's `cast` for the transfer — the server itself
cannot sign transactions, by design. The EURC suite watches `block_tag:
"latest"` (not production's `finalized`) because Sepolia finality is ~13
minutes; the rail's reorg warning in that suite's server log is expected. To
prove the funded wallet end-to-end before it gates a deploy, run the suite
live once from a dev shell:
```sh
EURC_E2E_PRIVATE_KEY=0x... crafter-build test ShouldSettleEurcOnTestnet --product=server
```
## Shipping rates: Sendcloud (REQUIRED to sell)
Sendcloud is the only source of shipping prices. There is no compiled-in

View file

@ -268,6 +268,25 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
.Requires("tool:gpg")
.Timeout(std::chrono::seconds(180));
// ── live-provider suites ──────────────────────────────────────
// The two payment rails against their REAL counterparts: Mollie's
// API on a test-mode key, and the EURC rail settling an actual
// on-chain transfer on Base Sepolia. Both are MANDATORY deploy
// gates in CI (the workflow supplies MOLLIE_TEST_API_KEY and
// EURC_E2E_PRIVATE_KEY, and the suites fail there if either is
// missing); locally they skip unless those variables are exported.
// That gate lives in the suites themselves rather than in a
// Requires() — a Requires would skip silently when the credential
// vanished, which is exactly the pass this gate must never give.
// Timeouts cover real network: Mollie is a few round trips; the
// EURC suite waits out block inclusion plus the rail's 30 s poll.
cfg.AddTest("ShouldCreateMollieTestPayments", harness)
.Dependencies({ &cfg, shared, network }).Args({ serverBin })
.Timeout(std::chrono::seconds(120));
cfg.AddTest("ShouldSettleEurcOnTestnet", harness)
.Dependencies({ &cfg, shared, network }).Args({ serverBin })
.Timeout(std::chrono::seconds(300));
return cfg;
}

View file

@ -216,6 +216,25 @@ bool JsonRpcIdIs(std::string_view json, std::int64_t want) {
return static_cast<std::int64_t>(id->number) == want;
}
std::string BalanceOfCallData(std::string_view address) {
// Selector, then one 32-byte ABI word: 12 zero BYTES of left padding (24
// hex digits), then the 20 address bytes (40 hex digits). The first
// version of this appended 24 bytes of padding — bytes and hex digits
// confused — which shifted the address past the argument word. Solidity's
// decoder read the word's low 20 bytes (mostly padding plus the address's
// first 8 bytes), found a valid-looking address with nothing on it, and
// answered 0: every real payment read as "not arrived", with no error on
// either side. Pinned byte-for-byte in ShouldParseEurcChains, and proven
// against a real chain in ShouldSettleEurcOnTestnet — the suite that
// caught it.
std::string data;
data.reserve(2 + 8 + 64);
data += kBalanceOfSelector;
data.append(12 * 2, '0');
data += address.substr(2);
return data;
}
std::optional<std::int64_t> ParseEthCallUint(std::string_view json) {
auto doc = Json::Parse(json);
if (!doc || !doc->IsObject()) return std::nullopt;
@ -809,13 +828,9 @@ private:
const std::string& address) {
// A node that is not serving this chain does not get to answer.
if (!EndpointServesChain(chain, url)) return std::nullopt;
// 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);
// eth_call to the token contract; the calldata encoding lives in
// BalanceOfCallData, where the self-test can pin it.
const std::string data = BalanceOfCallData(address);
const std::string body =
std::string(R"({"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":")")

View file

@ -410,6 +410,16 @@ export namespace Catcrafts::Server {
// the HTTP around it is thin.
std::optional<std::int64_t> ParseEthCallUint(std::string_view json);
// The eth_call data for balanceOf(address): the 4-byte selector, then the
// address left-padded into ONE 32-byte ABI word. Exported for the
// self-test for the same reason as ParseEthCallUint, learned the hard
// way: this encoding once padded 24 zero bytes instead of 12 (a
// bytes-vs-hex-digits slip), which pushed the address out of the argument
// word — every node then answered balanceOf of a zero-balance garbage
// address, cleanly, and paid orders read as unpaid forever. No error
// anywhere; the testnet e2e suite is what caught it.
std::string BalanceOfCallData(std::string_view address);
// 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.

View file

@ -148,7 +148,7 @@ export const std::vector<Product>& Products() {
// Android with the Play Store on screen — which is the single thing
// this listing exists to sell the absence of.
q.image = "/fp6plus-pmos.jpg";
q.summary = "The Fairphone (Gen. 6+), reflashed by Catcrafts to run postmarketOS with the same patches and IMS/VoLTE implementation as the Gen. 6 — that image is confirmed working on this board. The hardware is Fairphone's mid-generation refresh: a faster Snapdragon and 12 GB of RAM in the same repairable chassis, with the same twelve replaceable modules and spare parts through 2033. All of that software is open source. You can download it and flash a Fairphone yourself, and you are welcome to. What you pay for here is the thing open source doesn't come with: real support. A phone that arrives working, and one email address that answers for it. Not a forum, not a git issue, but support like any other manufacturer offers. And the margin funds the development itself.";
q.summary = "The Fairphone (Gen. 6+), reflashed by Catcrafts to run postmarketOS with the patches and IMS/VoLTE implementation Catcrafts maintains. All of that software is open source. You can download it and flash a Fairphone yourself, and you are welcome to. What you pay for here is the thing open source doesn't come with: real support. A phone that arrives working, and one email address that answers for it. Not a forum, not a git issue, but support like any other manufacturer offers. And the margin funds the development itself.";
q.warranty = kPhoneWarranty;
// Supplier €649.00 incl VAT in every colour — Fairphone's own launch
// price, which is what the device costs anywhere a day after launch and
@ -416,14 +416,14 @@ export const std::vector<LegalPage>& LegalPages() {
{ "Image credits",
{
"The product renders are the manufacturer's own: © Fairphone, used under the Creative Commons Attribution-ShareAlike 4.0 licence, https://creativecommons.org/licenses/by-sa/4.0 . The Fairphone (Gen. 6) in Forest Green came through Wikimedia Commons; the Fairphone (Gen. 6+) in Cobalt Blue comes from Fairphone's own photostream at https://www.flickr.com/photos/fairphone . Both were cropped and recompressed to fit this site, and those adaptations are offered back under the same licence.",
"They are renders of stock hardware, which is exactly what Catcrafts ships — the difference is the software on it. No manufacturer render shows postmarketOS, so treat the screens in those images as the manufacturer's artwork rather than a picture of what arrives.",
"They are renders of stock hardware, which is exactly what Catcrafts ships. The difference is the software on it. No manufacturer render shows postmarketOS, so treat the screens in those images as the manufacturer's artwork rather than a picture of what arrives.",
} },
},
},
{
.slug = "terms",
.title = "Terms",
.updated = "2026-08-17",
.updated = "2026-08-20",
.lede = "The terms for buying from this shop. Written to be read: short sections, no boilerplate imported from anywhere, and every claim checkable against what the site actually does.",
.sections = {
{ "Ordering and payment",
@ -447,10 +447,9 @@ export const std::vector<LegalPage>& LegalPages() {
} },
{ "Where Catcrafts sells",
{
"Catcrafts ships to a short list of countries, and the list is the whole policy: the Netherlands, Switzerland, Serbia, Montenegro, Albania, Kosovo, Georgia, Australia, Hong Kong and Singapore. Checkout accepts a delivery address in those and refuses every other country. The current list is always the one checkout enforces, so if a country is not offered at checkout it is not offered at all. Outside the Netherlands you are the importer: the parcel is sent duty unpaid, and your own customs authority charges you import VAT and any duty when it arrives. That is not a way of pushing costs onto you — in two of these countries it is the specific legal reason the sale is permitted at all.",
"Catcrafts ships to a short list of countries, and the list is the whole policy: the Netherlands, Switzerland, Serbia, Montenegro, Albania, Kosovo, Georgia, Australia, Hong Kong and Singapore. Checkout accepts a delivery address in those and refuses every other country. The current list is always the one checkout enforces, so if a country is not offered at checkout it is not offered at all. Outside the Netherlands you are the importer: the parcel is sent duty unpaid, and your own customs authority charges you import VAT and any duty when it arrives.",
"The reason it is a list of what is allowed rather than a list of what is not, is that the rules are national and there are nearly two hundred of them. Selling a phone into a country can enrol Catcrafts in that country's recycling and battery schemes, require its own radio approval, or require the handset to be registered in a national database before any local network will carry a call. Those obligations attach before the first parcel, not after it, and a shop this size cannot pretend to have read every country's version. So a country is added when it has actually been checked, and until then the answer is no. That is a deliberately cautious default and it will sometimes say no to a country that would have been fine.",
"If your country is not on the list, please write to orders@catcrafts.net. There is no waiting list and no promised date, but which country gets looked at next is decided by who asks, so asking genuinely works.",
"Catcrafts does not ship to the United States or Canada, and these are worth naming because they are the two people ask about most. Canada is not a choice: the phone holds no Canadian radio certification, and Canadian law prohibits importing, distributing or selling uncertified radio equipment, so it cannot lawfully be sold there by anyone at present. The United States is a combination — a customs entry is now required for every individual parcel, the mobile networks certify which handsets may be activated, and none of the legal protection that shields telephone companies over emergency calls extends to whoever made the device. Insurance is part of the picture too, but it is no longer the interesting part, and buying a policy would not open either country.",
"Catcrafts does not ship to the United States or Canada, and these are worth naming because they are the two people ask about most. Canada is not a choice: the phone holds no Canadian radio certification, and Canadian law prohibits importing, distributing or selling uncertified radio equipment, so it cannot lawfully be sold there by anyone at present. The United States is a combination: a customs entry is now required for every individual parcel, the mobile networks certify which handsets may be activated, and none of the legal protection that shields telephone companies over emergency calls extends to whoever made the device. Insurance is part of the picture too, but it is no longer the interesting part, and buying a policy would not open either country.",
"Separately, Catcrafts cannot sell or ship to Russia, Belarus or North Korea. Unlike everything above this is not a judgement call: European Union sanctions prohibit exporting consumer electronics to those countries, and the prohibition covers indirect routes, a forwarding address, a reseller, or a purchase made on behalf of someone there just as much as a direct parcel. Checkout refuses a delivery address in any of them, and an order that turns out to be bound there by another route is refused and the payment returned.",
"The same anti-routing rule applies to every refused country, not only the sanctioned ones: if a parcel is to be forwarded onward, or the delivery address belongs to a forwarding service acting for a customer elsewhere, the order is refused, and refunded in full if that only becomes clear after payment. Please do not try to route around it. The point is that the sale does not happen, not that the address looks acceptable.",
"One more thing that can refuse an address even inside the list: shipping is priced entirely from the carrier's own rates, so a destination the carrier has no rate for cannot be quoted and cannot be ordered. That is a different and much softer no than the ones above, it is about a price not existing rather than a sale being refused, and it can change whenever the carrier's tariff does.",

View file

@ -0,0 +1,194 @@
/*
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 Mollie rail against the REAL api.mollie.com, on a test-mode key: a €1
// donation whose payment is actually created at Mollie, polled by the real
// reconciler, and read back by this suite with its own authenticated GET.
// The fake-rail suites prove the lifecycle; the parser suite proves the
// decoding; what neither can prove is the live conversation — the bearer
// auth, the amount formatting Mollie accepts, the JSON shape they answer
// with today. This suite is a MANDATORY deploy gate in CI (the operator's
// call — caution over convenience); locally it skips unless
// MOLLIE_TEST_API_KEY is exported, and in CI that same missing secret is a
// FAILURE, never a quiet skip.
//
// Coverage stops at Pending, deliberately. Mollie has no API that marks a
// test payment paid — the test-mode checkout page is where a human (or a
// headless browser this repo does not carry) picks the outcome. So the paid
// transition stays covered by the fake rail and by the pre-launch manual
// click-through; what this suite pins is everything up to it: create, the
// checkout URL, the ledger record, the poll reading "open" as still-awaiting
// rather than as dead, and no errors on the wire. Test-mode payments expire
// at Mollie on their own; nothing is left behind.
//
// The key must be a test_ key. A live_ key is refused outright, in every
// environment: this suite creates payments, and a payment created on the
// live key is a real invoice in the shop's Mollie dashboard.
import std;
import Crafter.Network;
import Catcrafts.E2eHarness;
using namespace Catcrafts::E2e;
namespace {
bool IsCi() {
for (const char* v : { "CI", "GITHUB_ACTIONS", "FORGEJO_ACTIONS" }) {
if (const char* s = std::getenv(v); s && *s) return true;
}
return false;
}
std::string FirstMatch(const std::string& text, const std::string& pattern) {
std::smatch m;
if (std::regex_search(text, m, std::regex(pattern))) return m[1].str();
return {};
}
// One authenticated GET against the live API, the suite's own eyes on the
// payment the server created — same endpoint the reconciler polls, but read
// here independently so a server-side bug cannot vouch for itself.
std::string MollieGet(const std::string& key, const std::string& path) {
try {
Crafter::ClientHTTP1 client("api.mollie.com", 443,
Crafter::TLSClientCredentials{});
Crafter::HTTPRequest req;
req.method = "GET";
req.path = path;
req.authority = "api.mollie.com";
req.headers["authorization"] = "Bearer " + key;
req.headers["user-agent"] = "catcrafts.net-e2e/1.0 (+https://catcrafts.net)";
const Crafter::HTTPResponse res = client.Send(req);
if (res.status.size() != 3 || res.status[0] != '2') {
std::println(std::cerr, "mollie e2e: GET {} -> {} {}", path, res.status,
res.body.substr(0, 200));
return {};
}
return res.body;
} catch (const std::exception& e) {
std::println(std::cerr, "mollie e2e: GET {} failed: {}", path, e.what());
return {};
}
}
} // namespace
int main(int argc, char** argv) {
if (argc < 2) {
std::println(std::cerr, "usage: ShouldCreateMollieTestPayments <server-binary>");
return 2;
}
const char* keyEnv = std::getenv("MOLLIE_TEST_API_KEY");
if (!keyEnv || !*keyEnv) {
if (IsCi()) {
std::println(std::cerr,
"FAIL: MOLLIE_TEST_API_KEY is not set. This suite is a mandatory "
"deploy gate: add the secret in Forgejo (repo Settings -> Actions -> "
"Secrets) — the test_ key from the Mollie dashboard, Developers -> "
"API keys. See deploy/README.md \"Live payment suites in CI\".");
return 1;
}
std::println("ShouldCreateMollieTestPayments: skipped — MOLLIE_TEST_API_KEY is "
"not set (mandatory in CI, opt-in locally)");
return 0;
}
const std::string key(keyEnv);
if (!key.starts_with("test_")) {
std::println(std::cerr,
"FAIL: MOLLIE_TEST_API_KEY does not start with test_ — refusing to run "
"a payment-creating suite on anything but a test-mode key.");
return 1;
}
ServerOptions options;
options.extraArgs = { "--rail=mollie", "--crypto-rail=fake-crypto" };
options.env = { { "MOLLIE_API_KEY", key } };
TestServer srv(argv[1], 8219, options);
// ── checkout: a €1 donation on the bank rail ─────────────────────
// The 303 goes to Mollie's hosted checkout, not the order page — that IS
// the assertion: a real payment now exists and has somewhere to be paid.
const auto created = srv.Post("/shop/donation", "amount=1");
Check(created.status == "303", "a €1 donation 303s to the payment",
created.status);
std::string checkoutUrl;
if (const auto it = created.headers.find("location"); it != created.headers.end()) {
checkoutUrl = it->second;
}
Check(checkoutUrl.starts_with("https://")
&& checkoutUrl.find("mollie.com") != std::string::npos,
"the redirect is Mollie's hosted checkout", checkoutUrl);
if (created.status != "303" || checkoutUrl.empty()) {
std::println(std::cerr, "server log:\n{}",
ReadFile(srv.Work() / "server.log"));
return Finish();
}
// ── the ledger records the live payment ──────────────────────────
const std::string ledger = srv.OrdersText();
const std::string token = FirstMatch(ledger, R"lit("type":"order".*?"id":"([0-9a-f]{32})")lit");
const std::string payId = FirstMatch(ledger, R"lit("pay_id":"(tr_[A-Za-z0-9]+)")lit");
Check(!token.empty(), "the order reached the ledger");
Check(!payId.empty(), "the ledger carries Mollie's tr_ payment id");
Check(ledger.find("\"pay_choice\":\"bank\"") != std::string::npos,
"a donation with no pay field lands on the bank rail");
Check(ledger.find("\"total_minor\":100") != std::string::npos,
"€1 is stored as 100 cents");
if (token.empty() || payId.empty()) return Finish();
const std::string orderPath = std::format("/order/{}", token);
// ── the suite's own read of the payment at Mollie ─────────────────
// status open (fresh, method not yet chosen), the exact amount format
// Mollie accepted, and OUR redirect back to this order — the round trip
// that proves FormatMinor and the create body against the live API.
{
const std::string payment = MollieGet(key, "/v2/payments/" + payId);
Check(!payment.empty(), "the payment the server created exists at Mollie");
if (!payment.empty()) {
Check(payment.find("\"status\":\"open\"") != std::string::npos,
"a fresh test payment reads as open",
FirstMatch(payment, R"lit("status":"([a-z]+)")lit"));
Check(payment.find("\"currency\":\"EUR\"") != std::string::npos
&& payment.find("\"value\":\"1.00\"") != std::string::npos,
"the amount arrived as EUR 1.00");
Check(payment.find("/order/" + token) != std::string::npos,
"the payment's redirectUrl returns to this order");
}
}
// ── the real poll reads open as still-awaiting ────────────────────
// Rendering the page triggers the arrival poll and the reconciler polls
// on Mollie's 10 s cadence; give both time for at least two live GETs.
// "Open" must stay awaiting — parsed as Dead it would cancel the order,
// parsed as an error it would log below.
{
const std::string page = srv.Body(orderPath);
Check(page.find("awaiting payment") != std::string::npos,
"the fresh order page shows awaiting payment");
Check(page.find("Resume payment") != std::string::npos,
"the order page offers the resume link");
Check(page.find("mollie.com") != std::string::npos,
"the resume link points at the hosted checkout");
}
std::this_thread::sleep_for(std::chrono::seconds(12));
Check(srv.Body(orderPath).find("awaiting payment") != std::string::npos,
"an open payment is still awaiting after live reconciler polls");
{
// Any "mollie:" line is a failed call — auth, transport, or a JSON
// shape the parser refused. Create succeeded (the checkout URL above),
// so a clean log here means the polls succeeded too.
const std::string log = ReadFile(srv.Work() / "server.log");
Check(log.find("mollie:") == std::string::npos,
"no Mollie call failed during create or polling",
FirstMatch(log, R"((mollie:[^\n]*))"));
}
return Finish();
}

View file

@ -65,6 +65,27 @@ int main() {
"eurc: numeric result rejected — the wire type is a hex string");
Check(!ParseEthCallUint("garbage").has_value(), "eurc: malformed payload");
// The OUTBOUND half of the balance check, byte for byte: selector, then
// the address left-padded into exactly one 32-byte ABI word — 24 zero hex
// digits (12 bytes), then the 40 address digits. Pinned as a literal
// because this line once padded 24 BYTES instead of 12: the address slid
// past the argument word, every node cleanly answered balanceOf of a
// zero-balance garbage address, and paid orders read as unpaid forever —
// no error on either side. A wrong length here is money-losing even when
// every reply parses.
{
const std::string data = Server::BalanceOfCallData(
"0x311660cfd1d0c35616cf6dfc3932881b22bb46ec");
Check(data ==
"0x70a08231"
"000000000000000000000000"
"311660cfd1d0c35616cf6dfc3932881b22bb46ec",
"eurc: balanceOf calldata is selector + one 32-byte word", data);
Check(data.size() == 2 + 8 + 64,
"eurc: balanceOf calldata is exactly 4 + 32 bytes",
std::to_string(data.size()));
}
const auto chains = Server::ParseEurcChains(R"({"chains":[
{"name":"base","rpc":"https://mainnet.base.org",
"contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42",

View file

@ -0,0 +1,282 @@
/*
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 against a REAL chain: a €1 donation paid in real (testnet)
// EURC on Ethereum Sepolia, settled by the same balanceOf quorum the mainnet
// rail runs. The fake-rail suites prove the order lifecycle; the unit suites prove
// the decoding; what neither can prove is that the rail's actual RPC
// conversation — eth_chainId verification, the ABI-encoded eth_call, two
// endpoints corroborating a covering balance — works against nodes we do not
// control. A mistake there costs real money on mainnet, which is why this
// suite exists and why it is a MANDATORY deploy gate in CI, network flake and
// all: a deploy that cannot prove the crypto rail settles is not a deploy
// (the operator's call — caution over convenience).
//
// Locally it skips unless EURC_E2E_PRIVATE_KEY is exported, so a train ride
// still runs the rest of the suites. In CI the same missing key is a FAILURE:
// a vanished secret must never quietly turn the gate into a pass.
//
// What it needs (see deploy/README.md "Live payment suites in CI"):
// EURC_E2E_PRIVATE_KEY an Ethereum Sepolia key holding testnet EURC
// (Circle's faucet, network set to Ethereum Sepolia)
// and some Sepolia ETH for gas (the pk910 PoW faucet
// mines it, no account). Each run spends 1 EURC + gas.
// cast Foundry's CLI, the only tool in a stock shell that
// can sign an ERC-20 transfer. The server itself
// deliberately cannot — that is the rail's design.
//
// The receiving pool is generated FRESH here, random addresses nobody holds a
// key for. That is load-bearing, not laziness: CheckPaid compares the
// address's TOTAL balance, so a reused address still holding last run's
// 1 EURC would settle this run's order before any payment — a false pass.
// The 1 EURC sent each run is stranded at the random address, which on a
// testnet costs nothing.
//
// block_tag is "latest" rather than production's "finalized": Sepolia-family
// finality is ~13 minutes, and a reorg un-paying a testnet donation is not a
// risk worth a quarter-hour CI stall. The rail warns about it at load; that
// warning appearing in this suite's server log is expected.
import std;
import Crafter.Network;
import Catcrafts.E2eHarness;
using namespace Catcrafts::E2e;
namespace fs = std::filesystem;
namespace {
// Circle's EURC on Ethereum Sepolia. Verify only against
// https://developers.circle.com/stablecoins/eurc-contract-addresses
constexpr std::string_view kContract = "0x08210F9170F89Ab7658F0B5E3fF39b0E03C594D4";
constexpr std::string_view kChainId = "11155111";
// Two endpoints from different operators (Allnodes, 1RPC) — the same two
// operators the production ethereum entry uses — because the rule under test
// is "no single node's word settles an order": min_confirmations=2 below
// makes both agree, same as the mainnet chains file.
constexpr std::string_view kRpcPrimary = "https://ethereum-sepolia-rpc.publicnode.com";
constexpr std::string_view kRpcSecondary = "https://1rpc.io/sepolia";
// €1 donation = 100 cents = 100 × 10^(6-2) EURC base units.
constexpr std::string_view kTransferUnits = "1000000";
bool IsCi() {
for (const char* v : { "CI", "GITHUB_ACTIONS", "FORGEJO_ACTIONS" }) {
if (const char* s = std::getenv(v); s && *s) return true;
}
return false;
}
// The order token from a checkout redirect's Location header — the EURC rail
// has no hosted page, so the 303 lands back on the order page itself.
std::string TokenOf(const Crafter::HTTPResponse& r) {
const auto it = r.headers.find("location");
if (it == r.headers.end()) return {};
std::smatch m;
if (std::regex_search(it->second, m, std::regex(R"(/order/([0-9a-f]{32})$)"))) {
return m[1].str();
}
return {};
}
std::string RandomAddress(std::mt19937_64& rng) {
static constexpr char hex[] = "0123456789abcdef";
std::string s = "0x";
for (int i = 0; i < 40; ++i) s += hex[rng() & 0xf];
return s;
}
std::string Trimmed(std::string s) {
while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ')) {
s.pop_back();
}
return s;
}
} // namespace
int main(int argc, char** argv) {
if (argc < 2) {
std::println(std::cerr, "usage: ShouldSettleEurcOnTestnet <server-binary>");
return 2;
}
const char* senderKey = std::getenv("EURC_E2E_PRIVATE_KEY");
if (!senderKey || !*senderKey) {
if (IsCi()) {
std::println(std::cerr,
"FAIL: EURC_E2E_PRIVATE_KEY is not set. This suite is a mandatory "
"deploy gate: add the secret in Forgejo (repo Settings -> Actions -> "
"Secrets) — an Ethereum Sepolia key funded with testnet EURC and gas. "
"See deploy/README.md \"Live payment suites in CI\".");
return 1;
}
std::println("ShouldSettleEurcOnTestnet: skipped — EURC_E2E_PRIVATE_KEY is not "
"set (mandatory in CI, opt-in locally)");
return 0;
}
// The key never enters a command line this process builds: cast reads it
// from the environment via shell expansion, so it cannot leak through a
// logged command or the failure output below.
if (std::system("command -v cast >/dev/null 2>&1") != 0) {
std::println(std::cerr,
"FAIL: EURC_E2E_PRIVATE_KEY is set but `cast` (Foundry) is not on PATH — "
"the suite cannot sign the testnet transfer without it. CI installs it in "
"the deploy workflow; locally: https://getfoundry.sh");
return 1;
}
// The suite's own scratch dir: the chains file and pool must exist before
// the server spawns, so they cannot live in TestServer's work dir.
std::random_device rd;
std::mt19937_64 rng((static_cast<std::uint64_t>(rd()) << 32) ^ rd());
const fs::path dir = fs::temp_directory_path()
/ std::format("catcrafts-eurc-testnet-{:016x}", rng());
std::error_code ec;
fs::remove_all(dir, ec);
fs::create_directories(dir);
const fs::path chains = dir / "chains.json";
WriteFile(chains, std::format(
R"({{"chains":[{{"name":"ethereum-sepolia",)"
R"("rpcs":["{}","{}"],)"
R"("min_confirmations":2,)"
R"("contract":"{}","chain_id":{},)"
R"("block_tag":"latest","note":"testnet"}}]}})",
kRpcPrimary, kRpcSecondary, kContract, kChainId));
// Fresh random addresses — see the header for why reuse would false-pass.
const fs::path pool = dir / "pool.txt";
{
std::string lines;
for (int i = 0; i < 3; ++i) lines += RandomAddress(rng) + "\n";
WriteFile(pool, lines);
}
ServerOptions options;
// Bank slot on the fake rail so the donation form offers the choice; the
// crypto slot is the real EURC rail pointed at the testnet chains file.
options.extraArgs = { "--rail=fake", "--crypto-rail=eurc" };
options.env = { { "EURC_CHAINS", chains.string() },
{ "EURC_POOL", pool.string() } };
TestServer srv(argv[1], 8218, options);
// ── checkout: a €1 crypto donation ────────────────────────────────
const auto created = srv.Post("/shop/donation", "amount=1&pay=crypto");
const std::string token = TokenOf(created);
Check(created.status == "303" && !token.empty(),
"a €1 crypto donation 303s to its order page", created.status);
if (token.empty()) {
std::println(std::cerr, "server log:\n{}",
ReadFile(srv.Work() / "server.log"));
fs::remove_all(dir, ec);
return Finish();
}
const std::string orderPath = std::format("/order/{}", token);
// The issued address, from the ledger — the same place the reconciler
// reads it, so this is the address the rail is actually watching.
std::string address;
{
const std::string ledger = srv.OrdersText();
std::smatch m;
if (std::regex_search(ledger, m, std::regex(
R"lit("id":")lit" + token
+ R"lit(".*?"pay_choice":"crypto".*?"pay_id":"(0x[0-9a-f]{40})@[0-9]+")lit"))) {
address = m[1].str();
}
Check(!address.empty(),
"the ledger records a crypto order with an address@deadline pay id");
}
if (address.empty()) {
std::println(std::cerr, "ledger was:\n{}\nserver log:\n{}",
srv.OrdersText(), ReadFile(srv.Work() / "server.log"));
fs::remove_all(dir, ec);
return Finish();
}
std::println("issued receiving address: {}", address);
// ── the order page asks for exactly the right payment ────────────
{
const std::string page = srv.Body(orderPath);
Check(page.find("Pay with EURC") != std::string::npos,
"the order page renders the self-hosted payment instructions");
Check(page.find(address) != std::string::npos,
"the order page shows the issued address");
// The EIP-681 wallet link carries the amount in base units — the
// number a wrong scale would corrupt 10,000× in either direction.
Check(page.find(std::format("uint256={}", kTransferUnits)) != std::string::npos,
"the wallet link asks for €1 in EURC base units");
Check(page.find("ethereum-sepolia") != std::string::npos
|| page.find("Ethereum-sepolia") != std::string::npos,
"the order page names the watched chain");
}
// ── negative control: an unpaid order must not settle ────────────
// The reconciler has polled the real RPCs at least once by now (first
// sweep lands ~1 s after the order); a zero balance must read as Pending.
std::this_thread::sleep_for(std::chrono::seconds(3));
Check(srv.Body(orderPath).find("awaiting payment") != std::string::npos,
"an unpaid order stays awaiting after a real RPC poll");
// ── the customer pays: 1 real EURC on Ethereum Sepolia ────────────
{
const fs::path who = dir / "sender.txt";
std::system(std::format(
"cast wallet address --private-key \"$EURC_E2E_PRIVATE_KEY\" > \"{}\" 2>&1",
who.string()).c_str());
std::println("paying from: {}", Trimmed(ReadFile(who)));
}
const fs::path sendLog = dir / "cast-send.log";
const int sent = std::system(std::format(
"cast send {} \"transfer(address,uint256)\" {} {} "
"--rpc-url {} --private-key \"$EURC_E2E_PRIVATE_KEY\" > \"{}\" 2>&1",
kContract, address, kTransferUnits, kRpcPrimary, sendLog.string()).c_str());
Check(sent == 0, "cast send transfers 1 testnet EURC and gets a receipt");
if (sent != 0) {
std::println(std::cerr,
"cast send failed — if it reports insufficient funds, top the sender "
"up: EURC at https://faucet.circle.com (network: Ethereum Sepolia), "
"gas ETH at https://sepolia-faucet.pk910.de. cast said:\n{}",
ReadFile(sendLog));
fs::remove_all(dir, ec);
return Finish();
}
std::println("transfer confirmed on-chain; waiting for the rail to notice");
// ── the rail notices, both endpoints agreeing ─────────────────────
// Cadence: the reconciler polls this order every 30 s (the rail's own
// interval), and both endpoints must see the balance at "latest". 150 s
// is five polls past the ~12 s block that included the transfer — roomy,
// tight enough to fail a deploy that would leave real buyers unconfirmed.
{
const std::string page = srv.WaitForBody(orderPath, "Thank you", 600);
Check(page.find("Thank you") != std::string::npos,
"the paid donation page appears once the balance covers the order");
Check(page.find("Pay with EURC") == std::string::npos,
"a settled order stops asking for money");
}
{
const std::string ledger = srv.OrdersText();
Check(ledger.find("\"status\":\"paid\"") != std::string::npos,
"the paid transition reached the ledger");
// The via column must name the chain that settled it — the fact the
// bookkeeping keeps about where the money lives.
Check(ledger.find("\"via\":\"eurc-ethereum-sepolia\"") != std::string::npos,
"the paid event records eurc-ethereum-sepolia as the method");
Check(ledger.find("\"type\":\"invoice\"") == std::string::npos,
"a donation settles without an invoice");
}
if (failures != 0) {
std::println(std::cerr, "server log:\n{}",
ReadFile(srv.Work() / "server.log"));
}
fs::remove_all(dir, ec);
return Finish();
}

View file

@ -22,6 +22,7 @@ module;
#include <fcntl.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/prctl.h>
#include <sys/wait.h>
#include <unistd.h>
export module Catcrafts.E2eHarness;
@ -187,6 +188,24 @@ public:
std::format("--orders={}", orders_.string()),
};
for (const std::string& a : options.extraArgs) argv.push_back(a);
// A stale server from an aborted run squatting on this port would
// answer the readiness probe while OUR child dies on a failed bind —
// every request then hits the wrong server and the suite fails on
// assertions that cannot make sense (an order that "vanishes" from
// the ledger). The pdeathsig in Spawn stops the leak from happening
// again; this stops anything already leaked (or anything else on the
// port) from being mistaken for the server under test.
try {
(void)Get("/api/healthz");
std::println(std::cerr,
"e2e: something is already listening on port {} — a stale "
"catcrafts-server from an aborted run? Find it with "
"`pgrep -af catcrafts-server`, kill it, and re-run.",
port_);
std::exit(1);
} catch (...) {
// Nothing answered: the port is ours to take.
}
Spawn(argv);
WaitUntilUp();
}
@ -297,6 +316,12 @@ private:
pid_ = ::fork();
if (pid_ == 0) {
// Die WITH the suite. A suite killed hard — Ctrl+C, a runner
// timeout, a crash that skips destructors — must not leave this
// child alive holding the port: the leaked server answers the
// next run's probes and every assertion after that lies.
::prctl(PR_SET_PDEATHSIG, SIGKILL);
if (::getppid() == 1) ::_exit(127); // parent died before prctl took
const int fd = ::open(log_.c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0644);
if (fd >= 0) {
::dup2(fd, 1);