catcrafts.net/tests/ShouldSettleEurcOnTestnet/main.cpp
Jorijn van der Graaf 45992c4f91
Some checks failed
Deploy / build-deploy (push) Failing after 4m58s
payment tests
2026-08-20 01:36:42 +02:00

282 lines
13 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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