From 1a38cfe5d7dc9d6b3e5e49d458b98a1a8fbd3c35 Mon Sep 17 00:00:00 2001
From: Jorijn van der Graaf
Date: Thu, 20 Aug 2026 02:50:28 +0200
Subject: [PATCH] crypto payment message
---
.../implementations/Catcrafts.Server-Eurc.cpp | 30 +++++++++++--
.../implementations/Catcrafts.Server-Http.cpp | 33 ++++++++++++++
.../Catcrafts.Server-Mollie.cpp | 39 ++++++++++++++--
server/interfaces/Catcrafts.Server.cppm | 6 +++
shared/interfaces/Catcrafts.Shared-Model.cppm | 5 +++
shared/interfaces/Catcrafts.Shared-Views.cppm | 28 +++++++++---
tests/ShouldProcessCheckout/main.cpp | 45 ++++++++++++++++---
7 files changed, 169 insertions(+), 17 deletions(-)
diff --git a/server/implementations/Catcrafts.Server-Eurc.cpp b/server/implementations/Catcrafts.Server-Eurc.cpp
index 7866510..e71137c 100644
--- a/server/implementations/Catcrafts.Server-Eurc.cpp
+++ b/server/implementations/Catcrafts.Server-Eurc.cpp
@@ -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 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 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 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 res = Call(chain, url, body);
if (!res) return std::nullopt;
diff --git a/server/implementations/Catcrafts.Server-Http.cpp b/server/implementations/Catcrafts.Server-Http.cpp
index 8177a10..ab6d91a 100644
--- a/server/implementations/Catcrafts.Server-Http.cpp
+++ b/server/implementations/Catcrafts.Server-Http.cpp
@@ -115,6 +115,7 @@ struct AdvanceResult {
};
std::optional 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::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 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 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 PollAndAdvance(const OrderRecord& order) {
if (!rail) return std::nullopt;
const std::optional 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 —
diff --git a/server/implementations/Catcrafts.Server-Mollie.cpp b/server/implementations/Catcrafts.Server-Mollie.cpp
index 198b9ad..a016db2 100644
--- a/server/implementations/Catcrafts.Server-Mollie.cpp
+++ b/server/implementations/Catcrafts.Server-Mollie.cpp
@@ -155,9 +155,42 @@ public:
std::optional 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";
+ // ".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 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::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_; }
diff --git a/server/interfaces/Catcrafts.Server.cppm b/server/interfaces/Catcrafts.Server.cppm
index ded7fb6..66dd206 100644
--- a/server/interfaces/Catcrafts.Server.cppm
+++ b/server/interfaces/Catcrafts.Server.cppm
@@ -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
diff --git a/shared/interfaces/Catcrafts.Shared-Model.cppm b/shared/interfaces/Catcrafts.Shared-Model.cppm
index 3f8af79..c49ad29 100644
--- a/shared/interfaces/Catcrafts.Shared-Model.cppm
+++ b/shared/interfaces/Catcrafts.Shared-Model.cppm
@@ -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
diff --git a/shared/interfaces/Catcrafts.Shared-Views.cppm b/shared/interfaces/Catcrafts.Shared-Views.cppm
index 8e5e6b5..a1ce16f 100644
--- a/shared/interfaces/Catcrafts.Shared-Views.cppm
+++ b/shared/interfaces/Catcrafts.Shared-Views.cppm
@@ -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"(This address is reserved for this order )"
R"(for about {} more {}. This page checks automatically and )"
- R"(confirms once the full amount has arrived.
)",
+ 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.
)",
Num(pay.minutesLeft >= 120 ? pay.minutesLeft / 60 : pay.minutesLeft),
pay.minutesLeft >= 120 ? Raw("hours") : Raw("minutes"))
: Raw(R"(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"(info@catcrafts.net )"
R"(and it will be settled by hand.
)");
+ // 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"(Your payment is on its way. )"
+ 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.
)")
+ : SafeHtml{};
+
SafeHtml indicativeLine = indicative.empty() ? SafeHtml{} : Format(
R"({}, indicative only. The charge is )"
R"(the euro amount above.
)",
@@ -1460,19 +1476,21 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
payBlock = Format(
R"()"
R"(Pay with EURC
)"
- R"(Send {} EURC to this address one )"
+ R"({})"
+ R"(
Send {} EURC to this address. One )"
R"(network, one payment:
)"
R"({}
)"
R"()"
R"({})"
R"(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 {}.
)"
R"({})"
R"()",
+ seenLine,
Escape(pay.amount),
Escape(pay.address),
Join(items),
diff --git a/tests/ShouldProcessCheckout/main.cpp b/tests/ShouldProcessCheckout/main.cpp
index f463f4d..8dcadaa 100644
--- a/tests/ShouldProcessCheckout/main.cpp
+++ b/tests/ShouldProcessCheckout/main.cpp
@@ -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 ".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");