crypto payment message
All checks were successful
Deploy / build-deploy (push) Successful in 3m13s

This commit is contained in:
Jorijn van der Graaf 2026-08-20 02:50:28 +02:00
commit 1a38cfe5d7
7 changed files with 169 additions and 17 deletions

View file

@ -630,6 +630,13 @@ public:
const bool decisive = now >= deadline;
bool anyUnknown = false;
// Money visible at "latest" while the settlement tag still reads
// short. Display-only, and deliberately held to a LOWER standard
// than settling: one endpoint's unfinalized word is plenty for
// "we see it, hold on" and worthless for "paid". The probe is
// skipped when the chain already settles on "latest" (dev, the
// testnet suite): there is no gap between seen and paid to report.
bool seenInFlight = false;
for (const EurcChain& chain : chains_) {
const std::optional<std::int64_t> required = RequiredUnits(chain, expectedMinor);
if (!required) {
@ -654,6 +661,14 @@ public:
anyUnknown = true;
break;
case ChainVerdict::NotCovered:
// Not settled at the settlement tag — but is it in flight?
// One extra call on the first endpoint, only while the order
// is live and only until something is spotted.
if (!decisive && !seenInFlight && chain.blockTag != "latest") {
const std::optional<std::int64_t> unfinalized =
BalanceOf(chain, chain.rpcUrls.front(), address, "latest");
if (unfinalized && *unfinalized >= *required) seenInFlight = true;
}
break;
}
}
@ -686,7 +701,10 @@ public:
"arrive there and needs settling by hand", address);
return PaidStatus{ PayState::Dead, {} };
}
return PaidStatus{ PayState::Pending, {} };
PaidStatus pending;
pending.state = PayState::Pending;
pending.seen = seenInFlight;
return pending;
}
// What the order page renders in place of a hosted-checkout button. Reads
@ -823,18 +841,24 @@ private:
return ChainVerdict::NotCovered;
}
// tagOverride replaces the chain's settlement tag for this one call; the
// in-flight probe uses it to peek at "latest". Empty means the settlement
// tag, which every settling caller passes implicitly.
std::optional<std::int64_t> BalanceOf(const EurcChain& chain,
const std::string& url,
const std::string& address) {
const std::string& address,
std::string_view tagOverride = {}) {
// 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 calldata encoding lives in
// BalanceOfCallData, where the self-test can pin it.
const std::string data = BalanceOfCallData(address);
const std::string_view tag =
tagOverride.empty() ? std::string_view(chain.blockTag) : tagOverride;
const std::string body =
std::string(R"({"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":")")
+ chain.contract + R"(","data":")" + data + R"("},")" + chain.blockTag + R"("]})";
+ chain.contract + R"(","data":")" + data + R"("},")" + std::string(tag) + R"("]})";
const std::optional<std::string> res = Call(chain, url, body);
if (!res) return std::nullopt;

View file

