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");