All checks were successful
Deploy / build-deploy (push) Successful in 3m40s
286 lines
13 KiB
C++
286 lines
13 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 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";
|
||
// Three endpoints from different operators (Allnodes, Tenderly, 1RPC) with
|
||
// min_confirmations=2, because the rule under test is "no single node's word
|
||
// settles an order" — and because public RPCs differ by VANTAGE POINT, not
|
||
// just uptime: 1RPC answers residential IPs and 403s the Hetzner runner,
|
||
// which stalled the first CI run at 1-of-2 forever. Any two of three settle;
|
||
// the corroboration loop stops at quorum, so the third is only ever dialed
|
||
// when one of the first two fails.
|
||
constexpr std::string_view kRpcPrimary = "https://ethereum-sepolia-rpc.publicnode.com";
|
||
constexpr std::string_view kRpcSecondary = "https://sepolia.gateway.tenderly.co";
|
||
constexpr std::string_view kRpcTertiary = "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, kRpcTertiary, 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();
|
||
}
|