@ -115,6 +115,7 @@ struct AdvanceResult {
};
std::optional<AdvanceResult> PollAndAdvance(const OrderRecord& order);
bool ArrivalPollAllowed(std::string_view token, std::chrono::seconds interval);
bool MoneySeenRecently(std::string_view token);
std::string NowIso8601();
// Common headers on every HTML response.
@ -317,6 +318,7 @@ HTTPResponse RenderPage(std::string_view target) {
std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
pay.minutesLeft = (instr->deadlineUnix - now) / 60;
pay.seen = MoneySeenRecently(order->token);
for (auto& c : instr->chains) {
pay.chains.push_back({ std::move(c.name), std::move(c.contract),
std::move(c.link), std::move(c.note) });
@ -894,6 +896,34 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
std::mutex gArrivalPollMutex;
std::unordered_map<std::string, std::chrono::steady_clock::time_point> gLastArrivalPoll;
// Orders whose payment the rail has SEEN in flight (visible on the network,
// finality pending), stamped by whichever poll noticed — the reconciler or an
// arrival check. Read at render time so the page can acknowledge the money
// without the render path ever dialing a provider itself: the page shows the
// most recent poll's knowledge, which is exactly as fresh as "paid" would be.
// Entries only ever accumulate truth ("seen" is never un-seen by one flaky
// probe) and age out; a settled or lapsed order stops rendering the section
// that reads this, so stale entries are harmless.
std::unordered_map<std::string, std::chrono::steady_clock::time_point> gMoneySeen;
void RememberMoneySeen(std::string_view token) {
const auto now = std::chrono::steady_clock::now();
std::lock_guard lock(gArrivalPollMutex);
std::erase_if(gMoneySeen, [&](const auto& entry) {
return now - entry.second > std::chrono::hours(1);
});
gMoneySeen[std::string(token)] = now;
}
bool MoneySeenRecently(std::string_view token) {
const auto now = std::chrono::steady_clock::now();
std::lock_guard lock(gArrivalPollMutex);
const auto it = gMoneySeen.find(std::string(token));
// Ten minutes covers several finality epochs; a payment seen longer ago
// that still has not settled is a claim this page should stop making.
return it != gMoneySeen.end() && now - it->second < std::chrono::minutes(10);
}
bool ArrivalPollAllowed(std::string_view token, std::chrono::seconds interval) {
const auto now = std::chrono::steady_clock::now();
std::lock_guard lock(gArrivalPollMutex);
@ -924,6 +954,9 @@ std::optional<AdvanceResult> PollAndAdvance(const OrderRecord& order) {
if (!rail) return std::nullopt;
const std::optional<PaidStatus> paid = rail->CheckPaid(order.payId, order.totalMinor);
if (!paid.has_value()) return std::nullopt;
if (paid->state == PayState::Pending && paid->seen) {
RememberMoneySeen(order.token);
}
if (paid->state == PayState::Paid) {
if (AppendOrderStatus(order.token, "paid", NowIso8601(), paid->method)) {
// The invoice number exists from the moment the money does —

View file

@ -155,9 +155,42 @@ public:
std::optional<PaidStatus> CheckPaid(const std::string&, std::int64_t) override {
std::error_code ec;
return PaidStatus{
std::filesystem::exists(marker_, ec) ? PayState::Paid : PayState::Pending,
"fake" };
if (std::filesystem::exists(marker_, ec)) {
return PaidStatus{ PayState::Paid, "fake" };
}
PaidStatus out;
out.state = PayState::Pending;
out.method = "fake";
// "<marker>.seen" is the in-flight state: money visible on the
// network, finality still pending. It exists so the e2e suite can
// drive the order page's "your payment is on its way" notice the
// same way the marker itself drives "paid".
std::filesystem::path seenMarker = marker_;
seenMarker += ".seen";
out.seen = std::filesystem::exists(seenMarker, ec);
return out;
}
// The crypto slot's fake renders payment INSTRUCTIONS, like the real
// EURC rail, so the suites exercise the order page's self-hosted branch
// (address, window, the in-flight notice) rather than the hosted button
// that slot never shows in production. The bank fake keeps the button,
// mirroring Mollie. Fixed values, so assertions can pin them.
std::optional<PayInstructions> Instructions(const std::string&,
std::int64_t totalMinor) const override {
if (name_ != "fake-crypto" || totalMinor <= 0) return std::nullopt;
PayInstructions out;
out.address = "0x" + std::string(40, 'f');
out.amount = Money::FormatMinor(totalMinor);
out.deadlineUnix =
std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count()
+ 24 * 3600;
PayChainOption chain;
chain.name = "fake-chain";
chain.contract = "0x" + std::string(40, 'f');
out.chains.push_back(std::move(chain));
return out;
}
std::string_view Name() const override { return name_; }

View file

@ -261,6 +261,12 @@ export namespace Catcrafts::Server {
struct PaidStatus {
PayState state = PayState::Pending;
std::string method; // "ideal" | "creditcard" | "bitcoin" | …
// Pending only: the money is visible but not yet trusted — for the
// EURC rail, a covering balance at "latest" while the settlement tag
// still reads short. NEVER a settlement input; it exists so the order
// page can tell a buyer their in-flight payment has been noticed
// during the ~15 minutes finality takes.
bool seen = false;
};
// Self-hosted payment instructions for the order page. A hosted rail sends

View file

@ -365,6 +365,11 @@ export struct OrderCryptoPay {
std::string amount; // decimal EURC amount ("570.43") — equals the
// euro total; EURC is euro-denominated at par
std::int64_t minutesLeft = 0; // until the window closes; <= 0 = closed
// The transfer is visible on the network but not yet in a finalized
// block. Display-only: the buyer whose wallet said "success" needs to
// hear "we see it, it is finalizing" or their next step is a support
// email — the first live payment proved exactly this.
bool seen = false;
struct Chain {
std::string name; // "base"
std::string contract; // token contract, shown so the buyer can verify

View file

@ -1438,20 +1438,36 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
// The window line tells the truth for both signs of minutesLeft. A
// closed window does NOT mean sent money is gone — the address stays
// ours — so the copy says where it went instead of leaving a buyer
// staring at money that "vanished".
// staring at money that "vanished". The open branch also owns the
// expectation the first live payment proved buyers need: a wallet
// says "success" within seconds, this shop only believes finalized
// blocks, and a buyer left to discover that ~15-minute gap alone
// discovers the support address instead.
const SafeHtml windowLine = pay.minutesLeft > 0
? Format(
R"(<p class="order__note">This address is reserved for this order )"
R"(for about {} more {}. This page checks automatically and )"
R"(confirms once the full amount has arrived.</p>)",
R"(confirms once the full amount has arrived and the network has )"
R"(finalized it: your wallet will report success well before then, )"
R"(and confirmation here typically follows in 10 to 25 minutes.</p>)",
Num(pay.minutesLeft >= 120 ? pay.minutesLeft / 60 : pay.minutesLeft),
pay.minutesLeft >= 120 ? Raw("hours") : Raw("minutes"))
: Raw(R"(<p class="order__note">The payment window for this order has )"
R"(closed and the order will lapse. If you already sent EURC it )"
R"(is not lost it arrived at the address above; contact )"
R"(is not lost: it arrived at the address above; contact )"
R"(<a href="mailto:info@catcrafts.net">info@catcrafts.net</a> )"
R"(and it will be settled by hand.</p>)");
// The in-flight acknowledgement: the rail has SEEN the full amount on
// the network, finality is the only thing missing. Rendered first,
// because a buyer who just paid scans for exactly this sentence.
const SafeHtml seenLine = pay.seen && pay.minutesLeft > 0
? Raw(R"(<p class="order__note"><strong>Your payment is on its way.</strong> )"
R"(It is visible on the network and is being finalized: this )"
R"(usually takes 10 to 25 minutes, and this page will confirm )"
R"(it automatically.</p>)")
: SafeHtml{};
SafeHtml indicativeLine = indicative.empty() ? SafeHtml{} : Format(
R"(<p class="order__indicative">{}, indicative only. The charge is )"
R"(the euro amount above.</p>)",
@ -1460,19 +1476,21 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
payBlock = Format(
R"(<section class="section">)"
R"(<h2 class="section__title">Pay with EURC</h2>)"
R"(<p>Send <strong>{} EURC</strong> to this address one )"
R"({})"
R"(<p>Send <strong>{} EURC</strong> to this address. One )"
R"(network, one payment:</p>)"
R"(<p><code class="order__address">{}</code></p>)"
R"(<ul class="order__chains">{}</ul>)"
R"({})"
R"(<p class="order__note">EURC is pegged to the euro, so the amount )"
R"(is exactly the euro total no exchange rate. Send EURC only, )"
R"(is exactly the euro total, no exchange rate. Send EURC only, )"
R"(and only on a network listed above. If your exchange deducts a )"
R"(withdrawal fee, send the missing rest to the same address; the )"
R"(order confirms once the full amount sits on one network. Your )"
R"(order reference is <strong>{}</strong>.</p>)"
R"({})"
R"(</section>)",
seenLine,
Escape(pay.amount),
Escape(pay.address),
Join(items),

View file

@ -143,14 +143,16 @@ void OpenShopLifecycle(TestServer& srv) {
}
}
Check(recorded, "the crypto choice is what the ledger records");
// The order page has to promise what is actually behind the button
// The order page has to promise what is actually behind the choice
// the bank copy on a crypto order would send someone looking for
// iDEAL. (The shell suite pinned 'Lightning' here, CoinGate-era copy
// that had already left the codebase — this is the check that caught
// the drift when the port first ran against an open shop.)
// iDEAL. The fake crypto slot renders instructions like the real
// EURC rail, so the probe is the instructions block. (The shell
// suite pinned 'Lightning' here, CoinGate-era copy that had already
// left the codebase — this is the check that caught the drift when
// the port first ran against an open shop.)
const std::string page = srv.Body(std::format("/order/{}", tokenCrypto));
Check(page.find("completes your crypto payment") != std::string::npos,
"the crypto order page describes the crypto payment");
Check(page.find("Pay with EURC") != std::string::npos,
"the crypto order page renders the payment instructions");
Check(page.find("iDEAL") == std::string::npos,
"the crypto order page does not promise iDEAL");
}
@ -546,6 +548,37 @@ void DonationLifecycle(TestServer& srv) {
"the amount refusal names the bounds");
}
// ── the in-flight state ───────────────────────────────────────────
// Between the wallet's "success" and finality the rail reports the money
// as SEEN but not yet evidence, and the page must say so — the first
// live EURC payment proved that a buyer staring at "awaiting payment"
// after their wallet said done is a support email in the making. The
// fake rail's "<marker>.seen" file is that state's test handle.
{
const std::string tokenSeen =
TokenOf(srv.Post("/shop/donation", "amount=2&pay=crypto"));
Check(!tokenSeen.empty(), "a crypto donation goes through");
if (!tokenSeen.empty()) {
const std::string seenPath = std::format("/order/{}", tokenSeen);
// Before anything is seen: the awaiting page owns the timing
// expectation, so the gap between wallet and shop is explained
// even to a buyer who never reloads.
srv.BodyHas(seenPath, "typically follows in 10 to 25 minutes",
"the awaiting page states the confirmation timing");
WriteFile(std::filesystem::path(
srv.Orders().string() + ".fake-paid.seen"), "");
const std::string page =
srv.WaitForBody(seenPath, "Your payment is on its way");
Check(page.find("Your payment is on its way") != std::string::npos,
"an in-flight payment is acknowledged on the order page");
Check(page.find("awaiting payment") != std::string::npos,
"an in-flight payment is still awaiting, not paid");
std::error_code ec;
std::filesystem::remove(std::filesystem::path(
srv.Orders().string() + ".fake-paid.seen"), ec);
}
}
// ── a donation with no email at all ───────────────────────────────
// Identity is optional: the capability URL is the receipt.
const auto created = srv.Post("/shop/donation", "amount=25");