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