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

@ -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();
}