diff --git a/.github/workflows/handbook-build-check.yaml b/.github/workflows/handbook-build-check.yaml
index 89c0547d7..31d752a01 100644
--- a/.github/workflows/handbook-build-check.yaml
+++ b/.github/workflows/handbook-build-check.yaml
@@ -96,8 +96,8 @@ jobs:
set -euo pipefail
bash scripts/assemble-handbook-screenshots.sh /tmp/handbook-shots
count=$(ls -1 /tmp/handbook-shots/*.png | wc -l | tr -d ' ')
- if [ "$count" != "301" ]; then
- echo "expected 301 screenshots, got $count" >&2
+ if [ "$count" != "304" ]; then
+ echo "expected 304 screenshots, got $count" >&2
exit 1
fi
@@ -177,10 +177,11 @@ jobs:
exit 1
fi
- # Spot-check handbook screenshots assembled from Goldens (301 PNGs).
- # Mix of original 01-61 slots, later Golden batches, and referral
- # baselines so a regression in any range surfaces here.
- for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default 269-dashboard-insider-unlocked 270-handbook-persona-dca 275-handbook-persona-exit-1j 276-referral-overview 282-referral-payout-row 283-referral-terms-readonly 287-kyc-registration-referral-recognized 288-transaction-history-referral-payout 289-dashboard-referral-entry-and-payout 290-kyc-registration-referral-promo 291-referral-bind-error-invalid 292-referral-bind-error-already-registered; do
+ # Spot-check handbook screenshots assembled from Goldens (304 PNGs).
+ # Mix of original 01-61 slots, later Golden batches, referral
+ # baselines, bind-error overlays, and pay whole-share rounding so a
+ # regression in any range surfaces here.
+ for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default 269-dashboard-insider-unlocked 270-handbook-persona-dca 275-handbook-persona-exit-1j 276-referral-overview 282-referral-payout-row 283-referral-terms-readonly 287-kyc-registration-referral-recognized 288-transaction-history-referral-payout 289-dashboard-referral-entry-and-payout 290-kyc-registration-referral-promo 291-referral-bind-error-invalid 292-referral-bind-error-already-registered 293-pay-info 294-pay-quote-ready 295-pay-quote-ready-merchant; do
# Auth is checked before the file exists: a missing PNG also
# returns 401, so HTTP status cannot prove assembly. Look on disk.
if ! docker exec handbook test -f "/usr/share/nginx/html/screenshots/${name}.png"; then
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 58765c8ca..9d3fd5699 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -248,7 +248,7 @@ The app supports three wallet modes (`software`, `bitbox`, `debug`) with differe
```bash
rg "^//\s*@no-integration-test:" lib/
```
-- Visual-regression Goldens under `test/goldens/screens/` and `test/goldens/widgets/` are also the source of the 301 screenshots served at `handbook.realunit.app`. When you add a handbook page, you MUST add a matching Golden test AND a row in the mapping table at `scripts/assemble-handbook-screenshots.sh` — the handbook will not pick up a Maestro-captured PNG anymore. The `Handbook Build Check` workflow on every PR runs the assembly script and fails loudly if a mapped Golden is missing.
+- Visual-regression Goldens under `test/goldens/screens/` and `test/goldens/widgets/` are also the source of the 304 screenshots served at `handbook.realunit.app`. When you add a handbook page, you MUST add a matching Golden test AND a row in the mapping table at `scripts/assemble-handbook-screenshots.sh` — the handbook will not pick up a Maestro-captured PNG anymore. The `Handbook Build Check` workflow on every PR runs the assembly script and fails loudly if a mapped Golden is missing.
- Why: single source of truth — a UI regression that breaks a Golden also breaks the handbook image before either ships; eliminates the previous "two pipelines, two truths" problem.
- See: [`docs/visual-regression-tests.md`](docs/visual-regression-tests.md) section "Handbook screenshots are sourced from Goldens".
diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb
index 36da5d176..827b7130a 100644
--- a/assets/languages/strings_de.arb
+++ b/assets/languages/strings_de.arb
@@ -182,11 +182,10 @@
"payFailureBitboxRequired": "Bitte verbinden Sie Ihre BitBox, um fortzufahren.",
"payFailureGeneric": "Bei der Zahlung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"payFailureInsufficientEth": "Es konnten nicht genügend ETH für die Netzwerkgebühren bereitgestellt werden. Bitte versuchen Sie es später erneut.",
- "payFailureInsufficientZchf": "Ihr REALU-Bestand reicht für diesen Betrag nicht aus.",
"payFailureQuoteExpired": "Das Zahlungsangebot ist abgelaufen. Bitte scannen Sie den Code erneut.",
"payFailureSignatureUnsupported": "Diese Wallet kann keine Transaktionen signieren. Wechseln Sie zu einer Software- oder BitBox-Wallet.",
"payFailureTitle": "Zahlung fehlgeschlagen",
- "payInfoBody": "Die Bezahlfunktion kann an allen OpenCryptoPay-Standorten genutzt werden.\n\nDer Tausch erfolgt in zwei Schritten. Zuerst wird REALU in ZCHF getauscht. ZCHF ist ein CHF-Stablecoin, der anschliessend an den Zahlungsempfänger übertragen wird.\n\nREALU ist ein Wertpapier und wird nicht für die Bezahlung selbst verwendet.",
+ "payInfoBody": "Die Bezahlfunktion kann an allen OpenCryptoPay-Standorten genutzt werden.\n\nDer Tausch erfolgt in zwei Schritten. Zuerst wird REALU in ZCHF getauscht. ZCHF ist ein CHF-Stablecoin, der anschliessend an den Zahlungsempfänger übertragen wird.\n\nREALU ist ein Wertpapier und wird nicht für die Bezahlung selbst verwendet.\n\nBei jeder Zahlung wird auf ganze REALU-Anteile aufgerundet, weil es keine halben REALU gibt. Restliches ZCHF behält DFX.",
"payInfoTitle": "Bevor Sie bezahlen",
"paymentInformationFailed": "Beim Abrufen der Zahlungsinformationen ist ein Fehler aufgetreten.",
"paymentInformationFailedDescription": "Bitte versuchen Sie es später erneut. Wenn der Fehler weiterhin besteht, wenden Sie sich an unseren Support.",
@@ -199,6 +198,7 @@
"payQuoteRealuEstimated": "Erwarteter Erlös",
"payQuoteRealuFees": "Gebühren",
"payQuoteRequested": "Geforderter Betrag",
+ "payQuoteRoundingNotice": "Bei jeder Zahlung wird auf ganze REALU-Anteile aufgerundet, weil es keine halben REALU gibt. Restliches ZCHF behält DFX.",
"payQuoteSummary": "Sie bezahlen ${amount} ${asset}",
"payQuoteTitle": "Zahlung bestätigen",
"payQuoteUnavailable": "Für diesen Zahlungscode ist keine ZCHF-Zahlung verfügbar.",
diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb
index 4d5578e31..fe5738880 100644
--- a/assets/languages/strings_en.arb
+++ b/assets/languages/strings_en.arb
@@ -182,11 +182,10 @@
"payFailureBitboxRequired": "Please connect your BitBox to continue.",
"payFailureGeneric": "Something went wrong with the payment. Please try again.",
"payFailureInsufficientEth": "Could not provision enough ETH for network fees. Please try again later.",
- "payFailureInsufficientZchf": "Your REALU holdings are not enough for this amount.",
"payFailureQuoteExpired": "The payment quote expired. Please scan the code again.",
"payFailureSignatureUnsupported": "This wallet cannot sign transactions. Switch to a software or BitBox wallet.",
"payFailureTitle": "Payment failed",
- "payInfoBody": "You can use the payment function at all OpenCryptoPay locations.\n\nThe exchange happens in two steps. First, REALU is swapped for ZCHF. ZCHF is a Swiss franc stablecoin, which is then transferred to the payment recipient.\n\nREALU is a security and is not used for the payment itself.",
+ "payInfoBody": "You can use the payment function at all OpenCryptoPay locations.\n\nThe exchange happens in two steps. First, REALU is swapped for ZCHF. ZCHF is a Swiss franc stablecoin, which is then transferred to the payment recipient.\n\nREALU is a security and is not used for the payment itself.\n\nEvery payment is rounded up to whole REALU shares, because there are no fractional REALU. Leftover ZCHF is kept by DFX.",
"payInfoTitle": "Before you pay",
"paymentInformationFailed": "An error occurred while getting the payment information.",
"paymentInformationFailedDescription": "Please try again later. If the error persists, contact our support team.",
@@ -199,6 +198,7 @@
"payQuoteRealuEstimated": "Estimated proceeds",
"payQuoteRealuFees": "Fees",
"payQuoteRequested": "Requested amount",
+ "payQuoteRoundingNotice": "Every payment is rounded up to whole REALU shares, because there are no fractional REALU. Leftover ZCHF is kept by DFX.",
"payQuoteSummary": "You pay ${amount} ${asset}",
"payQuoteTitle": "Confirm payment",
"payQuoteUnavailable": "No ZCHF payment is available for this payment code.",
diff --git a/docs/handbook/README.md b/docs/handbook/README.md
index 2bc7b928b..82d593d62 100644
--- a/docs/handbook/README.md
+++ b/docs/handbook/README.md
@@ -21,7 +21,7 @@ deployten Image (`handbook.realunit.app`).
## Screenshots regenerieren
-Es gibt keinen separaten Regeneration-Schritt: Die 301 Handbook-Screenshots
+Es gibt keinen separaten Regeneration-Schritt: Die 304 Handbook-Screenshots
sind direkt die Golden-Baselines unter `test/goldens/` (gemappt in
`scripts/assemble-handbook-screenshots.sh`). Eine UI-Änderung an einer der
gemappten Pages produziert beim `flutter test test/goldens` einen Diff —
diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html
index e65845eb1..e07a21f31 100644
--- a/docs/handbook/de/index.html
+++ b/docs/handbook/de/index.html
@@ -1137,6 +1137,9 @@
📱 App: RealUnit Wallet (Flutter)📂 Code:RealUnitCH/app
- 🧪 Tests: 355 Visual-Regression Goldens (301 davon im Handbook) · 26 Tier-3 Smoke-Flows
+ 🧪 Tests: 355 Visual-Regression Goldens (304 davon im Handbook) · 26 Tier-3 Smoke-Flows
Single Source of Truth
- Die 301 App-Screenshots dieser Seite sind Goldens aus
+ Die 304 App-Screenshots dieser Seite sind Goldens aus
test/goldens/{screens,widgets}/<feature>/goldens/macos/<file>.png.
Welcher Golden welcher Handbook-Slot ist, definiert die Mapping-Tabelle in
scripts/assemble-handbook-screenshots.sh. Der Docker-Build
@@ -1233,7 +1236,7 @@
Single Source of Truth
Pipeline: Page-Edit → flutter test --update-goldens test/goldens/
auf dem self-hosted Runner (Hardware-Determinismus) → neue Baseline committen →
handbook-deploy.yaml baut das Docker-Image aus dem gepushten Branch
- und assembliert die 301 Slots aus test/goldens/ → Push auf
+ und assembliert die 304 Slots aus test/goldens/ → Push auf
staging deployt das Handbook-Image nach Produktion, via
Cloudflare Tunnel.
@@ -8419,6 +8422,95 @@
+ OpenCryptoPay-Zahlungen verkaufen immer ganze REALU-Anteile —
+ halbe Anteile gibt es nicht. Die Bestätigungsseite («Zahlung bestätigen»)
+ sagt das ausdrücklich. Ein allfälliger ZCHF-Rest geht an DFX.
+ Die Zahlungs-API rundet den Tausch auf ganze Anteile auf; die App rechnet
+ die Stückzahl nicht selbst.
+
+ Intro-Seite Bevor Sie bezahlen mit dem zusätzlichen Absatz:
+ bei jeder Zahlung wird auf ganze REALU-Anteile aufgerundet, weil es
+ keine halben REALU gibt; ein allfälliger ZCHF-Rest geht an DFX.
+ Golden-Quelle: pay_info_page.png.
+
+ Bestätigungsseite mit Betrag, benötigten ZCHF und dem sichtbaren
+ Hinweis, dass jede Zahlung auf ganze REALU-Anteile aufgerundet wird.
+ Restliches ZCHF behält DFX. Button Bezahlen bleibt
+ die sticky CTA.
+ Golden-Quelle: pay_quote_page_ready.png.
+
+ Dieselbe Bestätigungsseite mit Empfänger, REALU-Anteilzeilen und
+ dem Aufrundungshinweis unter den Beträgen.
+ Golden-Quelle: pay_quote_page_ready_with_merchant.png.
+
+
+
+
+
+
+
diff --git a/docs/screens.md b/docs/screens.md
index 2f5adea2d..2b438c5a8 100644
--- a/docs/screens.md
+++ b/docs/screens.md
@@ -13,7 +13,8 @@ Column meaning:
**not a route**: it is shown inside a parent route (KYC steps, status
sub-pages, disclaimer steps).
- **Handbook** — the handbook screenshot slot number(s) that document the
- screen, or `—` if the screen has no Golden baseline. Each slot is a
+ screen, or `—` if the screen has no handbook slot (it may still have a
+ Visual-Regression Golden). Each slot is a
Visual-Regression Golden under `test/goldens/`, mapped to its handbook
position by `scripts/assemble-handbook-screenshots.sh`. The handbook now
covers a **curated subset of Golden baselines** — screens **plus selected
@@ -30,9 +31,10 @@ Column meaning:
default plus its state variants), so most cells now carry a range rather
than a single anchor. One Golden is a shared form widget rather than a
screen: slot `268` is `PhoneNumberField` under `test/goldens/widgets/form/`.
- Only `WebViewPage` (no active Golden) and `KycPageManager` (the orchestrator
- has no Golden of its own — its states are the individual KYC pages) still
- carry `—`. Slot ↔ Golden mapping in
+ `WebViewPage` (no active Golden) and `KycPageManager` (the orchestrator
+ has no Golden of its own — its states are the individual KYC pages) carry
+ `—`, as do `PayScanPage` and `PayProcessPage` (Visual-Regression goldens
+ exist, no handbook slot). Slot ↔ Golden mapping in
`scripts/assemble-handbook-screenshots.sh`, slot ↔ HTML block in
`docs/handbook/de/index.html`. See `docs/handbook/README.md`.
@@ -56,6 +58,10 @@ Column meaning:
| Dashboard & trading | `SellBitboxPage` | `sellBitbox` | `/sellBitbox` | `126`, `127`, `128`, `129`, `130`, `131`, `132`, `133`, `134`, `135`, `136` |
| Dashboard & trading | `SellBankAccountSelectionPage` | — | — | `121`, `122` |
| Dashboard & trading | `ReceivePage` | `receive` | `/receive` | `101`, `102` |
+| Dashboard & trading | `PayInfoPage` | `pay` | `/pay` | `293` |
+| Dashboard & trading | `PayScanPage` | — | — | — |
+| Dashboard & trading | `PayQuotePage` | — | — | `294`, `295` |
+| Dashboard & trading | `PayProcessPage` | — | — | — |
| Dashboard & trading | `ConnectBitboxPage` | — | — | `137`, `138`, `139`, `140`, `141`, `142`, `143`, `144`, `145`, `146` |
| Dashboard & trading | `BitboxAddressRecoveryPage` | `bitboxAddressRecovery` | `/bitboxAddressRecovery` | `147` |
| Dashboard & trading | `WebViewPage` | `webView` | `/webView` | — |
@@ -122,13 +128,15 @@ Column meaning:
| Shared widgets | `PhoneNumberField` | — | — | `268` |
| Shared widgets | `ReferralPayoutTransactionRow` | — | — | `282` |
-81 screens — 47 routed (`GoRoute`) + 34 non-routed. The table also carries
+85 screens — 48 routed (`GoRoute`) + 37 non-routed. The table also carries
two shared-widget baselines (`PhoneNumberField`, `ReferralPayoutTransactionRow`),
which are not screens.
Referral surfaces have Golden baselines (`276`–`292`); the handbook
documents the programme in `#spec-referral`. Overlay slots `291` and `292`
are listed on `DashboardPage` (the host screen), not as a second shared-widget
-row.
+row. OpenCryptoPay pay rounding cards `293`–`295` are documented in
+`#spec-81`. `PayScanPage` and `PayProcessPage` have Visual-Regression goldens
+under `test/goldens/screens/pay/` and no handbook slot.
## Notes
@@ -147,7 +155,7 @@ row.
`243`–`245`. `SetupPinPage` also backs the `settingsChangePin` route
(`/settings/security/changePin`) via a second constructor; that reuse has no
separate Golden and is not given its own row.
-- **Handbook numbering.** Each of the 301 handbook slots is a Visual-Regression
+- **Handbook numbering.** Each of the 304 handbook slots is a Visual-Regression
Golden under `test/goldens/`, mapped to its handbook position by
`scripts/assemble-handbook-screenshots.sh`. A parallel Tier-3 Maestro flow
(`.maestro/handbook/NN-*.yaml`) covers navigation/tap-routing smoke for the
diff --git a/docs/visual-regression-tests.md b/docs/visual-regression-tests.md
index a43d1d01f..c173b59b3 100644
--- a/docs/visual-regression-tests.md
+++ b/docs/visual-regression-tests.md
@@ -191,7 +191,7 @@ public repos are free even for macOS minutes.
## Handbook screenshots are sourced from Goldens
-The 301 PNGs the handbook serves at `handbook.realunit.app/screenshots/`
+The 304 PNGs the handbook serves at `handbook.realunit.app/screenshots/`
are assembled from the Golden baselines at docker-build time. One
Golden → one handbook page, via the explicit mapping in
`scripts/assemble-handbook-screenshots.sh`. The handbook does **not**
@@ -231,7 +231,7 @@ that directory into `/usr/share/nginx/html/screenshots/`.
Golden file.
4. Open the PR. The `Handbook Build Check` workflow runs
`docker build` and a container smoke (`/healthz` + auth gate +
- spot-checks selected mapped screenshots via `docker exec test -f`; the full set is gated by the assemble step (`expected 301`)). A missing Golden surfaces here
+ spot-checks selected mapped screenshots via `docker exec test -f`; the full set is gated by the assemble step (`expected 304`)). A missing Golden surfaces here
as a missing-source error from the assembly script before docker
even spins up.
diff --git a/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart
index 763711c8f..7b1f5a27e 100644
--- a/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart
+++ b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart
@@ -50,11 +50,13 @@ class PayProcessCubit extends Cubit {
SwapPaymentInfo? _swap;
- /// Set once the REALU→ZCHF swap has been broadcast successfully. From this
- /// point the user holds ZCHF and recovery must NEVER re-swap — the pay leg is
- /// retried on its own via [retryPay].
+ /// Set after the swap is signed, before broadcast. A timeout can still have
+ /// sent the tx; recovery must NEVER re-swap — the pay leg is retried via
+ /// [retryPay].
bool _swapCompleted = false;
+ bool get swapCompleted => _swapCompleted;
+
/// Guards overlapping ETH-poll ticks from each calling [_executeSwap]. Set
/// synchronously before the first await in a tick; released in `finally` on
/// every path out of a tick (success, max-attempts, isClosed, transient
@@ -108,7 +110,8 @@ class PayProcessCubit extends Cubit {
/// the common case (a few minutes of drift + the OCP/swap fees), so any
/// adverse move stranded the user in ZCHF that could not cover settlement.
/// 3% is a pragmatic headroom that absorbs ordinary drift while keeping the
- /// over-swap small (leftover ZCHF simply stays in the wallet); a larger move
+ /// over-swap small (leftover from the 3% swap buffer is swept to DFX on the
+ /// pay transfer, not left on the user address); a larger move
/// is caught explicitly and surfaced as a retryable
/// [PayRetryReason.insufficientZchf] rather than a server-side failure.
static const _slippageBuffer = 1.03;
@@ -173,7 +176,13 @@ class PayProcessCubit extends Cubit {
// The API is the authority on whether the swap is fundable; render its
// signal rather than recomputing limits locally.
if (!swap.isValid) {
- emit(const PayProcessFailure(PayProcessFailureReason.insufficientZchf));
+ final error = swap.error;
+ emit(
+ PayProcessFailure(
+ PayProcessFailureReason.generic,
+ message: (error != null && error.isNotEmpty) ? error : null,
+ ),
+ );
return;
}
@@ -259,12 +268,12 @@ class PayProcessCubit extends Cubit {
if (isClosed) return;
final signed = await _signTransaction(unsigned.swap);
if (isClosed) return;
- await _payService.broadcastSwapTransaction(swap.id, signed);
- if (isClosed) return;
- // The swap is now irreversible — the user holds ZCHF. From here every
- // recovery path retries the PAY leg only; the swap is never redone.
+ // Mark completed before the HTTP round-trip. A timeout or dropped 2xx
+ // can still have broadcast the tx; the quote must not re-enable Pay.
_swapCompleted = true;
_acquiredZchf = swap.estimatedAmount;
+ await _payService.broadcastSwapTransaction(swap.id, signed);
+ if (isClosed) return;
await _refreshQuoteAndPay();
} on PaySignatureUnsupportedException {
if (isClosed) return;
@@ -274,6 +283,17 @@ class PayProcessCubit extends Cubit {
emit(const PayProcessFailure(PayProcessFailureReason.bitboxRequired));
} catch (e) {
if (isClosed) return;
+ if (_swapCompleted) {
+ // API errors 1:1; transport/timeout has no user copy — the retry sheet
+ // falls back to payRetryTransient (ZCHF stays until pay succeeds).
+ emit(
+ PayProcessPayRetry(
+ PayRetryReason.transient,
+ message: e is ApiException ? e.message : null,
+ ),
+ );
+ return;
+ }
emit(PayProcessFailure(PayProcessFailureReason.generic, message: ApiException.userFacingMessage(e)));
}
}
diff --git a/lib/screens/pay/cubits/pay_process/pay_process_state.dart b/lib/screens/pay/cubits/pay_process/pay_process_state.dart
index 1a512e003..24be3412f 100644
--- a/lib/screens/pay/cubits/pay_process/pay_process_state.dart
+++ b/lib/screens/pay/cubits/pay_process/pay_process_state.dart
@@ -3,10 +3,6 @@ part of 'pay_process_cubit.dart';
/// Why the pay flow failed. Each reason maps to a localized, user-facing
/// message in the view — the cubit carries the reason, not the copy.
enum PayProcessFailureReason {
- /// The swap quote came back invalid (e.g. not fundable for the requested
- /// ZCHF amount after the slippage buffer).
- insufficientZchf,
-
/// Not enough ETH to cover gas and the faucet top-up did not arrive.
insufficientEth,
diff --git a/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart
index ceeff9eb6..d056b89c0 100644
--- a/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart
+++ b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart
@@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart';
import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart';
import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
part 'pay_quote_state.dart';
@@ -11,7 +12,9 @@ part 'pay_quote_state.dart';
/// the requested fiat amount + the exact ZCHF amount the Ethereum method
/// requires. The amount comes from the API `transferAmounts` (ZCHF on the
/// Ethereum entry) — the app never computes it. An expired quote surfaces as a
-/// typed state so the view can prompt a re-scan.
+/// typed state so the view can prompt a re-scan. An invalid swap preview
+/// (`isValid: false`) is [PayQuoteError], never Ready — the app does not
+/// locally ceil share counts; `swap.amount` is displayed only when valid.
class PayQuoteCubit extends Cubit {
final RealUnitPayService _payService;
final String _paymentLinkId;
@@ -47,6 +50,11 @@ class PayQuoteCubit extends Cubit {
);
if (isClosed) return;
+ if (!swap.isValid) {
+ emit(_invalidSwapState(swap));
+ return;
+ }
+
emit(
PayQuoteReady(
paymentLinkId: _paymentLinkId,
@@ -67,6 +75,17 @@ class PayQuoteCubit extends Cubit {
}
}
+ /// Invalid preview swaps are not payable. Non-empty API errors are shown 1:1;
+ /// a missing/empty error uses the view's generic copy. The app does not map
+ /// `AmountTooLow` onto holdings — that string is the API's volume signal.
+ static PayQuoteError _invalidSwapState(SwapPaymentInfo swap) {
+ final error = swap.error;
+ if (error != null && error.isNotEmpty) {
+ return PayQuoteError(error);
+ }
+ return const PayQuoteError('');
+ }
+
/// The ZCHF amount listed for the Ethereum transfer method, or null if the
/// payment link does not offer an Ethereum/ZCHF method.
static double? _zchfTransferAmount(LnurlpPaymentDto details) {
diff --git a/lib/screens/pay/pay_process_page.dart b/lib/screens/pay/pay_process_page.dart
index fcd0338c5..71baef6dc 100644
--- a/lib/screens/pay/pay_process_page.dart
+++ b/lib/screens/pay/pay_process_page.dart
@@ -59,6 +59,7 @@ class PayProcessView extends StatelessWidget {
icon: Icons.check_circle_rounded,
title: S.of(context).paySuccess,
description: S.of(context).paySuccessDescription,
+ swapCompleted: true,
);
} else if (state is PayProcessPayRetry) {
// The swap already succeeded — offer to retry the PAY leg only. The
@@ -70,6 +71,7 @@ class PayProcessView extends StatelessWidget {
icon: Icons.error_rounded,
title: S.of(context).payFailureTitle,
description: _failureMessage(context, state),
+ swapCompleted: context.read().swapCompleted,
);
}
},
@@ -115,7 +117,6 @@ class PayProcessView extends StatelessWidget {
return apiText;
}
return switch (state.reason) {
- PayProcessFailureReason.insufficientZchf => S.of(context).payFailureInsufficientZchf,
PayProcessFailureReason.insufficientEth => S.of(context).payFailureInsufficientEth,
PayProcessFailureReason.signatureUnsupported => S.of(context).payFailureSignatureUnsupported,
PayProcessFailureReason.bitboxRequired => S.of(context).payFailureBitboxRequired,
@@ -141,6 +142,7 @@ class PayProcessView extends StatelessWidget {
required IconData icon,
required String title,
required String description,
+ required bool swapCompleted,
}) async {
await waitForIncomingRouteAnimation(context);
if (!context.mounted) {
@@ -175,7 +177,7 @@ class PayProcessView extends StatelessWidget {
),
),
);
- if (context.mounted) Navigator.of(context).pop();
+ if (context.mounted) Navigator.of(context).pop(swapCompleted);
}
/// Recovery sheet shown after a successful swap when the pay leg failed. The
@@ -233,7 +235,8 @@ class PayProcessView extends StatelessWidget {
await cubit.retryPay();
} else if (context.mounted) {
// Closed: leave the flow. The swapped ZCHF stays safely in the wallet.
- Navigator.of(context).pop();
+ // true tells the quote page not to offer Pay again on the same quote.
+ Navigator.of(context).pop(true);
}
}
}
diff --git a/lib/screens/pay/pay_quote_page.dart b/lib/screens/pay/pay_quote_page.dart
index 71ec878f6..665dc4bc6 100644
--- a/lib/screens/pay/pay_quote_page.dart
+++ b/lib/screens/pay/pay_quote_page.dart
@@ -121,22 +121,43 @@ class _PayQuoteReadyViewState extends State<_PayQuoteReadyView> {
value: '${state.realuFeesTotal!.toStringAsFixed(2)} ${realUnitAsset.symbol}',
),
],
+ Text(
+ S.of(context).payQuoteRoundingNotice,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: RealUnitColors.neutral500,
+ ),
+ ),
],
),
actions: [
FilledButton(
onPressed: _navigating
? null
- : () {
+ : () async {
+ if (_navigating) return;
setState(() => _navigating = true);
- Navigator.of(context).push(
- MaterialPageRoute(
- builder: (_) => PayProcessPage(
- paymentLinkId: state.paymentLinkId,
- zchfNeeded: state.zchfAmount,
+ bool? swapCompleted = false;
+ try {
+ swapCompleted = await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => PayProcessPage(
+ paymentLinkId: state.paymentLinkId,
+ zchfNeeded: state.zchfAmount,
+ ),
),
- ),
- );
+ );
+ } finally {
+ // Only a typed pre-swap failure pops `false` and re-enables
+ // Pay. `true` (swap ran) and `null` (AppBar / system back)
+ // both leave this quote so REALU cannot be sold twice.
+ if (mounted && swapCompleted == false) {
+ setState(() => _navigating = false);
+ }
+ }
+ if (mounted && swapCompleted != false) {
+ Navigator.of(context).pop();
+ }
},
child: Text(S.of(context).payConfirmButton),
),
diff --git a/scripts/assemble-handbook-screenshots.sh b/scripts/assemble-handbook-screenshots.sh
index 73aee07fd..e6eab98af 100755
--- a/scripts/assemble-handbook-screenshots.sh
+++ b/scripts/assemble-handbook-screenshots.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
#
-# Assemble the 301 handbook screenshots from the visual-regression Golden
+# Assemble the 304 handbook screenshots from the visual-regression Golden
# baselines. The flat `NN-name.png` output layout matches what
# docs/handbook/de/index.html links to (``
# — the relative path resolves to `docs/handbook/screenshots/NN-name.png`).
@@ -341,6 +341,11 @@ MAPPING=(
"290-kyc-registration-referral-promo=screens/kyc/goldens/macos/kyc_registration_referral_step_promo.png"
"291-referral-bind-error-invalid=screens/referral/goldens/macos/referral_bind_error_invalid.png"
"292-referral-bind-error-already-registered=screens/referral/goldens/macos/referral_bind_error_already_registered.png"
+ # 293–295: OpenCryptoPay pay whole-share rounding disclosure. No matching
+ # .maestro/handbook flow — pay is behind the insider unlock in the 01–26 chain.
+ "293-pay-info=screens/pay/goldens/macos/pay_info_page.png"
+ "294-pay-quote-ready=screens/pay/goldens/macos/pay_quote_page_ready.png"
+ "295-pay-quote-ready-merchant=screens/pay/goldens/macos/pay_quote_page_ready_with_merchant.png"
)
missing=()
diff --git a/test/goldens/screens/pay/goldens/macos/pay_info_page.png b/test/goldens/screens/pay/goldens/macos/pay_info_page.png
index c21ea3bd9..9fe11497c 100644
Binary files a/test/goldens/screens/pay/goldens/macos/pay_info_page.png and b/test/goldens/screens/pay/goldens/macos/pay_info_page.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png
index 9441f7a8a..ad005122a 100644
Binary files a/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png and b/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready_with_merchant.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready_with_merchant.png
index 5df1e5c2b..f6a4c25b4 100644
Binary files a/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready_with_merchant.png and b/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready_with_merchant.png differ
diff --git a/test/goldens/screens/pay/pay_process_golden_test.dart b/test/goldens/screens/pay/pay_process_golden_test.dart
index 80f8d4f30..02bb41a2f 100644
--- a/test/goldens/screens/pay/pay_process_golden_test.dart
+++ b/test/goldens/screens/pay/pay_process_golden_test.dart
@@ -15,6 +15,7 @@ void main() {
setUp(() {
processCubit = _MockPayProcessCubit();
when(() => processCubit.state).thenReturn(const PayProcessInitial());
+ when(() => processCubit.swapCompleted).thenReturn(false);
});
// PayProcessPage resolves its cubit from getIt and calls start(); the golden
diff --git a/test/integration/pay_ocp_flow_test.dart b/test/integration/pay_ocp_flow_test.dart
new file mode 100644
index 000000000..fe7337f5f
--- /dev/null
+++ b/test/integration/pay_ocp_flow_test.dart
@@ -0,0 +1,331 @@
+// Cross-layer integration tests for the OpenCryptoPay pay flow.
+//
+// These tests stitch together the layers that a 1 EUR / 0.95 ZCHF merchant
+// bill touches end-to-end. That bill is the original screenshot bug (quote
+// showed 0 REALU sold vs ~0.95 ZCHF needed, Pay disabled) and the leftover
+// sweep (the pay unsigned-tx `amountWei` is the full ZCHF balance, not the
+// 0.95 quote):
+//
+// PayQuoteCubit / PayProcessCubit
+// → FakeBitboxCredentials.signToSignature (BitBox boundary, no real
+// BLE/USB stack)
+// → RealUnitPayService (real production class)
+// → MockClient (last-mile HTTP boundary stand-in)
+//
+// We deliberately wire up the real RealUnitPayService instead of stubbing
+// it — the goal is to pin the contract between the cubits and the service
+// AND the wire-format the service produces. The MockClient plays the fixed
+// API: PUT /v1/realunit/swap with targetAmount 0.95 returns amount: 1,
+// isValid: true. The app does not ceil shares locally; Ready displays the
+// JSON `amount`.
+//
+// They run headless (no device, no simulator, no live merchant QR), so they
+// live under `test/integration/` and run as part of `flutter test`.
+
+import 'dart:convert';
+
+import 'package:fake_async/fake_async.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
+import 'package:http/testing.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/packages/config/api_config.dart';
+import 'package:realunit_wallet/packages/config/network_mode.dart';
+import 'package:realunit_wallet/packages/repository/cache_repository.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/packages/service/session_cache.dart';
+import 'package:realunit_wallet/packages/service/wallet_service.dart';
+import 'package:realunit_wallet/packages/wallet/wallet.dart';
+import 'package:realunit_wallet/packages/wallet/wallet_account.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart';
+
+import '../helper/fake_bitbox_credentials.dart';
+
+class _MockFaucet extends Mock implements DfxFaucetService {}
+
+class _MockBlockchain extends Mock implements DfxBlockchainApiService {}
+
+class _MockAppStore extends Mock implements AppStore {}
+
+class _MockWallet extends Mock implements AWallet {}
+
+class _MockWalletAccount extends Mock implements AWalletAccount {}
+
+class _MockWalletService extends Mock implements WalletService {}
+
+class _MockCacheRepository extends Mock implements CacheRepository {}
+
+const _paymentLinkId = 'pl_realunit_ocp_1eur';
+const _lnurlpPath = '/v1/lnurlp/pl_realunit_ocp_1eur';
+const _swapPath = '/v1/realunit/swap';
+const _swapUnsignedPath = '/v1/realunit/swap/99/unsigned-transaction';
+const _swapBroadcastPath = '/v1/realunit/swap/99/broadcast';
+const _payUnsignedPath = '/v1/realunit/pay/unsigned-transaction';
+const _paySubmitPath = '/v1/realunit/pay/submit';
+const _payStatusPath = '/v1/realunit/pay/pl_realunit_ocp_1eur/status';
+
+// Verified EIP-1559 unsigned pay tx from pay_process_cubit_test.dart
+// (`amountWei: 5 ZCHF` vs a 0.95 quote — the leftover overpay). The app must
+// forward this hex unchanged; it must not shrink amountWei to the quote.
+const _unsignedPayHex =
+ '0x02f87183aa36a7018459682f008504a817c800830186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c0';
+
+Map _lnurlpJson() => {
+ 'requestedAmount': {'asset': 'EUR', 'amount': 1},
+ 'quote': {
+ 'id': 'plq_1eur',
+ 'expiration': DateTime.now().add(const Duration(minutes: 5)).toIso8601String(),
+ },
+ 'transferAmounts': [
+ {
+ 'method': 'Ethereum',
+ 'assets': [
+ {'asset': 'ZCHF', 'amount': 0.95},
+ ],
+ },
+ ],
+};
+
+Map _swapInfoJson({
+ required num amount,
+ required bool isValid,
+ String? error,
+}) => {
+ 'id': 99,
+ 'uid': 'MOCK-UID',
+ 'routeId': 7,
+ 'timestamp': '2026-06-03T00:00:00.000Z',
+ 'amount': amount,
+ 'estimatedAmount': 1.05,
+ 'targetAsset': 'ZCHF',
+ 'minVolume': 1,
+ 'maxVolume': 1000,
+ 'minVolumeTarget': 95,
+ 'maxVolumeTarget': 95000,
+ 'ethBalance': 1.0,
+ 'requiredGasEth': 0.001,
+ 'isValid': isValid,
+ if (error != null) 'error': error,
+};
+
+Map _unsignedPayJson() => {
+ 'unsignedTx': _unsignedPayHex,
+ 'tokenAddress': '0x111111111111111111111111111111111111ac01',
+ 'recipient': '0x222222222222222222222222222222222222bc02',
+ 'amountWei': '5000000000000000000',
+ 'chainId': 11155111,
+};
+
+void main() {
+ late _MockFaucet faucet;
+ late _MockBlockchain blockchain;
+ late _MockAppStore appStore;
+ late _MockWallet wallet;
+ late _MockWalletAccount account;
+ late _MockWalletService walletService;
+ late SessionCache session;
+ late FakeBitboxCredentials creds;
+
+ setUp(() {
+ faucet = _MockFaucet();
+ blockchain = _MockBlockchain();
+ appStore = _MockAppStore();
+ wallet = _MockWallet();
+ account = _MockWalletAccount();
+ walletService = _MockWalletService();
+ session = SessionCache(_MockCacheRepository());
+ // Pre-seed an auth token so the service skips its sign-message round-trip.
+ session.setAuthToken('jwt-test');
+
+ creds = FakeBitboxCredentials(signDelay: Duration.zero);
+
+ when(() => appStore.wallet).thenReturn(wallet);
+ when(
+ () => appStore.apiConfig,
+ ).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet));
+ when(() => appStore.sessionCache).thenReturn(session);
+ when(() => appStore.primaryAddress).thenReturn(creds.address.hexEip55);
+ when(() => wallet.walletType).thenReturn(WalletType.bitbox);
+ when(() => wallet.currentAccount).thenReturn(account);
+ when(() => account.primaryAddress).thenReturn(creds);
+ when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {});
+ when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {});
+ });
+
+ // Build a real [RealUnitPayService] backed by [client]. The cubits talk to
+ // this service object — there is no service-level stub between the cubit
+ // and the HTTP boundary.
+ RealUnitPayService buildPayService(http.Client client) {
+ when(() => appStore.httpClient).thenReturn(client);
+ return RealUnitPayService(appStore, walletService);
+ }
+
+ group('pay OCP flow cross-layer: cubit → BitBox boundary → service → MockClient', () {
+ test(
+ '1 EUR / 0.95 ZCHF quote with a 1-share swap preview becomes PayQuoteReady',
+ () async {
+ // Original screenshot bug: 0 REALU sold / grey Pay. The MockClient
+ // plays the fixed API (targetAmount 0.95 → amount: 1, isValid: true).
+ // Ready with 1 share is the fix — the harness does not ceil locally.
+ Map? swapBody;
+ final client = MockClient((request) async {
+ if (request.method == 'GET' && request.url.path == _lnurlpPath) {
+ return http.Response(jsonEncode(_lnurlpJson()), 200);
+ }
+ if (request.method == 'PUT' && request.url.path == _swapPath) {
+ swapBody = jsonDecode(request.body) as Map;
+ return http.Response(
+ jsonEncode(_swapInfoJson(amount: 1, isValid: true)),
+ 200,
+ );
+ }
+ fail('unexpected ${request.method} ${request.url.path}');
+ });
+
+ final cubit = PayQuoteCubit(buildPayService(client), _paymentLinkId);
+ await cubit.load();
+
+ expect(cubit.state, isA());
+ final state = cubit.state as PayQuoteReady;
+ expect(state.fiatAsset, 'EUR');
+ expect(state.fiatAmount, 1);
+ expect(state.zchfAmount, 0.95);
+ expect(state.realuAmount, 1);
+ expect(state.realuEstimatedZchf, 1.05);
+
+ expect(swapBody, isNotNull);
+ expect(swapBody!['targetAmount'], 0.95);
+ expect(swapBody!.containsKey('amount'), isFalse);
+
+ await cubit.close();
+ },
+ );
+
+ test('invalid 0-share swap preview is PayQuoteError, never Ready', () async {
+ // Same lnurlp bill; the API returns amount: 0 / isValid: false. The app
+ // surfaces AmountTooLow 1:1 and does not invent a 1-share Ready state.
+ final client = MockClient((request) async {
+ if (request.method == 'GET' && request.url.path == _lnurlpPath) {
+ return http.Response(jsonEncode(_lnurlpJson()), 200);
+ }
+ if (request.method == 'PUT' && request.url.path == _swapPath) {
+ return http.Response(
+ jsonEncode(
+ _swapInfoJson(amount: 0, isValid: false, error: 'AmountTooLow'),
+ ),
+ 200,
+ );
+ }
+ fail('unexpected ${request.method} ${request.url.path}');
+ });
+
+ final cubit = PayQuoteCubit(buildPayService(client), _paymentLinkId);
+ await cubit.load();
+
+ expect(cubit.state, isA());
+ expect(cubit.state, isNot(isA()));
+ expect((cubit.state as PayQuoteError).message, 'AmountTooLow');
+
+ await cubit.close();
+ });
+
+ test('process pays the unsigned amountWei (full ZCHF), not the 0.95 quote', () {
+ fakeAsync((async) {
+ // The sign step uses `Future.delayed(Duration.zero)` (FakeBitboxCredentials),
+ // which is a zero-duration *timer* under fakeAsync — `flushMicrotasks` alone
+ // does not fire it. Elapsing zero repeatedly drains the whole await chain
+ // (each MockClient future + every zero-delay sign timer) until the cubit
+ // settles. Then elapse 3s for the Completed status poll.
+ void drain() {
+ for (var i = 0; i < 40; i++) {
+ async.flushMicrotasks();
+ async.elapse(Duration.zero);
+ }
+ }
+
+ Map? swapBody;
+ Map? submitBody;
+
+ final client = MockClient((request) async {
+ final path = request.url.path;
+ if (request.method == 'GET' && path == _lnurlpPath) {
+ return http.Response(jsonEncode(_lnurlpJson()), 200);
+ }
+ if (request.method == 'PUT' && path == _swapPath) {
+ swapBody = jsonDecode(request.body) as Map;
+ return http.Response(
+ jsonEncode(_swapInfoJson(amount: 1, isValid: true)),
+ 200,
+ );
+ }
+ if (request.method == 'PUT' && path == _swapUnsignedPath) {
+ return http.Response(jsonEncode({'swap': '0x02f8aa'}), 200);
+ }
+ if (request.method == 'PUT' && path == _swapBroadcastPath) {
+ return http.Response(jsonEncode({'txHash': '0xswaptx'}), 200);
+ }
+ if (request.method == 'PUT' && path == _payUnsignedPath) {
+ return http.Response(jsonEncode(_unsignedPayJson()), 200);
+ }
+ if (request.method == 'PUT' && path == _paySubmitPath) {
+ submitBody = jsonDecode(request.body) as Map;
+ return http.Response(jsonEncode({'txId': '0xpaytx'}), 200);
+ }
+ if (request.method == 'GET' && path == _payStatusPath) {
+ return http.Response(jsonEncode({'status': 'Completed'}), 200);
+ }
+ fail('unexpected ${request.method} ${request.url.path}');
+ });
+
+ // _unsignedPay fixture is chainId 11155111 (Sepolia). PayProcessCubit
+ // binds the RLP chainId to apiConfig.asset.chainId; mainnet would fail
+ // as unsignedTxMismatch.
+ when(
+ () => appStore.apiConfig,
+ ).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet));
+ final payService = buildPayService(client);
+
+ final cubit = PayProcessCubit(
+ payService: payService,
+ faucetService: faucet,
+ blockchainService: blockchain,
+ walletService: walletService,
+ appStore: appStore,
+ paymentLinkId: _paymentLinkId,
+ zchfNeeded: 0.95,
+ );
+ cubit.start();
+ drain();
+
+ expect(
+ cubit.state,
+ anyOf(isA(), isA()),
+ );
+
+ expect(swapBody, isNotNull);
+ expect(swapBody!['targetAmount'], closeTo(0.95 * 1.03, 0.0001));
+ expect(swapBody!.containsKey('amount'), isFalse);
+
+ expect(submitBody, isNotNull);
+ expect(submitBody!['unsignedTx'], _unsignedPayHex);
+ expect(submitBody!['r'], isNotNull);
+ expect(submitBody!['s'], isNotNull);
+ expect(submitBody!.containsKey('v'), isTrue);
+
+ async.elapse(const Duration(seconds: 3));
+ drain();
+ expect(
+ cubit.state,
+ anyOf(isA(), isA()),
+ );
+
+ cubit.close();
+ async.flushTimers();
+ });
+ });
+ });
+}
diff --git a/test/screens/pay/pay_info_page_test.dart b/test/screens/pay/pay_info_page_test.dart
index edbea6c40..1c917c128 100644
--- a/test/screens/pay/pay_info_page_test.dart
+++ b/test/screens/pay/pay_info_page_test.dart
@@ -15,6 +15,10 @@ void main() {
expect(find.text(S.current.payInfoTitle), findsOneWidget);
expect(find.text(S.current.payInfoBody), findsOneWidget);
expect(find.text(S.current.next), findsOneWidget);
+ expect(
+ S.current.payInfoBody,
+ anyOf(contains('ganze REALU'), contains('whole REALU shares')),
+ );
});
testWidgets('continues to the scanner with the initial payload', (tester) async {
diff --git a/test/screens/pay/pay_process_cubit_test.dart b/test/screens/pay/pay_process_cubit_test.dart
index cfcb40dbd..3e9b1ab3e 100644
--- a/test/screens/pay/pay_process_cubit_test.dart
+++ b/test/screens/pay/pay_process_cubit_test.dart
@@ -57,6 +57,7 @@ SwapPaymentInfo _swap({
double ethBalance = 1.0,
double requiredGasEth = 0.001,
bool isValid = true,
+ String? error,
}) {
return SwapPaymentInfo.fromDto(
RealUnitSwapPaymentInfoDto(
@@ -74,6 +75,7 @@ SwapPaymentInfo _swap({
ethBalance: ethBalance,
requiredGasEth: requiredGasEth,
isValid: isValid,
+ error: error,
),
);
}
@@ -313,14 +315,17 @@ void main() {
await cubit.close();
});
- test('invalid swap quote → insufficientZchf', () async {
- when(() => payService.getSwapPaymentInfo(any())).thenAnswer((_) async => _swap(isValid: false));
+ test('invalid swap quote surfaces the API error 1:1', () async {
+ when(() => payService.getSwapPaymentInfo(any())).thenAnswer(
+ (_) async => _swap(isValid: false, error: 'AmountTooLow'),
+ );
final cubit = build();
await cubit.start();
final state = cubit.state as PayProcessFailure;
- expect(state.reason, PayProcessFailureReason.insufficientZchf);
+ expect(state.reason, PayProcessFailureReason.generic);
+ expect(state.message, 'AmountTooLow');
await cubit.close();
});
@@ -984,6 +989,23 @@ void main() {
await cubit.close();
});
+ test('broadcast throw after sign is a pay-only retry, not a re-scan', () async {
+ wireHappyPath();
+ when(
+ () => payService.broadcastSwapTransaction(any(), any()),
+ ).thenThrow(Exception('timeout'));
+
+ final cubit = build();
+ final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry);
+ await cubit.start();
+ final state = await retry as PayProcessPayRetry;
+
+ expect(state.reason, PayRetryReason.transient);
+ expect(state.message, isNull);
+ expect(cubit.swapCompleted, isTrue);
+ await cubit.close();
+ });
+
test('transient quote re-fetch failure after swap → retry (not re-scan)', () async {
wireHappyPath();
when(() => payService.getPaymentDetails('pl_abc')).thenThrow(Exception('lnurlp 500'));
diff --git a/test/screens/pay/pay_process_page_test.dart b/test/screens/pay/pay_process_page_test.dart
index 2882fcfe0..531232b94 100644
--- a/test/screens/pay/pay_process_page_test.dart
+++ b/test/screens/pay/pay_process_page_test.dart
@@ -66,6 +66,7 @@ void main() {
processCubit = _MockPayProcessCubit();
when(() => processCubit.state).thenReturn(const PayProcessInitial());
when(() => processCubit.retryPay()).thenAnswer((_) async {});
+ when(() => processCubit.swapCompleted).thenReturn(false);
});
Widget buildSubject() => BlocProvider.value(
@@ -220,6 +221,63 @@ void main() {
await tester.pump(const Duration(milliseconds: 400));
}
+ /// Pushes [PayProcessView] onto a navigator so the `push` result can
+ /// be captured. Phone-sized surface matches [pumpWithState] so result sheets
+ /// fit the hit box.
+ Future pumpPushedView(
+ WidgetTester tester, {
+ required void Function(bool? result) onPopped,
+ }) async {
+ tester.view.physicalSize = const Size(1200, 2400);
+ tester.view.devicePixelRatio = 1.0;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+
+ await tester.pumpApp(
+ Builder(
+ builder: (context) => Scaffold(
+ body: Center(
+ child: TextButton(
+ onPressed: () async {
+ final result = await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => BlocProvider.value(
+ value: processCubit,
+ child: const PayProcessView(),
+ ),
+ ),
+ );
+ onPopped(result);
+ },
+ child: const Text('go'),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.text('go'));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+ }
+
+ Future pumpPushedWithState(
+ WidgetTester tester,
+ PayProcessState terminal, {
+ required void Function(bool? result) onPopped,
+ }) async {
+ whenListen(
+ processCubit,
+ Stream.fromIterable([terminal]),
+ initialState: const PayProcessSwapping(),
+ );
+ await pumpPushedView(tester, onPopped: onPopped);
+ // Incoming route animation + sheet open (same extra pumps as the
+ // TimedMaterialPageRoute page test).
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+ }
+
group('$PayProcessView result sheet', () {
testWidgets('success emits a success sheet with title + description', (tester) async {
await pumpWithState(tester, const PayProcessSuccess());
@@ -236,19 +294,6 @@ void main() {
expect(find.byIcon(Icons.check_circle_rounded), findsNothing);
});
- testWidgets('insufficient-zchf failure emits a failure sheet', (tester) async {
- await pumpWithState(
- tester,
- const PayProcessFailure(PayProcessFailureReason.insufficientZchf),
- );
-
- // payFailureTitle also renders as the progress-label behind the sheet,
- // so it appears twice; the reason message is the sheet-unique assertion.
- expect(find.text(S.current.payFailureTitle), findsWidgets);
- expect(find.text(S.current.payFailureInsufficientZchf), findsOne);
- expect(find.byIcon(Icons.error_rounded), findsOne);
- });
-
testWidgets('insufficient-eth failure message', (tester) async {
await pumpWithState(
tester,
@@ -352,4 +397,52 @@ void main() {
expect(find.text(S.current.payRetryTransient), findsNothing);
});
});
+
+ group('$PayProcessView swapCompleted pop', () {
+ testWidgets('failure sheet after a completed swap pops the process route with true', (
+ tester,
+ ) async {
+ when(() => processCubit.swapCompleted).thenReturn(true);
+
+ bool? popped;
+ await pumpPushedWithState(
+ tester,
+ const PayProcessFailure(PayProcessFailureReason.generic),
+ onPopped: (result) => popped = result,
+ );
+
+ expect(find.text(S.current.close), findsOne);
+ // pumpPushedWithState nests an extra navigator; Close lands off-screen.
+ final navigator = tester.state(find.byType(Navigator).first);
+ navigator.pop(); // dismiss the sheet
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+
+ expect(find.byType(PayProcessView), findsNothing);
+ expect(popped, isTrue);
+ });
+
+ testWidgets('failure sheet before a swap pops the process route with false', (tester) async {
+ bool? popped;
+ await pumpPushedWithState(
+ tester,
+ const PayProcessFailure(PayProcessFailureReason.generic),
+ onPopped: (result) => popped = result,
+ );
+
+ expect(find.text(S.current.close), findsOne);
+ // pumpPushedWithState nests an extra navigator; Close lands off-screen.
+ final navigator = tester.state(find.byType(Navigator).first);
+ navigator.pop(); // dismiss the sheet
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+
+ expect(find.byType(PayProcessView), findsNothing);
+ expect(popped, isFalse);
+ });
+ });
}
diff --git a/test/screens/pay/pay_quote_cubit_test.dart b/test/screens/pay/pay_quote_cubit_test.dart
index d92901741..2df72fbd7 100644
--- a/test/screens/pay/pay_quote_cubit_test.dart
+++ b/test/screens/pay/pay_quote_cubit_test.dart
@@ -42,6 +42,8 @@ SwapPaymentInfo _swap({
double amount = 5,
double estimatedAmount = 1.98,
double? feesTotal = 0.02,
+ bool isValid = true,
+ String? error,
}) {
return SwapPaymentInfo(
id: 99,
@@ -50,7 +52,8 @@ SwapPaymentInfo _swap({
targetAsset: 'ZCHF',
ethBalance: 1.0,
requiredGasEth: 0.001,
- isValid: true,
+ isValid: isValid,
+ error: error,
feesTotal: feesTotal,
);
}
@@ -141,6 +144,50 @@ void main() {
expect: () => [isA(), isA()],
);
+ blocTest(
+ 'an invalid swap quote with AmountTooLow emits PayQuoteError and not Ready',
+ build: build,
+ setUp: () {
+ when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenAnswer(
+ (_) async => _details(expiration: DateTime.now().add(const Duration(minutes: 5))),
+ );
+ when(() => payService.getSwapPaymentInfo(any())).thenAnswer(
+ (_) async => _swap(isValid: false, error: 'AmountTooLow', amount: 0),
+ );
+ },
+ act: (cubit) => cubit.load(),
+ expect: () => [
+ isA(),
+ const PayQuoteError('AmountTooLow'),
+ ],
+ verify: (cubit) {
+ expect(cubit.state, isA());
+ expect(cubit.state, isNot(isA()));
+ expect((cubit.state as PayQuoteError).message, 'AmountTooLow');
+ },
+ );
+
+ blocTest(
+ 'an invalid swap quote with KycRequired emits that API error 1:1',
+ build: build,
+ setUp: () {
+ when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenAnswer(
+ (_) async => _details(expiration: DateTime.now().add(const Duration(minutes: 5))),
+ );
+ when(() => payService.getSwapPaymentInfo(any())).thenAnswer(
+ (_) async => _swap(isValid: false, error: 'KycRequired'),
+ );
+ },
+ act: (cubit) => cubit.load(),
+ expect: () => [
+ isA(),
+ const PayQuoteError('KycRequired'),
+ ],
+ verify: (cubit) {
+ expect(cubit.state, isNot(isA()));
+ },
+ );
+
blocTest(
'a service error emits PayQuoteError',
build: build,
diff --git a/test/screens/pay/pay_quote_page_test.dart b/test/screens/pay/pay_quote_page_test.dart
index f4bf037e7..26e422295 100644
--- a/test/screens/pay/pay_quote_page_test.dart
+++ b/test/screens/pay/pay_quote_page_test.dart
@@ -1,5 +1,6 @@
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
@@ -115,6 +116,7 @@ void main() {
expect(find.text(S.current.payQuoteSummary('2.00', 'CHF')), findsOne);
expect(find.text('2.00 CHF'), findsOne);
expect(find.text('2.00 ZCHF'), findsOne);
+ expect(find.text(S.current.payQuoteRoundingNotice), findsOne);
expect(find.text(S.current.payConfirmButton), findsOne);
});
@@ -139,6 +141,7 @@ void main() {
expect(find.text('5 REALU'), findsOne);
expect(find.text('1.98 ZCHF'), findsOne);
expect(find.text('0.02 REALU'), findsOne);
+ expect(find.text(S.current.payQuoteRoundingNotice), findsOne);
});
testWidgets('confirm button navigates to the process step', (tester) async {
@@ -155,6 +158,50 @@ void main() {
expect(find.byType(PayProcessView), findsOne);
});
+ testWidgets('double-tap on Pay does not push two process routes', (tester) async {
+ when(() => quoteCubit.state).thenReturn(ready);
+ await tester.pumpApp(buildSubject());
+
+ await tester.tap(find.text(S.current.payConfirmButton));
+ await tester.tap(find.text(S.current.payConfirmButton), warnIfMissed: false);
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+
+ // Offstage: a second pushed route would hide the first; count both.
+ expect(find.byType(PayProcessView, skipOffstage: false), findsOne);
+ });
+
+ testWidgets('Pay re-enables after the process route pops', (tester) async {
+ when(() => quoteCubit.state).thenReturn(ready);
+ await tester.pumpApp(buildSubject());
+
+ await tester.tap(find.text(S.current.payConfirmButton));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+
+ expect(find.byType(PayProcessView), findsOne);
+
+ // Debug wallet fails before the swap and shows a result sheet. Pop the
+ // sheet; the process route then pops with swapCompleted=false so Pay
+ // re-enables. Avoid tap(): the sheet is taller than the default test
+ // surface, so Close is outside the hit box.
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+ final navigator = tester.state(find.byType(Navigator).first);
+ navigator.pop();
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+ navigator.pop(false);
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+
+ expect(find.byType(PayProcessView), findsNothing);
+ final button = tester.widget(
+ find.widgetWithText(FilledButton, S.current.payConfirmButton),
+ );
+ expect(button.onPressed, isNotNull);
+ });
+
testWidgets('expired state shows the re-scan message', (tester) async {
when(() => quoteCubit.state).thenReturn(const PayQuoteExpired());
await tester.pumpApp(buildSubject());
@@ -175,6 +222,7 @@ void main() {
expect(find.text('boom'), findsOne);
expect(find.text(S.current.payFailureGeneric), findsNothing);
+ expect(find.text(S.current.payConfirmButton), findsNothing);
});
testWidgets('error state without API text falls back to the generic copy', (tester) async {
@@ -182,6 +230,7 @@ void main() {
await tester.pumpApp(buildSubject());
expect(find.text(S.current.payFailureGeneric), findsOne);
+ expect(find.text(S.current.payConfirmButton), findsNothing);
});
testWidgets('error state retry button re-invokes load()', (tester) async {