From 72f74ee97a64fe4eb0d3920b32842b9e30632d5b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:14:06 +0200 Subject: [PATCH 1/7] feat: OCP pay-flow (RealU->ZCHF->Open CryptoPay) Add the Phase 2 Open CryptoPay pay-flow client: scan a POS payment QR, swap REALU -> ZCHF (proceeds stay in the user wallet), then pay that ZCHF to the OCP recipient via the public lnurlp settlement path. - QR scan + LUD-01 bech32 / app->api host decode (LnurlDecoder) - RealUnitPayService (extends DFXAuthService): public lnurlp read, the 3 swap endpoints and the 3 pay endpoints, with a typed mainnet-only gate for the pay/* endpoints keyed off ApiConfig.networkMode - DTOs with fromJson per resource under models/payment/pay/dto - Page + Cubit per step (scan / quote / process), separate state files; process orchestrates ETH-gas check -> swap (sign+broadcast) -> re-fetch quote -> pay (sign+submit) -> poll status, surfacing typed failures - Unified raw-payload signing (signToSignature -> r/s/v) for software and BitBox; debug wallet surfaces a dedicated non-signing failure - New typed exceptions enumerated in exception_surface_test - AppRoutes.pay + GoRoute + a third dashboard Pay action (golden updated) - mobile_scanner dependency; iOS camera usage string covers payments - i18n keys in both ARB files Consumes DFXswiss/api#3819 (pair-PR; backend lands first). --- assets/languages/strings_de.arb | 43 +- assets/languages/strings_en.arb | 43 +- ios/Runner/Info.plist | 2 +- .../exceptions/payment/pay_exceptions.dart | 39 ++ lib/packages/service/dfx/lnurl_decoder.dart | 192 ++++++ .../payment/pay/dto/lnurlp_payment_dto.dart | 93 +++ .../pay/dto/real_unit_ocp_pay_dto.dart | 14 + .../pay/dto/real_unit_ocp_pay_result_dto.dart | 11 + .../pay/dto/real_unit_ocp_pay_status_dto.dart | 41 ++ .../pay/dto/real_unit_ocp_pay_submit_dto.dart | 30 + ...unit_ocp_pay_unsigned_transaction_dto.dart | 28 + .../payment/pay/dto/real_unit_swap_dto.dart | 25 + .../dto/real_unit_swap_payment_info_dto.dart | 58 ++ ...al_unit_swap_unsigned_transaction_dto.dart | 12 + .../models/payment/pay/swap_payment_info.dart | 50 ++ .../service/dfx/real_unit_pay_service.dart | 166 +++++ .../widgets/sections/dashboard_actions.dart | 43 +- .../cubits/pay_process/pay_process_cubit.dart | 263 ++++++++ .../cubits/pay_process/pay_process_state.dart | 89 +++ .../pay/cubits/pay_quote/pay_quote_cubit.dart | 60 ++ .../pay/cubits/pay_quote/pay_quote_state.dart | 50 ++ .../pay/cubits/pay_scan/pay_scan_cubit.dart | 28 + .../pay/cubits/pay_scan/pay_scan_state.dart | 30 + lib/screens/pay/pay_process_page.dart | 147 +++++ lib/screens/pay/pay_quote_page.dart | 137 +++++ lib/screens/pay/pay_scan_page.dart | 65 ++ lib/setup/di.dart | 4 + lib/setup/routing/router_config.dart | 7 + lib/setup/routing/routes/app_routes.dart | 1 + pubspec.lock | 8 + pubspec.yaml | 1 + .../goldens/macos/dashboard_with_balance.png | Bin 26087 -> 27898 bytes .../exceptions/exception_surface_test.dart | 4 + .../service/dfx/lnurl_decoder_test.dart | 110 ++++ .../dfx/models/payment/pay/pay_dtos_test.dart | 271 +++++++++ .../dfx/real_unit_pay_service_test.dart | 379 ++++++++++++ test/screens/pay/pay_process_cubit_test.dart | 569 ++++++++++++++++++ test/screens/pay/pay_process_state_test.dart | 49 ++ test/screens/pay/pay_quote_cubit_test.dart | 99 +++ test/screens/pay/pay_scan_cubit_test.dart | 51 ++ 40 files changed, 3279 insertions(+), 33 deletions(-) create mode 100644 lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart create mode 100644 lib/packages/service/dfx/lnurl_decoder.dart create mode 100644 lib/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart create mode 100644 lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart create mode 100644 lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart create mode 100644 lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart create mode 100644 lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart create mode 100644 lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart create mode 100644 lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart create mode 100644 lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart create mode 100644 lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart create mode 100644 lib/packages/service/dfx/models/payment/pay/swap_payment_info.dart create mode 100644 lib/packages/service/dfx/real_unit_pay_service.dart create mode 100644 lib/screens/pay/cubits/pay_process/pay_process_cubit.dart create mode 100644 lib/screens/pay/cubits/pay_process/pay_process_state.dart create mode 100644 lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart create mode 100644 lib/screens/pay/cubits/pay_quote/pay_quote_state.dart create mode 100644 lib/screens/pay/cubits/pay_scan/pay_scan_cubit.dart create mode 100644 lib/screens/pay/cubits/pay_scan/pay_scan_state.dart create mode 100644 lib/screens/pay/pay_process_page.dart create mode 100644 lib/screens/pay/pay_quote_page.dart create mode 100644 lib/screens/pay/pay_scan_page.dart create mode 100644 test/packages/service/dfx/lnurl_decoder_test.dart create mode 100644 test/packages/service/dfx/models/payment/pay/pay_dtos_test.dart create mode 100644 test/packages/service/dfx/real_unit_pay_service_test.dart create mode 100644 test/screens/pay/pay_process_cubit_test.dart create mode 100644 test/screens/pay/pay_process_state_test.dart create mode 100644 test/screens/pay/pay_quote_cubit_test.dart create mode 100644 test/screens/pay/pay_scan_cubit_test.dart diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index e3f21595..10eadec3 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -33,8 +33,8 @@ "buyPaymentConfirmFailedAktionariat": "Es gibt ein technisches Problem. Bitte überprüfen Sie Ihr E-Mail-Postfach, möglicherweise fehlt noch eine Bestätigung Ihrer Blockchain-Adresse. Andernfalls versuchen Sie es später erneut. Falls der Fehler weiterhin besteht, kontaktieren Sie unseren Support.", "buyPaymentInformation": "Zahlungsinformationen", "buyPaymentInformationDescription": "Bitte überweisen Sie den Kaufbetrag mit diesen Angaben über Ihre Bankanwendung. Der Verwendungszweck ist wichtig!", - "buyRealUnit": "RealUnit kaufen", "buyRealu": "RealUnit Token kaufen", + "buyRealUnit": "RealUnit kaufen", "cancel": "Abbrechen", "changeAddress": "Adresse ändern", "changeInReview": "Änderung in Prüfung", @@ -53,11 +53,11 @@ "connectBitboxContent": "Bitte verbinden Sie Ihre BitBox mit Ihrem Smartphone.", "connectBitboxContentIos": "Bitte verbinden Sie Ihre BitBox mit Ihrem Smartphone und aktivieren Sie zusätzlich Bluetooth.", "connectBitboxFailed": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", - "connectBitboxSignInHint": "Nach der Code-Überprüfung wird die BitBox um eine zusätzliche Bestätigung zur Anmeldung gebeten.", "connectBitboxSignatureCapturing": "Bitte bestätigen Sie die Anmeldeanfrage auf Ihrem BitBox-Gerät. Diese Signatur wird einmalig erfasst, damit künftige Käufe Ihre BitBox nicht erneut benötigen.", "connectBitboxSignatureCapturingTitle": "Anmeldung bestätigen", "connectBitboxSignatureFailed": "Ihre Anmeldesignatur konnte nicht erfasst werden. Sie können es erneut versuchen oder trotzdem fortfahren – Ihre BitBox wird dann möglicherweise für Ihren ersten Kauf erneut benötigt.", "connectBitboxSignatureFailedTitle": "Anmeldung nicht abgeschlossen", + "connectBitboxSignInHint": "Nach der Code-Überprüfung wird die BitBox um eine zusätzliche Bestätigung zur Anmeldung gebeten.", "connectBitboxTitle": "BitBox verbinden", "connected": "Verbunden", "connectedBitboxContent": "Bitte bestätigen Sie und folgen nun den letzten Anweisungen auf Ihrer BitBox.", @@ -167,10 +167,35 @@ "or": "Oder", "originalPdf": "Original-PDF", "pay": "Bezahlen", + "payAwaitingSettlement": "Zahlung wird abgeschlossen", + "payConfirmButton": "Bezahlen", + "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.", + "payFailurePayFailed": "Die Zahlung konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.", + "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", + "payFailureUnsupportedEnvironment": "Open CryptoPay ist nur im Mainnet verfügbar.", "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.", "payoutAccountAdd": "Auszahlungskonto hinzufügen", "payoutAccountSelect": "Auszahlungskonto auswählen", + "payPaying": "Zahlung wird gesendet", + "payPreparingSwap": "Tausch wird vorbereitet", + "payQuoteRequested": "Geforderter Betrag", + "payQuoteSummary": "Sie bezahlen ${amount} ${asset}", + "payQuoteTitle": "Zahlung bestätigen", + "payQuoteUnavailable": "Für diesen Zahlungscode ist keine ZCHF-Zahlung verfügbar.", + "payQuoteZchfNeeded": "Benötigte ZCHF", + "payRefreshingQuote": "Angebot wird aktualisiert", + "payScanInvalid": "Dies ist kein gültiger RealUnit-Zahlungscode.", + "payScanTitle": "Zahlungscode scannen", + "paySuccess": "Zahlung erfolgreich", + "paySuccessDescription": "Ihre Zahlung wurde abgeschlossen.", + "paySwapping": "REALU wird in ZCHF getauscht", + "payWaitingForEth": "Netzwerkgebühren werden angefordert", "pdf": "PDF", "pendingTransactions": "Ausstehende Transaktionen", "personalData": "Persönliche Daten", @@ -197,8 +222,8 @@ "proofDocument": "Nachweis-Dokument", "purposeOfPayment": "Verwendungszweck", "qrCode": "QR-Code", - "realunitStockToken": "RealUnit Aktientoken", "realunitStockprice": "RealUnit Aktienkurs", + "realunitStockToken": "RealUnit Aktientoken", "realunitWallet": "RealUnit Wallet", "realunitWalletLogout": "Aus REALU Wallet abmelden", "realunitWalletLogoutCheck": "Ich habe meine Wiederherstellungsphrase gesichert.", @@ -246,18 +271,18 @@ "sellBitboxCheckingEth": "Wallet-Guthaben wird geprüft", "sellBitboxDepositDescription": "Bestätigen Sie auf der BitBox, um ZCHF an die DFX-Einzahlungsadresse zu überweisen.", "sellBitboxDepositFrom": "Sie senden", + "sellBitboxDepositing": "ZCHF wird gesendet. Bestätigen Sie auf der Bitbox", "sellBitboxDepositRetryDescription": "Der Tausch wurde abgeschlossen, aber die ZCHF-Einzahlung konnte nicht gesendet werden. Ihre Mittel sind sicher. Tippen Sie auf Wiederholen.", "sellBitboxDepositRetryTitle": "Einzahlung fehlgeschlagen", "sellBitboxDepositTitle": "ZCHF an DFX senden", "sellBitboxDepositTo": "DFX-Einzahlung", - "sellBitboxDepositing": "ZCHF wird gesendet. Bestätigen Sie auf der Bitbox", "sellBitboxEthReady": "Wallet bereit", "sellBitboxEthReadyDescription": "Ihr Wallet hat genug ETH, um mit dem Verkauf fortzufahren.", "sellBitboxSwapDescription": "Bestätigen Sie auf Ihrem BitBox, um REALU über den BrokerBot in ZCHF zu tauschen.", "sellBitboxSwapFrom": "Sie senden", + "sellBitboxSwapping": "Tausch on-chain. Bestätigen Sie auf der Bitbox.", "sellBitboxSwapTitle": "REALU → ZCHF tauschen", "sellBitboxSwapTo": "Sie erhalten", - "sellBitboxSwapping": "Tausch on-chain. Bestätigen Sie auf der Bitbox.", "sellBitboxWaitingForEth": "Gasgebühren werden angefordert", "sellBitboxWaitingForEthDescription": "Ein kleiner ETH-Betrag wird an Ihr Wallet gesendet, um die Transaktionsgebühren zu decken. Dies kann einige Minuten dauern.", "sellMinAmount": "Mindestbetrag: ${amount} ${currency}", @@ -282,10 +307,10 @@ "settingsWalletBackupSubtitle1": "Bitte notieren Sie Ihre 12 Wiederherstellungs-Wörter in der exakten Reihenfolge auf einem Blatt Papier und bewahren Sie sie absolut sicher auf.", "settingsWalletBackupSubtitle2": "Dies ist die einzige Möglichkeit, Ihre Wallet wiederherzustellen.", "showSeed": "Seed anzeigen", - "signMessage": "Signierte Nachricht", - "signMessageGet": "Signierte Nachricht abrufen", "signature": "Signatur", "signingCancelled": "Signatur abgebrochen — bitte BitBox erneut bestätigen", + "signMessage": "Signierte Nachricht", + "signMessageGet": "Signierte Nachricht abrufen", "skip": "Überspringen", "softwareTermsText": "Mit der Nutzung dieser App akzeptieren Sie die Nutzungsbedingungen dieser Software.", "softwareTermsTextHighlighted": "Nutzungsbedingungen", @@ -329,9 +354,9 @@ "transactionBuy": "Kauf", "transactionHistory": "Transaktionshistorie", "transactionPending": "In Bearbeitung", + "transactions": "Transaktionen", "transactionSell": "Verkauf", "transactionWaitingForPayment": "Warte auf Zahlung", - "transactions": "Transaktionen", "twoFa": "2-Faktor Authentifizierung", "twoFaCodeRequired": "Code ist erforderlich", "twoFaCodeTooShort": "Der Code sollte 6 Ziffern lang sein", @@ -356,4 +381,4 @@ "youPay": "Sie bezahlen", "youReceive": "Sie erhalten", "youSell": "Sie verkaufen" -} +} \ No newline at end of file diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 56e4db7f..f1e5df20 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -33,8 +33,8 @@ "buyPaymentConfirmFailedAktionariat": "There is a technical problem. Please check your email inbox — you may still need to confirm your blockchain address. Otherwise, please try again later. If the error persists, contact our support team.", "buyPaymentInformation": "Payment information", "buyPaymentInformationDescription": "Please transfer the purchase amount using your banking app with these details. The purpose of payment is important!", - "buyRealUnit": "Buy RealUnit", "buyRealu": "Buy RealUnit Token", + "buyRealUnit": "Buy RealUnit", "cancel": "Cancel", "changeAddress": "Change address", "changeInReview": "Change in review", @@ -53,11 +53,11 @@ "connectBitboxContent": "Please connect your BitBox with your Smartphone.", "connectBitboxContentIos": "Please connect your BitBox with your Smartphone and activate Bluetooth.", "connectBitboxFailed": "Something went wrong. Please try to connect again.", - "connectBitboxSignInHint": "After verifying the code, the BitBox will ask for one additional confirmation to sign you in.", "connectBitboxSignatureCapturing": "Please confirm the sign-in request on your BitBox device. This signature is captured once so future purchases won't need your BitBox again.", "connectBitboxSignatureCapturingTitle": "Confirm sign-in", "connectBitboxSignatureFailed": "We couldn't capture your sign-in signature. You can retry, or continue anyway – your BitBox may then be needed again for your first purchase.", "connectBitboxSignatureFailedTitle": "Sign-in not completed", + "connectBitboxSignInHint": "After verifying the code, the BitBox will ask for one additional confirmation to sign you in.", "connectBitboxTitle": "Connect BitBox", "connected": "Connected", "connectedBitboxContent": "Please confirm and follow the last steps on your BitBox.", @@ -167,10 +167,35 @@ "or": "Or", "originalPdf": "Original PDF", "pay": "Pay", + "payAwaitingSettlement": "Completing payment", + "payConfirmButton": "Pay", + "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.", + "payFailurePayFailed": "The payment could not be completed. Please try again.", + "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", + "payFailureUnsupportedEnvironment": "Open CryptoPay is only available on mainnet.", "paymentInformationFailed": "An error occurred while getting the payment information.", "paymentInformationFailedDescription": "Please try again later. If the error persists, contact our support team.", "payoutAccountAdd": "Add payout account", "payoutAccountSelect": "Select payout account", + "payPaying": "Sending payment", + "payPreparingSwap": "Preparing swap", + "payQuoteRequested": "Requested amount", + "payQuoteSummary": "You pay ${amount} ${asset}", + "payQuoteTitle": "Confirm payment", + "payQuoteUnavailable": "No ZCHF payment is available for this payment code.", + "payQuoteZchfNeeded": "ZCHF needed", + "payRefreshingQuote": "Refreshing quote", + "payScanInvalid": "This is not a valid RealUnit payment code.", + "payScanTitle": "Scan payment code", + "paySuccess": "Payment successful", + "paySuccessDescription": "Your payment has been completed.", + "paySwapping": "Swapping REALU to ZCHF", + "payWaitingForEth": "Requesting network fees", "pdf": "PDF", "pendingTransactions": "Pending transactions", "personalData": "Personal data", @@ -197,8 +222,8 @@ "proofDocument": "Proof document", "purposeOfPayment": "Purpose of payment", "qrCode": "QR code", - "realunitStockToken": "RealUnit Stock Token", "realunitStockprice": "RealUnit Stockprice", + "realunitStockToken": "RealUnit Stock Token", "realunitWallet": "RealUnit Wallet", "realunitWalletLogout": "Log out of REALU Wallet", "realunitWalletLogoutCheck": "I have backed up my recovery phrase.", @@ -246,18 +271,18 @@ "sellBitboxCheckingEth": "Checking your wallet balance", "sellBitboxDepositDescription": "Confirm on your BitBox to transfer ZCHF to the DFX deposit address.", "sellBitboxDepositFrom": "You send", + "sellBitboxDepositing": "Sending ZCHF. Please confirm on the Bitbox.", "sellBitboxDepositRetryDescription": "The swap was completed but the ZCHF deposit could not be sent. Your funds are safe. Tap retry to try again.", "sellBitboxDepositRetryTitle": "Deposit failed", "sellBitboxDepositTitle": "Send ZCHF to DFX", "sellBitboxDepositTo": "DFX deposit", - "sellBitboxDepositing": "Sending ZCHF. Please confirm on the Bitbox.", "sellBitboxEthReady": "Wallet ready", "sellBitboxEthReadyDescription": "Your wallet has enough ETH to proceed with the sale.", "sellBitboxSwapDescription": "Confirm on your BitBox to swap REALU for ZCHF via the BrokerBot.", "sellBitboxSwapFrom": "You send", + "sellBitboxSwapping": "Swapping on-chain. Please confirm on the Bitbox.", "sellBitboxSwapTitle": "Swap REALU → ZCHF", "sellBitboxSwapTo": "You receive", - "sellBitboxSwapping": "Swapping on-chain. Please confirm on the Bitbox.", "sellBitboxWaitingForEth": "Requesting gas funds", "sellBitboxWaitingForEthDescription": "A small amount of ETH is being sent to your wallet to cover transaction fees. This may take a few minutes.", "sellMinAmount": "Minimum amount: ${amount} ${currency}", @@ -282,10 +307,10 @@ "settingsWalletBackupSubtitle1": "Please write down your 12 recovery words in the exact order on a piece of paper and keep them in a completely safe place.", "settingsWalletBackupSubtitle2": "This is the only way to recover your wallet.", "showSeed": "Show Seed", - "signMessage": "Sign Message", - "signMessageGet": "Get Sign Message", "signature": "Signature", "signingCancelled": "Signature cancelled — please confirm on the BitBox again", + "signMessage": "Sign Message", + "signMessageGet": "Get Sign Message", "skip": "Skip", "softwareTermsText": "By using this app, you accept the terms of use of this software.", "softwareTermsTextHighlighted": "terms of use", @@ -329,9 +354,9 @@ "transactionBuy": "Buy", "transactionHistory": "Transaction history", "transactionPending": "Processing", + "transactions": "Transactions", "transactionSell": "Sell", "transactionWaitingForPayment": "Waiting for payment", - "transactions": "Transactions", "twoFa": "Two-factor authentication", "twoFaCodeRequired": "Code is required", "twoFaCodeTooShort": "Code should be 6 digits", @@ -356,4 +381,4 @@ "youPay": "You pay", "youReceive": "You receive", "youSell": "You sell" -} +} \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 421d9688..60b32b0c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -44,7 +44,7 @@ LSRequiresIPhoneOS NSCameraUsageDescription - This app needs camera access to verify your identity + This app needs camera access to verify your identity and to scan payment codes NSLocationWhenInUseUsageDescription Please provide us with your geolocation data to prove your current location NSMicrophoneUsageDescription diff --git a/lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart b/lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart new file mode 100644 index 00000000..4872f492 --- /dev/null +++ b/lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart @@ -0,0 +1,39 @@ +// Typed failures for the OCP pay flow (scan → swap → pay). Each one renders a +// human-readable string (see `exception_surface_test.dart`) so it can surface +// cleanly in logs, Sentry, and user-facing error states instead of the Dart +// default `Instance of '...'`. + +/// The scanned QR / pasted code is not a DFX Open CryptoPay payment link. +class InvalidPaymentLinkException implements Exception { + final String reason; + + const InvalidPaymentLinkException(this.reason); + + @override + String toString() => 'InvalidPaymentLinkException: $reason'; +} + +/// The Open CryptoPay settlement is not available on the current backend +/// environment. The payment-link engine is mainnet-only, so `pay/submit` and +/// `pay/unsigned-transaction` fail fast on dev.api.dfx.swiss (Sepolia). +class PayUnsupportedEnvironmentException implements Exception { + const PayUnsupportedEnvironmentException(); + + @override + String toString() => + 'PayUnsupportedEnvironmentException: Open CryptoPay settlement is only ' + 'available on mainnet'; +} + +/// The loaded wallet cannot produce EIP-1559 signatures (today: the debug +/// wallet). The pay flow needs to sign the swap and pay transactions locally, +/// so it cannot proceed in this wallet mode. +class PaySignatureUnsupportedException implements Exception { + // Only ever thrown / constructed as a const expression, so the zero-arg + // body never registers a runtime line hit; toString() below is exercised. + const PaySignatureUnsupportedException(); // coverage:ignore-line + + @override + String toString() => + 'PaySignatureUnsupportedException: this wallet mode cannot sign transactions'; +} diff --git a/lib/packages/service/dfx/lnurl_decoder.dart b/lib/packages/service/dfx/lnurl_decoder.dart new file mode 100644 index 00000000..6c778110 --- /dev/null +++ b/lib/packages/service/dfx/lnurl_decoder.dart @@ -0,0 +1,192 @@ +/// Decodes an Open CryptoPay POS QR into the DFX lnurlp payment-link id and the +/// API URL the app must read the quote from. +/// +/// Two encodings are supported, both pointing at the single allowed DFX host: +/// 1. A LUD-01 bech32 `LNURL1...` string (carried in the `lightning` query +/// parameter of an `https://app.dfx.swiss/pl/?lightning=LNURL1...` QR). +/// Decoding the bech32 yields the wrapped `https://api.dfx.swiss/v1/lnurlp/pl_...` +/// URL directly. +/// 2. A plain `https://app.dfx.swiss/v1/lnurlp/pl_...` (or `/pl/?...`) URL, +/// where the `app` host is rewritten to `api` as a fallback. +/// +/// Only `app.dfx.swiss` / `api.dfx.swiss` (and their `dev.` testnet twins) are +/// accepted — any other host is rejected so a malicious QR cannot redirect the +/// authenticated quote read to a third party. +library; + +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart'; + +class DecodedPaymentLink { + /// Fully-qualified `https:///v1/lnurlp/` URL the app reads the + /// OCP quote from. + final Uri lnurlpUrl; + + /// The payment-link id (e.g. `pl_...` / `plp_...`). + final String id; + + const DecodedPaymentLink({required this.lnurlpUrl, required this.id}); +} + +abstract final class LnurlDecoder { + static const _allowedHosts = { + 'api.dfx.swiss', + 'app.dfx.swiss', + 'dev.api.dfx.swiss', + 'dev.app.dfx.swiss', + }; + + // bech32 character set (BIP-173). Index in this string is the 5-bit value. + static const _charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + + /// Decodes [raw] — the full scanned QR payload — into a [DecodedPaymentLink]. + /// + /// Throws [InvalidPaymentLinkException] when the payload is neither a + /// DFX-hosted lnurlp URL nor a bech32 LNURL wrapping one. + static DecodedPaymentLink decode(String raw) { + final input = raw.trim(); + if (input.isEmpty) { + throw const InvalidPaymentLinkException('Empty payment code'); + } + + final lightning = _extractLightningParam(input); + final target = lightning != null ? _decodeBech32(lightning) : input; + + final uri = _parseHttpUri(target); + final apiUri = _toApiUri(uri); + final id = _extractId(apiUri); + + return DecodedPaymentLink(lnurlpUrl: apiUri, id: id); + } + + /// Pulls the `lightning=` value out of a wrapper URL/URI, or returns the raw + /// bech32 when the scan is a bare `lightning:LNURL1...` / `LNURL1...` string. + static String? _extractLightningParam(String input) { + final upper = input.toUpperCase(); + if (upper.startsWith('LNURL1')) return input; + if (upper.startsWith('LIGHTNING:')) return input.substring('lightning:'.length); + + final uri = Uri.tryParse(input); + final value = uri?.queryParameters['lightning']; + if (value != null && value.toUpperCase().startsWith('LNURL1')) return value; + return null; + } + + static Uri _parseHttpUri(String value) { + final uri = Uri.tryParse(value); + if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) { + throw InvalidPaymentLinkException('Not a payment link: $value'); + } + return uri; + } + + /// Rewrites an allowed `app.dfx.swiss` host to its `api.dfx.swiss` twin and + /// forces https. Rejects any non-DFX host. + static Uri _toApiUri(Uri uri) { + if (!_allowedHosts.contains(uri.host)) { + throw InvalidPaymentLinkException('Unsupported payment host: ${uri.host}'); + } + final apiHost = uri.host.replaceFirst('app.dfx.swiss', 'api.dfx.swiss'); + return uri.replace(scheme: 'https', host: apiHost); + } + + /// Extracts the `pl_...` / `plp_...` id from the lnurlp path. + static String _extractId(Uri uri) { + final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList(); + final lnurlpIndex = segments.indexOf('lnurlp'); + if (lnurlpIndex != -1 && lnurlpIndex + 1 < segments.length) { + return segments[lnurlpIndex + 1]; + } + if (segments.isNotEmpty) return segments.last; + throw InvalidPaymentLinkException('No payment id in: $uri'); + } + + /// Decodes a LUD-01 bech32 `LNURL1...` string to its wrapped UTF-8 URL. + /// + /// LUD-01 deliberately drops the 90-char BIP-173 length limit, so only the + /// charset, the 1-byte-per-char separation, and the 6-char checksum are + /// validated here. + static String _decodeBech32(String bech) { + final lower = bech.toLowerCase(); + final sepIndex = lower.lastIndexOf('1'); + if (sepIndex < 1 || sepIndex + 7 > lower.length) { + throw InvalidPaymentLinkException('Malformed LNURL: $bech'); + } + + final hrp = lower.substring(0, sepIndex); + final dataPart = lower.substring(sepIndex + 1); + + final data = []; + for (final char in dataPart.split('')) { + final value = _charset.indexOf(char); + if (value == -1) { + throw InvalidPaymentLinkException('Invalid LNURL character: $char'); + } + data.add(value); + } + + if (!_verifyChecksum(hrp, data)) { + throw const InvalidPaymentLinkException('Invalid LNURL checksum'); + } + + // Drop the 6-symbol checksum, then regroup 5-bit → 8-bit. + final payload = data.sublist(0, data.length - 6); + final bytes = _convertBitsTo8(payload); + return String.fromCharCodes(bytes); + } + + /// Regroups 5-bit bech32 symbols into 8-bit bytes (no padding — the LNURL + /// payload is always a whole number of bytes). Rejects leftover bits that + /// cannot form a full byte, which signals a corrupt data section. + static List _convertBitsTo8(List data) { + const from = 5; + const to = 8; + var acc = 0; + var bits = 0; + final result = []; + const maxv = (1 << to) - 1; + for (final value in data) { + acc = (acc << from) | value; + bits += from; + while (bits >= to) { + bits -= to; + result.add((acc >> bits) & maxv); + } + } + // Defensive bech32 invariant: a checksum-valid LNURL payload always + // regroups into whole bytes, so this only trips on corrupt-yet-checksum- + // passing input, which the preceding checksum verify already rules out. + if (bits >= from || ((acc << (to - bits)) & maxv) != 0) { + throw const InvalidPaymentLinkException('Invalid LNURL padding'); // coverage:ignore-line + } + return result; + } + + static int _polymod(List values) { + const generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + var chk = 1; + for (final value in values) { + final top = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ value; + for (var i = 0; i < 5; i++) { + if (((top >> i) & 1) == 1) chk ^= generator[i]; + } + } + return chk; + } + + static List _hrpExpand(String hrp) { + final result = []; + for (final c in hrp.codeUnits) { + result.add(c >> 5); + } + result.add(0); + for (final c in hrp.codeUnits) { + result.add(c & 31); + } + return result; + } + + static bool _verifyChecksum(String hrp, List data) { + return _polymod([..._hrpExpand(hrp), ...data]) == 1; + } +} diff --git a/lib/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart new file mode 100644 index 00000000..32d3eadb --- /dev/null +++ b/lib/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart @@ -0,0 +1,93 @@ +/// Public payment-link read response of `GET /v1/lnurlp/:id` (on api.dfx.swiss, +/// no auth). Carries the requested fiat amount and the active quote the app +/// needs to size the swap and later settle the payment. Only the fields the pay +/// flow consumes are mapped. +class LnurlpPaymentDto { + final LnurlpRequestedAmountDto requestedAmount; + final LnurlpQuoteDto quote; + final String? recipient; + + /// Per-method/chain transfer amounts. The Ethereum entry lists the exact ZCHF + /// amount the app must transfer; the app does not compute it locally. + final List transferAmounts; + + const LnurlpPaymentDto({ + required this.requestedAmount, + required this.quote, + required this.transferAmounts, + this.recipient, + }); + + factory LnurlpPaymentDto.fromJson(Map json) { + final transfers = (json['transferAmounts'] as List?) ?? const []; + return LnurlpPaymentDto( + requestedAmount: LnurlpRequestedAmountDto.fromJson( + json['requestedAmount'] as Map, + ), + quote: LnurlpQuoteDto.fromJson(json['quote'] as Map), + recipient: json['recipient'] as String?, + transferAmounts: transfers + .map((e) => LnurlpTransferAmountDto.fromJson(e as Map)) + .toList(), + ); + } +} + +class LnurlpRequestedAmountDto { + final String asset; + final double amount; + + const LnurlpRequestedAmountDto({required this.asset, required this.amount}); + + factory LnurlpRequestedAmountDto.fromJson(Map json) { + return LnurlpRequestedAmountDto( + asset: json['asset'] as String, + amount: (json['amount'] as num).toDouble(), + ); + } +} + +class LnurlpQuoteDto { + final String id; + final DateTime expiration; + + const LnurlpQuoteDto({required this.id, required this.expiration}); + + factory LnurlpQuoteDto.fromJson(Map json) { + return LnurlpQuoteDto( + id: json['id'] as String, + expiration: DateTime.parse(json['expiration'] as String), + ); + } +} + +class LnurlpTransferAmountDto { + final String method; + final List assets; + + const LnurlpTransferAmountDto({required this.method, required this.assets}); + + factory LnurlpTransferAmountDto.fromJson(Map json) { + final assets = (json['assets'] as List?) ?? const []; + return LnurlpTransferAmountDto( + method: json['method'] as String, + assets: assets + .map((e) => LnurlpTransferAssetDto.fromJson(e as Map)) + .toList(), + ); + } +} + +class LnurlpTransferAssetDto { + final String asset; + final double amount; + + const LnurlpTransferAssetDto({required this.asset, required this.amount}); + + factory LnurlpTransferAssetDto.fromJson(Map json) { + return LnurlpTransferAssetDto( + asset: json['asset'] as String, + amount: (json['amount'] as num).toDouble(), + ); + } +} diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart new file mode 100644 index 00000000..7393a562 --- /dev/null +++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart @@ -0,0 +1,14 @@ +/// Request body for `PUT /v1/realunit/pay/unsigned-transaction`. References the +/// scanned payment link and its active quote so the backend resolves recipient +/// and exact ZCHF amount. +class RealUnitOcpPayDto { + final String paymentLinkId; + final String quoteId; + + const RealUnitOcpPayDto({required this.paymentLinkId, required this.quoteId}); + + Map toJson() => { + 'paymentLinkId': paymentLinkId, + 'quoteId': quoteId, + }; +} diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart new file mode 100644 index 00000000..1a90a2b4 --- /dev/null +++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart @@ -0,0 +1,11 @@ +/// Response of `PUT /v1/realunit/pay/submit` — the blockchain transaction id of +/// the submitted ZCHF payment. +class RealUnitOcpPayResultDto { + final String txId; + + const RealUnitOcpPayResultDto({required this.txId}); + + factory RealUnitOcpPayResultDto.fromJson(Map json) { + return RealUnitOcpPayResultDto(txId: json['txId'] as String); + } +} diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart new file mode 100644 index 00000000..b03f4e0a --- /dev/null +++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart @@ -0,0 +1,41 @@ +/// Mirrors the backend `PaymentLinkPaymentStatus` enum 1:1 (type-safe DTO +/// mirroring, not local business logic). The backend remains the authority on +/// the payment status; the app renders it and uses [isTerminal] / [isCompleted] +/// only to decide when to stop polling and which UI state to show. +enum OcpPaymentStatus { + pending('Pending'), + completed('Completed'), + cancelled('Cancelled'), + expired('Expired'), + unknown('') + ; + + final String value; + + const OcpPaymentStatus(this.value); + + static OcpPaymentStatus fromValue(String value) { + return OcpPaymentStatus.values.firstWhere( + (s) => s.value == value, + orElse: () => OcpPaymentStatus.unknown, + ); + } + + /// Polling stops once the payment reaches a final state. + bool get isTerminal => this == completed || this == cancelled || this == expired; + + bool get isCompleted => this == completed; +} + +/// Response of `GET /v1/realunit/pay/:id/status`. +class RealUnitOcpPayStatusDto { + final OcpPaymentStatus status; + + const RealUnitOcpPayStatusDto({required this.status}); + + factory RealUnitOcpPayStatusDto.fromJson(Map json) { + return RealUnitOcpPayStatusDto( + status: OcpPaymentStatus.fromValue(json['status'] as String), + ); + } +} diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart new file mode 100644 index 00000000..acd5740b --- /dev/null +++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart @@ -0,0 +1,30 @@ +/// Request body for `PUT /v1/realunit/pay/submit`. The signed-tx envelope +/// (`unsignedTx` + `r`/`s`/`v`) mirrors the sell/swap broadcast shape, plus the +/// payment-link/quote references so the backend forwards the hex into the +/// lnurlp settlement path. +class RealUnitOcpPaySubmitDto { + final String unsignedTx; + final String r; + final String s; + final int v; + final String paymentLinkId; + final String quoteId; + + const RealUnitOcpPaySubmitDto({ + required this.unsignedTx, + required this.r, + required this.s, + required this.v, + required this.paymentLinkId, + required this.quoteId, + }); + + Map toJson() => { + 'unsignedTx': unsignedTx, + 'r': r, + 's': s, + 'v': v, + 'paymentLinkId': paymentLinkId, + 'quoteId': quoteId, + }; +} diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart new file mode 100644 index 00000000..e887938f --- /dev/null +++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart @@ -0,0 +1,28 @@ +/// Response of `PUT /v1/realunit/pay/unsigned-transaction` — the serialized +/// unsigned EIP-1559 ZCHF transfer transaction to the OCP recipient, plus the +/// metadata the app shows / can sanity-check before signing. +class RealUnitOcpPayUnsignedTransactionDto { + final String unsignedTx; + final String tokenAddress; + final String recipient; + final String amountWei; + final int chainId; + + const RealUnitOcpPayUnsignedTransactionDto({ + required this.unsignedTx, + required this.tokenAddress, + required this.recipient, + required this.amountWei, + required this.chainId, + }); + + factory RealUnitOcpPayUnsignedTransactionDto.fromJson(Map json) { + return RealUnitOcpPayUnsignedTransactionDto( + unsignedTx: json['unsignedTx'] as String, + tokenAddress: json['tokenAddress'] as String, + recipient: json['recipient'] as String, + amountWei: json['amountWei'] as String, + chainId: json['chainId'] as int, + ); + } +} diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart new file mode 100644 index 00000000..33ba336c --- /dev/null +++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart @@ -0,0 +1,25 @@ +/// Request body for `PUT /v1/realunit/swap`. The backend enforces the `amount` +/// XOR `targetAmount` rule; the app sends exactly one. `amount` is in REALU +/// shares, `targetAmount` is in ZCHF. IBAN-free by design (proceeds stay in the +/// user wallet). +class RealUnitSwapDto { + /// Amount of REALU shares to swap. + final int? amount; + + /// Target amount in ZCHF (alternative to [amount]). + final double? targetAmount; + + // Part of the amount-XOR-targetAmount contract. The OCP pay flow always sizes + // the swap by ZCHF target (fromTargetAmount); this constructor is exercised + // via toJson in unit tests but const-constructed there, so its body never + // registers a runtime line hit. + const RealUnitSwapDto.fromAmount(int this.amount) // coverage:ignore-line + : targetAmount = null; + + const RealUnitSwapDto.fromTargetAmount(double this.targetAmount) : amount = null; + + Map toJson() => { + if (amount != null) 'amount': amount, + if (targetAmount != null) 'targetAmount': targetAmount, + }; +} diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart new file mode 100644 index 00000000..54dee50d --- /dev/null +++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart @@ -0,0 +1,58 @@ +/// Response of `PUT /v1/realunit/swap` — the REALU → ZCHF swap quote. The +/// backend is the authority on validity, limits, fees and the ZCHF estimate; +/// the app renders these fields and never recomputes them. +class RealUnitSwapPaymentInfoDto { + final int id; + final String uid; + final int routeId; + final DateTime timestamp; + final double amount; + final double estimatedAmount; + final String targetAsset; + final double minVolume; + final double maxVolume; + final double minVolumeTarget; + final double maxVolumeTarget; + final double ethBalance; + final double requiredGasEth; + final bool isValid; + final String? error; + + const RealUnitSwapPaymentInfoDto({ + required this.id, + required this.uid, + required this.routeId, + required this.timestamp, + required this.amount, + required this.estimatedAmount, + required this.targetAsset, + required this.minVolume, + required this.maxVolume, + required this.minVolumeTarget, + required this.maxVolumeTarget, + required this.ethBalance, + required this.requiredGasEth, + required this.isValid, + this.error, + }); + + factory RealUnitSwapPaymentInfoDto.fromJson(Map json) { + return RealUnitSwapPaymentInfoDto( + id: json['id'] as int, + uid: json['uid'] as String, + routeId: json['routeId'] as int, + timestamp: DateTime.parse(json['timestamp'] as String), + amount: (json['amount'] as num).toDouble(), + estimatedAmount: (json['estimatedAmount'] as num).toDouble(), + targetAsset: json['targetAsset'] as String, + minVolume: (json['minVolume'] as num).toDouble(), + maxVolume: (json['maxVolume'] as num).toDouble(), + minVolumeTarget: (json['minVolumeTarget'] as num).toDouble(), + maxVolumeTarget: (json['maxVolumeTarget'] as num).toDouble(), + ethBalance: (json['ethBalance'] as num).toDouble(), + requiredGasEth: (json['requiredGasEth'] as num).toDouble(), + isValid: json['isValid'] as bool, + error: json['error'] as String?, + ); + } +} diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart new file mode 100644 index 00000000..5e0fe5b6 --- /dev/null +++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart @@ -0,0 +1,12 @@ +/// Response of `PUT /v1/realunit/swap/:id/unsigned-transaction` — the +/// serialized unsigned EIP-1559 REALU `transferAndCall` swap transaction hex +/// (no deposit sweep; ZCHF lands in the user wallet). +class RealUnitSwapUnsignedTransactionDto { + final String swap; + + const RealUnitSwapUnsignedTransactionDto({required this.swap}); + + factory RealUnitSwapUnsignedTransactionDto.fromJson(Map json) { + return RealUnitSwapUnsignedTransactionDto(swap: json['swap'] as String); + } +} diff --git a/lib/packages/service/dfx/models/payment/pay/swap_payment_info.dart b/lib/packages/service/dfx/models/payment/pay/swap_payment_info.dart new file mode 100644 index 00000000..b1704adc --- /dev/null +++ b/lib/packages/service/dfx/models/payment/pay/swap_payment_info.dart @@ -0,0 +1,50 @@ +import 'package:equatable/equatable.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart'; + +/// Domain model for an IBAN-free REALU → ZCHF swap quote. The backend decides +/// validity, limits and the ZCHF estimate; this model only carries those fields +/// for the flow's cubits to render and to drive the ETH-balance / swap steps. +class SwapPaymentInfo extends Equatable { + final int id; + final double amount; + final double estimatedAmount; + final String targetAsset; + final double ethBalance; + final double requiredGasEth; + final bool isValid; + final String? error; + + const SwapPaymentInfo({ + required this.id, + required this.amount, + required this.estimatedAmount, + required this.targetAsset, + required this.ethBalance, + required this.requiredGasEth, + required this.isValid, + this.error, + }); + + factory SwapPaymentInfo.fromDto(RealUnitSwapPaymentInfoDto dto) => SwapPaymentInfo( + id: dto.id, + amount: dto.amount, + estimatedAmount: dto.estimatedAmount, + targetAsset: dto.targetAsset, + ethBalance: dto.ethBalance, + requiredGasEth: dto.requiredGasEth, + isValid: dto.isValid, + error: dto.error, + ); + + @override + List get props => [ + id, + amount, + estimatedAmount, + targetAsset, + ethBalance, + requiredGasEth, + isValid, + error, + ]; +} diff --git a/lib/packages/service/dfx/real_unit_pay_service.dart b/lib/packages/service/dfx/real_unit_pay_service.dart new file mode 100644 index 00000000..cee92568 --- /dev/null +++ b/lib/packages/service/dfx/real_unit_pay_service.dart @@ -0,0 +1,166 @@ +import 'dart:convert'; + +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.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_ocp_pay_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_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/dto/real_unit_swap_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_request_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_response_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart'; + +/// Backend client for the Open CryptoPay pay flow (DFXswiss/api #3819, all under +/// `/v1/realunit/...`). Subclasses [DFXAuthService] for the JWT handshake + +/// retry-on-401 the sell flow already uses; the public lnurlp read is the only +/// unauthenticated call. +class RealUnitPayService extends DFXAuthService { + static const _lnurlpPath = '/v1/lnurlp'; + static const _swapPath = '/v1/realunit/swap'; + static String _swapUnsignedTxPath(int id) => '/v1/realunit/swap/$id/unsigned-transaction'; + static String _swapBroadcastPath(int id) => '/v1/realunit/swap/$id/broadcast'; + static const _payUnsignedTxPath = '/v1/realunit/pay/unsigned-transaction'; + static const _paySubmitPath = '/v1/realunit/pay/submit'; + static String _payStatusPath(String id) => '/v1/realunit/pay/$id/status'; + + static const _httpTimeout = Duration(seconds: 20); + + RealUnitPayService(super.appStore, super.walletService); + + /// Public OCP payment-link read (no auth). Returns the requested fiat amount, + /// the active quote (id + expiration) and the per-method transfer amounts. + Future getPaymentDetails(String id) async { + final uri = buildUri(host, '$_lnurlpPath/$id'); + final response = await appStore.httpClient + .get(uri, headers: {'accept': 'application/json'}) + .timeout(_httpTimeout); + + if (response.statusCode != 200) { + _throwApi(response.body, response.statusCode); + } + return LnurlpPaymentDto.fromJson(jsonDecode(response.body) as Map); + } + + // --- Swap (REALU → ZCHF, proceeds stay in the user wallet) --- + + Future getSwapPaymentInfo(RealUnitSwapDto dto) async { + final uri = buildUri(host, _swapPath); + final response = await authenticatedPut( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(dto.toJson()), + ); + + if (response.statusCode != 200 && response.statusCode != 201) { + _throwApi(response.body, response.statusCode); + } + final responseDto = RealUnitSwapPaymentInfoDto.fromJson( + jsonDecode(response.body) as Map, + ); + return SwapPaymentInfo.fromDto(responseDto); + } + + Future createSwapUnsignedTransaction(int id) async { + final uri = buildUri(host, _swapUnsignedTxPath(id)); + final response = await authenticatedPut( + uri, + headers: {'Content-Type': 'application/json'}, + ); + + if (response.statusCode != 200 && response.statusCode != 201) { + _throwApi(response.body, response.statusCode); + } + return RealUnitSwapUnsignedTransactionDto.fromJson( + jsonDecode(response.body) as Map, + ); + } + + Future broadcastSwapTransaction(int id, BroadcastTransactionRequestDto dto) async { + final uri = buildUri(host, _swapBroadcastPath(id)); + final response = await authenticatedPut( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(dto.toJson()), + ); + + if (response.statusCode != 200 && response.statusCode != 201) { + _throwApi(response.body, response.statusCode); + } + return BroadcastTransactionResponseDto.fromJson( + jsonDecode(response.body) as Map, + ).txHash; + } + + // --- OCP pay (settle a ZCHF payment-link quote via the lnurlp flow) --- + + Future createPayUnsignedTransaction( + RealUnitOcpPayDto dto, + ) async { + _assertPaySupported(); + final uri = buildUri(host, _payUnsignedTxPath); + final response = await authenticatedPut( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(dto.toJson()), + ); + + if (response.statusCode != 200 && response.statusCode != 201) { + _throwApi(response.body, response.statusCode); + } + return RealUnitOcpPayUnsignedTransactionDto.fromJson( + jsonDecode(response.body) as Map, + ); + } + + Future submitPay(RealUnitOcpPaySubmitDto dto) async { + _assertPaySupported(); + final uri = buildUri(host, _paySubmitPath); + final response = await authenticatedPut( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(dto.toJson()), + ); + + if (response.statusCode != 200 && response.statusCode != 201) { + _throwApi(response.body, response.statusCode); + } + return RealUnitOcpPayResultDto.fromJson( + jsonDecode(response.body) as Map, + ).txId; + } + + Future getPayStatus(String id) async { + final uri = buildUri(host, _payStatusPath(id)); + final response = await authenticatedGet(uri); + + if (response.statusCode != 200) { + _throwApi(response.body, response.statusCode); + } + return RealUnitOcpPayStatusDto.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// The OCP payment-link engine settles on mainnet only. On testnet the + /// pay/* endpoints fail fast server-side with a 400; we mirror that as a + /// typed, surfaced failure before the round-trip rather than parsing the + /// backend error body. This is a backend-environment capability gate keyed + /// off [ApiConfig], not local business logic. + void _assertPaySupported() { + if (appStore.apiConfig.networkMode.isTestnet) { + throw const PayUnsupportedEnvironmentException(); + } + } + + Never _throwApi(String body, int statusCode) { + final errorJson = jsonDecode(body) as Map; + throw ApiException.fromJson(errorJson, httpStatusCode: statusCode); + } +} diff --git a/lib/screens/dashboard/widgets/sections/dashboard_actions.dart b/lib/screens/dashboard/widgets/sections/dashboard_actions.dart index 9b68eed6..2fb38a1f 100644 --- a/lib/screens/dashboard/widgets/sections/dashboard_actions.dart +++ b/lib/screens/dashboard/widgets/sections/dashboard_actions.dart @@ -13,23 +13,38 @@ class DashboardActions extends StatelessWidget { return Row( spacing: 10, children: [ - ActionButton( - icon: Icon( - Icons.add_circle_rounded, - color: RealUnitColors.basic.white, - size: 20, + Expanded( + child: ActionButton( + icon: Icon( + Icons.add_circle_rounded, + color: RealUnitColors.basic.white, + size: 20, + ), + label: S.of(context).buy, + onPressed: () => context.pushNamed(AppRoutes.buy), ), - label: S.of(context).buy, - onPressed: () => context.pushNamed(AppRoutes.buy), ), - ActionButton( - icon: Icon( - Icons.do_not_disturb_on_rounded, - color: RealUnitColors.basic.white, - size: 20, + Expanded( + child: ActionButton( + icon: Icon( + Icons.do_not_disturb_on_rounded, + color: RealUnitColors.basic.white, + size: 20, + ), + label: S.of(context).sell, + onPressed: () => context.pushNamed(AppRoutes.sell), + ), + ), + Expanded( + child: ActionButton( + icon: Icon( + Icons.qr_code_scanner_rounded, + color: RealUnitColors.basic.white, + size: 20, + ), + label: S.of(context).pay, + onPressed: () => context.pushNamed(AppRoutes.pay), ), - label: S.of(context).sell, - onPressed: () => context.pushNamed(AppRoutes.sell), ), ], ); diff --git a/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart new file mode 100644 index 00000000..f761b0fe --- /dev/null +++ b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart @@ -0,0 +1,263 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:convert/convert.dart' as convert; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.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/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_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/models/payment/sell/dto/broadcast_transaction_request_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:web3dart/crypto.dart'; + +part 'pay_process_state.dart'; + +/// Orchestrates the on-chain half of the OCP pay flow after the user confirms a +/// quote: check ETH gas → swap REALU→ZCHF (sign + broadcast) → re-fetch the OCP +/// quote (fresh quoteId, guards expiry between swap and pay) → pay (sign + +/// submit) → poll status until terminal. +/// +/// Signing uses the unified raw-payload path (`signToSignature` → r/s/v) for +/// BOTH software and BitBox wallets — the backend returns the unsigned txs, the +/// app signs them the same way regardless of wallet mode. The flow is NOT +/// branched on `walletType`; only the genuine capability gap (a debug wallet +/// that cannot sign) is gated, surfacing [PaySignaturePending] → +/// [PaySignatureUnsupportedException]. +class PayProcessCubit extends Cubit { + final RealUnitPayService _payService; + final DfxFaucetService _faucetService; + final DfxBlockchainApiService _blockchainService; + final WalletService _walletService; + final AppStore _appStore; + + final String _paymentLinkId; + final double _zchfNeeded; + + SwapPaymentInfo? _swap; + + Timer? _ethPollingTimer; + Timer? _statusPollingTimer; + + /// Small buffer over the OCP ZCHF amount so the swap target covers the OCP + /// fee/min-fee and price slippage between quoting and settling. + static const _slippageBuffer = 1.01; + + static const _ethPollInterval = Duration(seconds: 5); + static const _statusPollInterval = Duration(seconds: 3); + + PayProcessCubit({ + required RealUnitPayService payService, + required DfxFaucetService faucetService, + required DfxBlockchainApiService blockchainService, + required WalletService walletService, + required AppStore appStore, + required String paymentLinkId, + required double zchfNeeded, + }) : _payService = payService, + _faucetService = faucetService, + _blockchainService = blockchainService, + _walletService = walletService, + _appStore = appStore, + _paymentLinkId = paymentLinkId, + _zchfNeeded = zchfNeeded, + super(const PayProcessInitial()); + + /// Entry point — called by the view once the user confirms the quote. + Future start() async { + if (_appStore.wallet.walletType == WalletType.debug) { + emit(const PayProcessFailure(PayProcessFailureReason.signatureUnsupported)); + return; + } + await _requestSwapQuote(); + } + + Future _requestSwapQuote() async { + try { + emit(const PayProcessPreparingSwap()); + final swap = await _payService.getSwapPaymentInfo( + RealUnitSwapDto.fromTargetAmount(_zchfNeeded * _slippageBuffer), + ); + _swap = swap; + + // 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)); + return; + } + + await _checkEthBalance(swap); + } catch (e) { + emit(PayProcessFailure(PayProcessFailureReason.generic, message: e.toString())); + } + } + + Future _checkEthBalance(SwapPaymentInfo swap) async { + if (swap.ethBalance >= swap.requiredGasEth) { + await _executeSwap(); + return; + } + await _requestFaucet(swap); + } + + Future _requestFaucet(SwapPaymentInfo swap) async { + try { + emit(const PayProcessWaitingForEth()); + await _faucetService.requestFaucet(); + _startEthPolling(swap); + } catch (e) { + emit(PayProcessFailure(PayProcessFailureReason.insufficientEth, message: e.toString())); + } + } + + void _startEthPolling(SwapPaymentInfo swap) { + _ethPollingTimer?.cancel(); + _ethPollingTimer = Timer.periodic(_ethPollInterval, (_) async { + try { + final balance = await _blockchainService.getEthBalance(_appStore.primaryAddress); + if (balance >= swap.requiredGasEth) { + _ethPollingTimer?.cancel(); + await _executeSwap(); + } + } catch (_) { + // keep polling on transient errors + } + }); + } + + Future _executeSwap() async { + final swap = _swap; + if (swap == null) return; + try { + emit(const PayProcessSwapping()); + final unsigned = await _payService.createSwapUnsignedTransaction(swap.id); + final signed = await _signTransaction(unsigned.swap); + await _payService.broadcastSwapTransaction(swap.id, signed); + await _refreshQuoteAndPay(); + } on PaySignatureUnsupportedException { + emit(const PayProcessFailure(PayProcessFailureReason.signatureUnsupported)); + } on BitboxNotConnectedException { + emit(const PayProcessFailure(PayProcessFailureReason.bitboxRequired)); + } catch (e) { + emit(PayProcessFailure(PayProcessFailureReason.generic, message: e.toString())); + } + } + + /// Re-reads the OCP quote so the pay step uses a fresh quoteId — the swap may + /// have taken longer than the original quote's validity window. + Future _refreshQuoteAndPay() async { + try { + emit(const PayProcessRefreshingQuote()); + final details = await _payService.getPaymentDetails(_paymentLinkId); + if (details.quote.expiration.isBefore(DateTime.now())) { + emit(const PayProcessFailure(PayProcessFailureReason.quoteExpired)); + return; + } + await _executePay(details.quote.id); + } catch (e) { + emit(PayProcessFailure(PayProcessFailureReason.quoteExpired, message: e.toString())); + } + } + + Future _executePay(String quoteId) async { + try { + emit(const PayProcessPaying()); + final RealUnitOcpPayUnsignedTransactionDto unsigned = await _payService + .createPayUnsignedTransaction( + RealUnitOcpPayDto(paymentLinkId: _paymentLinkId, quoteId: quoteId), + ); + final signed = await _signTransaction(unsigned.unsignedTx); + final txId = await _payService.submitPay( + RealUnitOcpPaySubmitDto( + unsignedTx: signed.unsignedTx, + r: signed.r, + s: signed.s, + v: signed.v, + paymentLinkId: _paymentLinkId, + quoteId: quoteId, + ), + ); + emit(PayProcessAwaitingSettlement(txId)); + _startStatusPolling(); + } on PayUnsupportedEnvironmentException { + emit(const PayProcessFailure(PayProcessFailureReason.payUnsupportedEnvironment)); + } on PaySignatureUnsupportedException { + emit(const PayProcessFailure(PayProcessFailureReason.signatureUnsupported)); + } on BitboxNotConnectedException { + emit(const PayProcessFailure(PayProcessFailureReason.bitboxRequired)); + } catch (e) { + emit(PayProcessFailure(PayProcessFailureReason.payFailed, message: e.toString())); + } + } + + void _startStatusPolling() { + _statusPollingTimer?.cancel(); + _statusPollingTimer = Timer.periodic(_statusPollInterval, (_) async { + try { + final status = await _payService.getPayStatus(_paymentLinkId); + if (!status.status.isTerminal) return; + _statusPollingTimer?.cancel(); + if (status.status.isCompleted) { + emit(const PayProcessSuccess()); + } else { + emit(const PayProcessFailure(PayProcessFailureReason.payFailed)); + } + } catch (_) { + // keep polling on transient errors + } + }); + } + + /// Signs a serialized unsigned EIP-1559 tx with the active wallet credentials + /// and returns the broadcast envelope (`unsignedTx` + r/s/v). Works for + /// software and BitBox; a debug wallet's `signToSignature` throws + /// [UnsupportedError], normalised here to [PaySignatureUnsupportedException]. + Future _signTransaction(String rawTransaction) async { + await _walletService.ensureCurrentWalletUnlocked(); + try { + final credentials = _appStore.wallet.currentAccount.primaryAddress; + final payload = Uint8List.fromList( + convert.hex.decode( + rawTransaction.startsWith('0x') ? rawTransaction.substring(2) : rawTransaction, + ), + ); + final MsgSignature sig; + try { + sig = await credentials.signToSignature( + payload, + chainId: _appStore.apiConfig.asset.chainId, + isEIP1559: true, + ); + } on UnsupportedError { + throw const PaySignatureUnsupportedException(); + } + final r = sig.r.toRadixString(16).padLeft(64, '0'); + final s = sig.s.toRadixString(16).padLeft(64, '0'); + return BroadcastTransactionRequestDto( + unsignedTx: rawTransaction, + r: '0x$r', + s: '0x$s', + v: sig.v, + ); + } finally { + await _walletService.lockCurrentWallet(); + } + } + + @override + Future close() { + _ethPollingTimer?.cancel(); + _statusPollingTimer?.cancel(); + return super.close(); + } +} diff --git a/lib/screens/pay/cubits/pay_process/pay_process_state.dart b/lib/screens/pay/cubits/pay_process/pay_process_state.dart new file mode 100644 index 00000000..6973324e --- /dev/null +++ b/lib/screens/pay/cubits/pay_process/pay_process_state.dart @@ -0,0 +1,89 @@ +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, + + /// The OCP quote expired between the swap and the pay step. + quoteExpired, + + /// Open CryptoPay settlement failed (rejected by the engine or a terminal + /// non-completed status). + payFailed, + + /// Open CryptoPay settlement is unavailable on the current backend + /// environment (mainnet-only; fails fast on testnet). + payUnsupportedEnvironment, + + /// The active wallet mode cannot sign transactions (debug wallet). + signatureUnsupported, + + /// A BitBox is required but not connected. + bitboxRequired, + + /// Any other unexpected error. + generic, +} + +sealed class PayProcessState extends Equatable { + const PayProcessState(); + + @override + List get props => []; +} + +class PayProcessInitial extends PayProcessState { + const PayProcessInitial(); +} + +class PayProcessPreparingSwap extends PayProcessState { + const PayProcessPreparingSwap(); +} + +class PayProcessWaitingForEth extends PayProcessState { + const PayProcessWaitingForEth(); +} + +class PayProcessSwapping extends PayProcessState { + const PayProcessSwapping(); +} + +class PayProcessRefreshingQuote extends PayProcessState { + const PayProcessRefreshingQuote(); +} + +class PayProcessPaying extends PayProcessState { + const PayProcessPaying(); +} + +/// Pay tx submitted; polling `/pay/:id/status` until it settles. +class PayProcessAwaitingSettlement extends PayProcessState { + final String txId; + + const PayProcessAwaitingSettlement(this.txId); + + @override + List get props => [txId]; +} + +class PayProcessSuccess extends PayProcessState { + const PayProcessSuccess(); +} + +class PayProcessFailure extends PayProcessState { + final PayProcessFailureReason reason; + + /// Diagnostic detail for logs — not the user-facing copy. + final String? message; + + const PayProcessFailure(this.reason, {this.message}); + + @override + List get props => [reason, message]; +} diff --git a/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart new file mode 100644 index 00000000..c4f1a848 --- /dev/null +++ b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart @@ -0,0 +1,60 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; + +part 'pay_quote_state.dart'; + +/// Reads the public OCP payment-link quote (`GET /v1/lnurlp/:id`) and surfaces +/// 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. +class PayQuoteCubit extends Cubit { + final RealUnitPayService _payService; + final String _paymentLinkId; + + PayQuoteCubit(this._payService, this._paymentLinkId) : super(const PayQuoteLoading()); + + Future load() async { + emit(const PayQuoteLoading()); + try { + final details = await _payService.getPaymentDetails(_paymentLinkId); + + if (details.quote.expiration.isBefore(DateTime.now())) { + emit(const PayQuoteExpired()); + return; + } + + final zchfAmount = _zchfTransferAmount(details); + if (zchfAmount == null) { + emit(const PayQuoteUnavailable()); + return; + } + + emit( + PayQuoteReady( + paymentLinkId: _paymentLinkId, + quoteId: details.quote.id, + fiatAsset: details.requestedAmount.asset, + fiatAmount: details.requestedAmount.amount, + zchfAmount: zchfAmount, + ), + ); + } catch (e) { + emit(PayQuoteError(e.toString())); + } + } + + /// 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) { + for (final transfer in details.transferAmounts) { + if (transfer.method.toLowerCase() != 'ethereum') continue; + for (final asset in transfer.assets) { + if (asset.asset.toUpperCase() == 'ZCHF') return asset.amount; + } + } + return null; + } +} diff --git a/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart b/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart new file mode 100644 index 00000000..0984f573 --- /dev/null +++ b/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart @@ -0,0 +1,50 @@ +part of 'pay_quote_cubit.dart'; + +sealed class PayQuoteState extends Equatable { + const PayQuoteState(); + + @override + List get props => []; +} + +class PayQuoteLoading extends PayQuoteState { + const PayQuoteLoading(); +} + +class PayQuoteReady extends PayQuoteState { + final String paymentLinkId; + final String quoteId; + final String fiatAsset; + final double fiatAmount; + final double zchfAmount; + + const PayQuoteReady({ + required this.paymentLinkId, + required this.quoteId, + required this.fiatAsset, + required this.fiatAmount, + required this.zchfAmount, + }); + + @override + List get props => [paymentLinkId, quoteId, fiatAsset, fiatAmount, zchfAmount]; +} + +/// The quote attached to the scanned link has expired — the user must re-scan. +class PayQuoteExpired extends PayQuoteState { + const PayQuoteExpired(); +} + +/// The payment link offers no Ethereum/ZCHF transfer method. +class PayQuoteUnavailable extends PayQuoteState { + const PayQuoteUnavailable(); +} + +class PayQuoteError extends PayQuoteState { + final String message; + + const PayQuoteError(this.message); + + @override + List get props => [message]; +} diff --git a/lib/screens/pay/cubits/pay_scan/pay_scan_cubit.dart b/lib/screens/pay/cubits/pay_scan/pay_scan_cubit.dart new file mode 100644 index 00000000..d40c3cf6 --- /dev/null +++ b/lib/screens/pay/cubits/pay_scan/pay_scan_cubit.dart @@ -0,0 +1,28 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/lnurl_decoder.dart'; + +part 'pay_scan_state.dart'; + +/// Decodes a scanned OCP QR into a DFX payment-link id + lnurlp URL. Pure +/// decode — no network. The view advances to the quote step on +/// [PayScanDecoded]; a malformed code keeps the scanner open with an error. +class PayScanCubit extends Cubit { + PayScanCubit() : super(const PayScanScanning()); + + /// Called once per detected barcode. Guards against re-entry after a + /// successful decode so a continuously-detecting scanner does not re-emit. + void onCodeDetected(String raw) { + if (state is PayScanDecoded) return; + try { + final decoded = LnurlDecoder.decode(raw); + emit(PayScanDecoded(decoded)); + } on InvalidPaymentLinkException catch (e) { + emit(PayScanInvalid(e.reason)); + } + } + + /// Dismiss an error and resume scanning. + void reset() => emit(const PayScanScanning()); +} diff --git a/lib/screens/pay/cubits/pay_scan/pay_scan_state.dart b/lib/screens/pay/cubits/pay_scan/pay_scan_state.dart new file mode 100644 index 00000000..f3552e5f --- /dev/null +++ b/lib/screens/pay/cubits/pay_scan/pay_scan_state.dart @@ -0,0 +1,30 @@ +part of 'pay_scan_cubit.dart'; + +sealed class PayScanState extends Equatable { + const PayScanState(); + + @override + List get props => []; +} + +class PayScanScanning extends PayScanState { + const PayScanScanning(); +} + +class PayScanInvalid extends PayScanState { + final String reason; + + const PayScanInvalid(this.reason); + + @override + List get props => [reason]; +} + +class PayScanDecoded extends PayScanState { + final DecodedPaymentLink link; + + const PayScanDecoded(this.link); + + @override + List get props => [link.id, link.lnurlpUrl]; +} diff --git a/lib/screens/pay/pay_process_page.dart b/lib/screens/pay/pay_process_page.dart new file mode 100644 index 00000000..2e56b990 --- /dev/null +++ b/lib/screens/pay/pay_process_page.dart @@ -0,0 +1,147 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/generated/i18n.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/wallet_service.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/styles/colors.dart'; + +class PayProcessPage extends StatelessWidget { + final String paymentLinkId; + final double zchfNeeded; + + const PayProcessPage({ + super.key, + required this.paymentLinkId, + required this.zchfNeeded, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => PayProcessCubit( + payService: getIt(), + faucetService: getIt(), + blockchainService: getIt(), + walletService: getIt(), + appStore: getIt(), + paymentLinkId: paymentLinkId, + zchfNeeded: zchfNeeded, + )..start(), + child: const PayProcessView(), + ); + } +} + +class PayProcessView extends StatelessWidget { + const PayProcessView({super.key}); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) async { + if (state is PayProcessSuccess) { + await _showResultSheet( + context, + icon: Icons.check_circle_rounded, + title: S.of(context).paySuccess, + description: S.of(context).paySuccessDescription, + ); + } else if (state is PayProcessFailure) { + await _showResultSheet( + context, + icon: Icons.error_rounded, + title: S.of(context).payFailureTitle, + description: _failureMessage(context, state.reason), + ); + } + }, + builder: (context, state) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).pay)), + body: SafeArea( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 24, + children: [ + const CupertinoActivityIndicator(radius: 16), + Text( + _progressLabel(context, state), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ), + ); + }, + ); + } + + String _progressLabel(BuildContext context, PayProcessState state) => switch (state) { + PayProcessInitial() || PayProcessPreparingSwap() => S.of(context).payPreparingSwap, + PayProcessWaitingForEth() => S.of(context).payWaitingForEth, + PayProcessSwapping() => S.of(context).paySwapping, + PayProcessRefreshingQuote() => S.of(context).payRefreshingQuote, + PayProcessPaying() => S.of(context).payPaying, + PayProcessAwaitingSettlement() => S.of(context).payAwaitingSettlement, + PayProcessSuccess() => S.of(context).paySuccess, + PayProcessFailure() => S.of(context).payFailureTitle, + }; + + String _failureMessage(BuildContext context, PayProcessFailureReason reason) => switch (reason) { + PayProcessFailureReason.insufficientZchf => S.of(context).payFailureInsufficientZchf, + PayProcessFailureReason.insufficientEth => S.of(context).payFailureInsufficientEth, + PayProcessFailureReason.quoteExpired => S.of(context).payFailureQuoteExpired, + PayProcessFailureReason.payFailed => S.of(context).payFailurePayFailed, + PayProcessFailureReason.payUnsupportedEnvironment => + S.of(context).payFailureUnsupportedEnvironment, + PayProcessFailureReason.signatureUnsupported => S.of(context).payFailureSignatureUnsupported, + PayProcessFailureReason.bitboxRequired => S.of(context).payFailureBitboxRequired, + PayProcessFailureReason.generic => S.of(context).payFailureGeneric, + }; + + Future _showResultSheet( + BuildContext context, { + required IconData icon, + required String title, + required String description, + }) async { + await showModalBottomSheet( + context: context, + isDismissible: false, + builder: (_) => SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 24, + children: [ + Icon(icon, color: RealUnitColors.realUnitBlue, size: 64), + Text(title, style: Theme.of(context).textTheme.headlineMedium), + Text( + description, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(S.of(context).close), + ), + ], + ), + ), + ), + ); + if (context.mounted) Navigator.of(context).pop(); + } +} diff --git a/lib/screens/pay/pay_quote_page.dart b/lib/screens/pay/pay_quote_page.dart new file mode 100644 index 00000000..fc77fed4 --- /dev/null +++ b/lib/screens/pay/pay_quote_page.dart @@ -0,0 +1,137 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_process_page.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/styles/colors.dart'; + +class PayQuotePage extends StatelessWidget { + final String paymentLinkId; + + const PayQuotePage({super.key, required this.paymentLinkId}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => PayQuoteCubit(getIt(), paymentLinkId)..load(), + child: const PayQuoteView(), + ); + } +} + +class PayQuoteView extends StatelessWidget { + const PayQuoteView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).payQuoteTitle)), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: BlocBuilder( + builder: (context, state) => switch (state) { + PayQuoteLoading() => const Center(child: CupertinoActivityIndicator()), + PayQuoteReady() => _PayQuoteReadyView(state: state), + PayQuoteExpired() => _PayQuoteMessage(message: S.of(context).payFailureQuoteExpired), + PayQuoteUnavailable() => _PayQuoteMessage(message: S.of(context).payQuoteUnavailable), + PayQuoteError() => _PayQuoteMessage(message: S.of(context).payFailureGeneric), + }, + ), + ), + ), + ); + } +} + +class _PayQuoteReadyView extends StatelessWidget { + final PayQuoteReady state; + + const _PayQuoteReadyView({required this.state}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 24, + children: [ + const Spacer(), + Text( + S + .of(context) + .payQuoteSummary( + state.fiatAmount.toStringAsFixed(2), + state.fiatAsset, + ), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium, + ), + _AmountRow( + label: S.of(context).payQuoteRequested, + value: '${state.fiatAmount.toStringAsFixed(2)} ${state.fiatAsset}', + ), + _AmountRow( + label: S.of(context).payQuoteZchfNeeded, + value: '${state.zchfAmount.toStringAsFixed(2)} ZCHF', + ), + const Spacer(), + FilledButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PayProcessPage( + paymentLinkId: state.paymentLinkId, + zchfNeeded: state.zchfAmount, + ), + ), + ), + child: Text(S.of(context).payConfirmButton), + ), + ], + ); + } +} + +class _AmountRow extends StatelessWidget { + final String label; + final String value; + + const _AmountRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + Text(value, style: Theme.of(context).textTheme.bodyLarge), + ], + ); + } +} + +class _PayQuoteMessage extends StatelessWidget { + final String message; + + const _PayQuoteMessage({required this.message}); + + @override + Widget build(BuildContext context) { + return Center( + child: Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + ); + } +} diff --git a/lib/screens/pay/pay_scan_page.dart b/lib/screens/pay/pay_scan_page.dart new file mode 100644 index 00000000..302568c3 --- /dev/null +++ b/lib/screens/pay/pay_scan_page.dart @@ -0,0 +1,65 @@ +// @no-integration-test: the QR scanner is camera/MethodChannel-coupled +// (mobile_scanner) and can only be exercised on a real device with a live +// camera. The decode logic it feeds is unit-tested in lnurl_decoder_test.dart +// and the cubit behaviour in pay_scan_cubit_test.dart; the camera preview +// itself is out of scope for widget tests. +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_quote_page.dart'; +import 'package:realunit_wallet/styles/colors.dart'; + +class PayScanPage extends StatelessWidget { + const PayScanPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => PayScanCubit(), + child: const PayScanView(), + ); + } +} + +class PayScanView extends StatelessWidget { + const PayScanView({super.key}); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + if (state is PayScanDecoded) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PayQuotePage(paymentLinkId: state.link.id), + ), + ); + // Reset so returning to the scanner re-arms detection. + context.read().reset(); + } + if (state is PayScanInvalid) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context).payScanInvalid), + backgroundColor: RealUnitColors.status.red600, + ), + ); + context.read().reset(); + } + }, + builder: (context, state) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).payScanTitle)), + body: MobileScanner( + onDetect: (capture) { + final raw = capture.barcodes.firstOrNull?.rawValue; + if (raw != null) context.read().onCodeDetected(raw); + }, + ), + ); + }, + ); + } +} diff --git a/lib/setup/di.dart b/lib/setup/di.dart index fa7b20f1..60fca67a 100644 --- a/lib/setup/di.dart +++ b/lib/setup/di.dart @@ -29,6 +29,7 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_support_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_widget_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_account_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_sell_payment_info_service.dart'; @@ -174,6 +175,9 @@ void setupServices() { getIt.registerFactory( () => RealUnitBuyPaymentInfoService(getIt(), getIt()), ); + getIt.registerFactory( + () => RealUnitPayService(getIt(), getIt()), + ); getIt.registerFactory( () => RealUnitPdfService(getIt(), getIt()), ); diff --git a/lib/setup/routing/router_config.dart b/lib/setup/routing/router_config.dart index 3d0b9e26..610318c2 100644 --- a/lib/setup/routing/router_config.dart +++ b/lib/setup/routing/router_config.dart @@ -11,6 +11,7 @@ import 'package:realunit_wallet/screens/kyc/kyc_page_manager.dart'; import 'package:realunit_wallet/screens/legal/legal_disclaimer_page.dart'; import 'package:realunit_wallet/screens/legal/subpages/legal_document_page.dart'; import 'package:realunit_wallet/screens/onboarding/onboarding_completed_page.dart'; +import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; import 'package:realunit_wallet/screens/pin/setup_pin_page.dart'; import 'package:realunit_wallet/screens/pin/verify_pin_page.dart'; import 'package:realunit_wallet/screens/receive/receive_page.dart'; @@ -150,6 +151,12 @@ final GoRouter routerConfig = GoRouter( builder: (_, state) => SellBitboxPage(paymentInfo: state.extra as SellPaymentInfo), ), + GoRoute( + name: AppRoutes.pay, + path: '/pay', + builder: (_, _) => const PayScanPage(), + ), + GoRoute( name: LegalRoutes.disclaimer, path: '/legalDisclaimer', diff --git a/lib/setup/routing/routes/app_routes.dart b/lib/setup/routing/routes/app_routes.dart index 1ec2a07a..721e9e91 100644 --- a/lib/setup/routing/routes/app_routes.dart +++ b/lib/setup/routing/routes/app_routes.dart @@ -5,6 +5,7 @@ abstract final class AppRoutes { static const buy = 'buy'; static const sell = 'sell'; static const sellBitbox = 'sellBitbox'; + static const pay = 'pay'; static const kyc = 'kyc'; static const receive = 'receive'; diff --git a/pubspec.lock b/pubspec.lock index bf626bd7..ef7333d8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -951,6 +951,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760 + url: "https://pub.dev" + source: hosted + version: "5.2.3" mocktail: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 9877d964..cf0a2b4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,6 +65,7 @@ dependencies: http: ^1.1.0 intl: any local_auth: ^3.0.0 + mobile_scanner: ^5.2.3 no_screenshot: ^1.1.0 open_file: ^3.5.11 path: ^1.9.0 diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png index 02631d184a458a760d861d825e1941e43922224b..ff69acbf1a1c79b0461cc35602ee7deff946f718 100644 GIT binary patch literal 27898 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
r5*#J$(?omb4Qwc{-a1BRx*nj*^l5I7+xco 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

OyO(p9 zI1>uhk3;ODn1rmkHCYv$K7=3d>&NB$B?+S!q%OW_>F3aLA(^QNV-4b}W~$qL zcKjjA8Hpwm(?N%sV11^#+ivc3i% zuhvd{8YIDOW)VP{7chse<<{KQP0Kyx)vbVsZlP;^rWO565$x|3QC|K20?%n7U7rO@ zF2%)Qed;Jk6?BDNizW{i7}05K{!PoX}$to7^C<|E*~0CvF$^85OwSzJ&aX zPrchbh+7l8`DzPIVyBHLuXC+)P*k#d&xFr8x0KuL$GFRML%;oF%4% zol>=|^v%>;_bu&P4lM*K{;(pvl8jGe2K7hU?DMWSOLJBaE_B|%Wfswv<4mWU$laT| z8zt>NVe-3Cs}LwZV*ydZQStn_yj|FyLEJWMDYNXgLvPnT#Lo}!xivkSjy)EZm>)qG z_Kx0Dh^#_aVhK85wNB`y-doHoNsFhbob2 z?VV+g{p)92G@@|(ew^gtDQaj67w7{5eB!v<;c_c!8))OQ1%>fCHVqkfOg;6J!)yX6 zcdI?Jf=pKS(q%dxbNdIKTNB)LY4HoB{2<}))_yNiyY5bLx?X@kRnp{>qB4I-*8RL8 zR}`&=8ha3L?n>|r{7oT-N>m+n3fYhv-h}&U8(1e;%?h7CH}f;!ed^PDk^OjJ@aQF5 zt0Szq_zt-bubZG1Q8n6V|6Z6=>60~!lEr?>l>e?G1y*S*N-aM$w7t3|Vpi94|H5Ss z9~KuY+_kVyj|7`}LYLcU+E%-;TepBwU3U)ZUY^vio`)27#=P(+!t6n(D;l-NYiSQ% z`gk(8O#{ju(4!^8QRjQ%>E#6_hf*g|@kq1uj>u@FV2>&Xgl!b17`UC>U2X!0H-*#I zYtXybTc9m7^V!G!e&XnqhTjSbV}6PEv3tgF`0s5Hz3-(#F~5QkrBkn4I7QDk`xq6} zYk}8Z@muWDaG8{OV4g*M05-|kTE(wm7wRLI|I;(M&zn3JC?GbZPk6|N z^C&;Wjn&zDI)1poLPa&AVcv^~SMJGz*#bGTaRe>9i+zZNg$30g=Rs;xmbiZo&!exT zXnwe3zZ}{pZ|;o>y7TTu55(wqJxvfZwb1&s6OKzo$<7#A#r}N0p1?()^ z(KR`OF#wy_u6|%pO?9z|vnW*wpvX^72{qPv}}WF#Xcx0(y%(Lj68KG@JC3jz|Euo&%NOTgDkm^D@lmIMXr1j0U#Ec-?Ixx{2TkIz^>_eI6dZN36I6 zJkm<}O;fZ_xh~}n^|hu$RZiDYO!yPMNgusiG%G}%kLz8bJuF-oqXu)*_0-8C;bC(e zi(bVfO|dj@O+H+cmpgMhfW z(V65kVEbo|{Cb^AvbEPcZfBrl{2hS9ZuQAaJ^gwGZHtGW*%T64Wn^T;z{F&h*H8Dl z3otGcn-J!k8-==QcV%Vy_wF_6)Rx-_G@fdzZh++20+ydIAR;2N7X(NE0&J0sGtv7w zyAH678n>~#cUqMsytk6>CF}fs1PK-p$SabxcW1S7%h2p#qa}n zEG)a{L6+}Qz^hnAe-7YP1W0qqwXY^0esnJi_wy(Or$BIeg|u~RXSfRss@4_2L@)&~ z0c~RM+#sMW2_nXJWiTazzW%b9VyzEg)p{%K~E)-kHD~87m?yT^p!B;zxgqSU0xZ9~55^oBwf5 zFtbTNi%|W1RkS8>78@?o=fwR| z`!+Dj&(A;B=wAuaP@d%Og6mvrPJjT=)z@bRl;40haYwVOw|4|cd665q-7Xg6yxC-biioONag8{Hb{e|yhVq$Um9nrT0rKM}sve>UwSN1P^1Or%)RuX|cd?9|L zvJk<rMv({JkM?KKB_*EHnF0V`<0bhSnork=XxY~;O0y~kCJ~d=tk>d*HIjrDf$qd|5%c=Pq3OIqrcUDJe2LiY@@qh_MDg3}tPaB`+3s@~AER zSniqcvuDqol>YK$t3U2*sG83#Nb~I0#jeDRBkWOJ<#jqB!$reG>|ch%511(+or{Yb zFM);^S-m!i(57SidnkvXAE?!D8Rzd zT1rnzDdtcN`1w(tV{m6}JTWs94Y&o?mucAyrkjGxs>|QKdpB0)Y|L2rM~sMWdP2}` z?*_zf!+O=_qGeRW`hdl8|FHS3CgO@mV?kmT+Q`c*?#GX`OGRZ}o;#N}E*5VT;fX7@ z)NuUZGW+H2EY8X*zWzLNSR5m1K*@#@@3MI9B(Vmi*Ee-kc(CADIUA zCz&ivZEbC#xQ_!8H_)4-5#!EdAUGwx({oCH=awn%j-j!O8n+f^1`8t!w6Ol`ottef!feXHJRmU*{ zC)9vFWAx}zN^V{rdI{9E3>+Nr1rMk_UV&Bm(}A;hM0)b{1|L=P)p@AcTa<}WctY^S zuD^G?WZ>+&)8xuaT+ZLDd>ZY0O9SyqAAh@pN zq%gmrH$J8b%QsF_E%6Rg;gNc0f|rhF;AAXR@$0R(p{2NuXjttsyMSzjHp1lRwFG%n zMu>qFMVcD)EEv~^dyg&od&maq-!N`n9}d+2laJLNuxIsFpHm9j7sTU$&0 zB!GM>lZ^fJNeJ)_h(Y$HvrfdVM?lM8f|MKueRY2Y6qW7P;W2R1Xk^pq=qSY{m2b%L z>WKJC1JL!`;i#$$;jeGsmb2C?Qn7fW#CRB^kdMsMsAYl^RRudUO6khib1KKBOsN&~8 z6|d9c93SYitsOJb)}nl7KUMZo!4b2bGLk?xKZ5{>hXMbk%%ZPfAGUXNTuN<>^mW>VgYy?)7@xu1VXR?7tx&eP?#q8Ot28B#DC@nwOWib5MvVtkenP=i0BqPI3hYBAtBMkf-nA@-)4!G!T|V{zR`5p$H2^-4)|Pilz=b{ z%`NF3olLnA7P}sRJF(+V1(=#!c2WMnzwz~gx;naZP7J(RAt2=}0#+EcUiya*kB10^ z=;;k8z~vOw?@k3qi*?K|ETn=KXeEF)*+q$YFZPIm%a$$dR`{r-eK*O!7<4g>t*zOP zVb~j6Y?^}V z$s=XSqt%Gy`rloG(cJOiazRh!Y#AyYdc`Fidag7io#I9Fq0-P z7gyfI792y;5I?(qC8+0&a^>TeS2L#}#}rZ+MmJr$H|iF&4{Q!stin_Grv1ekM41^{ z9Gt=q7CSQx+zpIp7=qBBKM!b#1FBBvv;RDTbW-5DBwW9Xf=$*1R47>xH+t|V6bw}n zO*IIIt=nWm=fMN-&!yqBx$lKbe_@Ard>i4bS}shCh2#BNLhLlAmbkeW9Vhap#;^km zm;;m93gipy@wS~qPGQJ)<=GQuerz8IvjfG1w3VHccagEBqviRID1S9`8=ph~{3>_M z3a)9DFF&K1V7G^~DSLCVWgx6EPSMZK1oEv*)yt%$2B_T=3A{V54mpYMh69F8~iY_XKhTlE_6pua$ytoRq@@7KFpq;H1 zXIbTRJ)_BcsZUE=8)VIs*b(eC8&Gyu1t0oq8d+MV$#Eb%(fRq@$J+#bP=#uN)68F# zaU)fN1lX zeZ$P*`$yRDut_jLSY+JBnYcrt2 zsRBtm4XdOE7&j+^;tnvh(9U4WB?*9jin|lua)+yx1p*WFIk|zdfaWwMH{jPRMfwSf zDYK=%OrM2Ba~D9)O6)rXPH*IUg`L#1zyCa5_rnwBzr|C)#c=*hvFrr~0xO`KH$C<# zar{f-(yy{y&OhzL$moeA^q_|uF|TZ>N}}iy2y#wi&D_1%Y}`SOOa6C_T5G9BfrCVI z_*pMk4*4@%+HK4UtV{s8Ao-KCd)@4%waEr^kTaG34x(#HZaWJc07-UfW5Hl?X1~3? zoqgx=Rs?5=6V1wGrKA4B;-UZ-*WBk7i$qFs3E<^T0t_7(tz;B}IV^CA`hfgb<>lqY z0Dx%gGj`wrf+vMEU5KAje{Y52C{N*VxaZzSfC^b5M%xplT)!LWrb+Tcr&m{3AM30H zG`~CSgFvWXoP9^Yf1T)st$k!04b*9GbJk_7s$?C|_q+(jNINTEQNQ70!!dkl=;p?k z`*#uIIBp+6SDIjMRnbimy$}2al@gN(+}b_u&Eh}Kz+Zj(M@kfqsM?ALm z?Mm$r>wJ#My*{e3f3!oXjqAoO1oO}bK}rB)qz~HrMqtwmh9qdhsc+_Xas&$PMY{kL z8Zf8fx7WG0L0`a(Y?c<$kgSrLSAY5dAld-vj_s&nl{){+=_5g_{?bqCZh{Sih5-Qq zp2;efmX@3Qzzw)Gzc?3{57Ko+chbG($%gq3e$Z-Yx$Xoi`difL+-`t`2mGU#44Vs~ zFj{K$c}Pf8$N`y;hnH8NXIA;+eY;RHWDb>XinCUNvBFdRfMwb_r&26W6Fegzp0$A>DP{qQs8h#LP9l$Bf|O< zud>)t;X*3O$w3lN$V`M;&a8A0p>(E;@Z#}F9uo3j-uJ|;-jQ(LmnQ6zF*-v%K843H zB`6eM+3q?K?+9LNywygkp@SU_oW*P9W|Vg7%$^--=Ma=miQum;_+ z#qx%A@t4ZYpR8*8G{iwQ3xW{H#}EL5KZ)1u||8a37$QC3#rW##v zwXUO}0&@VE(M6DpbR?kaO>2}ow_270*u%pXm=1d)(H4uKmPqrBEtmLq`p7Xrp<-(L z`BR+cNv9lPXV7OcE*YA3dQ>+(&#?cXh64m9)B4H}I^>&F0L>-6_@OcwxYj7{tx?CU zn|4vDvfhOspMh|H7BN#n9aaxo7GYcaAa3b;d42i#>@2o}4-$r9XAK8V&FYB^aw-Hr zJ%J?Xo+Ses;%5tp*eDnI5I4brrKku|_8BTf1)>^vgaI5F^!iLXe+a?kh*rL?YYuXxEco>C0*IPm@e zrD{qBH(apEWT&L0P=%Hz2R@Eefjhs`Vt%mFSwsOppVEh(03 z)Q}m`f6rz`|2Pju+{;>bYwE#FHw$JkUik;aOua9vVyPyr~8G^Zbdj zay#7i??uipqAXmD41x9KU}kOD%)-bNAxuR@`M}bVG9>fbiyyK%{y56;iQ!MYXGGAa zeC6i5_`lnnKS2yGgoG;ZtHLiX&!pGL&uia3Trik7O&KqEE6NFKH;$#!@;&+`P-T&)$fd939|JNM@|NP7UeJ8~~>+;XK o{P%4&{}{tR#_<1xF&x2|dbJC%s`KEBVjvJrRlSGh4<5hz5B(3o&;S4c literal 26087 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 D^~h^B diff --git a/test/packages/service/dfx/exceptions/exception_surface_test.dart b/test/packages/service/dfx/exceptions/exception_surface_test.dart index d187e5a6..65625b44 100644 --- a/test/packages/service/dfx/exceptions/exception_surface_test.dart +++ b/test/packages/service/dfx/exceptions/exception_surface_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart'; import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; // Guard against a recurring failure mode: an Exception subclass without a @@ -24,6 +25,9 @@ void main() { requiredLevel: 1, currentLevel: 0, ), + const InvalidPaymentLinkException('test'), + const PayUnsupportedEnvironmentException(), + const PaySignatureUnsupportedException(), ]; for (final ex in exceptions) { diff --git a/test/packages/service/dfx/lnurl_decoder_test.dart b/test/packages/service/dfx/lnurl_decoder_test.dart new file mode 100644 index 00000000..bd660bf6 --- /dev/null +++ b/test/packages/service/dfx/lnurl_decoder_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/lnurl_decoder.dart'; + +void main() { + group('LnurlDecoder.decode', () { + // LUD-01 bech32 of `https://api.dfx.swiss/v1/lnurlp/pl_abc123`. + const lnurl = 'LNURL1DP68GURN8GHJ7CTSDYHXGENC9EEHW6TNWVHHVVF0D3H82UNVWQHHQMZLV93XXVFJXV5T0E5A'; + + test('decodes a bech32 LNURL to the api lnurlp url + id', () { + final result = LnurlDecoder.decode(lnurl); + + expect(result.lnurlpUrl.toString(), 'https://api.dfx.swiss/v1/lnurlp/pl_abc123'); + expect(result.id, 'pl_abc123'); + }); + + test('decodes the lightning= query param of an app.dfx.swiss wrapper URL', () { + final result = LnurlDecoder.decode('https://app.dfx.swiss/pl/?lightning=$lnurl'); + + expect(result.lnurlpUrl.host, 'api.dfx.swiss'); + expect(result.id, 'pl_abc123'); + }); + + test('accepts a lowercase lnurl and a lightning: scheme prefix', () { + final result = LnurlDecoder.decode('lightning:${lnurl.toLowerCase()}'); + + expect(result.id, 'pl_abc123'); + }); + + test('rewrites a plain app.dfx.swiss lnurlp url to the api host', () { + final result = LnurlDecoder.decode('https://app.dfx.swiss/v1/lnurlp/pl_xyz'); + + expect(result.lnurlpUrl.toString(), 'https://api.dfx.swiss/v1/lnurlp/pl_xyz'); + expect(result.id, 'pl_xyz'); + }); + + test('keeps an already-api lnurlp url and forces https', () { + final result = LnurlDecoder.decode('http://api.dfx.swiss/v1/lnurlp/plp_123'); + + expect(result.lnurlpUrl.scheme, 'https'); + expect(result.id, 'plp_123'); + }); + + test('rewrites the dev testnet host twin', () { + final result = LnurlDecoder.decode('https://dev.app.dfx.swiss/v1/lnurlp/pl_dev'); + + expect(result.lnurlpUrl.host, 'dev.api.dfx.swiss'); + expect(result.id, 'pl_dev'); + }); + + test('rejects an empty code', () { + expect( + () => LnurlDecoder.decode(' '), + throwsA(isA()), + ); + }); + + test('rejects a non-DFX host', () { + expect( + () => LnurlDecoder.decode('https://evil.example.com/v1/lnurlp/pl_x'), + throwsA(isA()), + ); + }); + + test('rejects a non-http payload', () { + expect( + () => LnurlDecoder.decode('not-a-url'), + throwsA(isA()), + ); + }); + + test('rejects a bech32 with an invalid checksum', () { + // Flip the last data character to break the checksum. + final broken = '${lnurl.substring(0, lnurl.length - 1)}Q'; + expect( + () => LnurlDecoder.decode(broken), + throwsA(isA()), + ); + }); + + test('rejects a bech32 with an invalid character in the data part', () { + // Replace a data char with 'b' (not in the bech32 charset) while keeping + // the overall length valid, so the per-character guard fires. + final withBadChar = '${lnurl.substring(0, 20)}B${lnurl.substring(21)}'; + expect( + () => LnurlDecoder.decode(withBadChar), + throwsA(isA()), + ); + }); + + test('rejects a too-short bech32', () { + expect( + () => LnurlDecoder.decode('LNURL1bbb'), + throwsA(isA()), + ); + }); + + test('falls back to the last path segment when there is no lnurlp segment', () { + final result = LnurlDecoder.decode('https://api.dfx.swiss/pl_direct'); + expect(result.id, 'pl_direct'); + }); + + test('rejects a DFX url with an empty path', () { + expect( + () => LnurlDecoder.decode('https://api.dfx.swiss/'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/packages/service/dfx/models/payment/pay/pay_dtos_test.dart b/test/packages/service/dfx/models/payment/pay/pay_dtos_test.dart new file mode 100644 index 00000000..5d192b69 --- /dev/null +++ b/test/packages/service/dfx/models/payment/pay/pay_dtos_test.dart @@ -0,0 +1,271 @@ +import 'package:flutter_test/flutter_test.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_ocp_pay_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_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/dto/real_unit_swap_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart'; + +void main() { + group('RealUnitSwapDto', () { + test('fromAmount serialises only amount', () { + expect(const RealUnitSwapDto.fromAmount(10).toJson(), {'amount': 10}); + }); + + test('fromTargetAmount serialises only targetAmount', () { + expect( + const RealUnitSwapDto.fromTargetAmount(95.5).toJson(), + {'targetAmount': 95.5}, + ); + }); + }); + + group('RealUnitSwapPaymentInfoDto.fromJson', () { + test('maps every field with no dynamic access', () { + final dto = RealUnitSwapPaymentInfoDto.fromJson({ + 'id': 99, + 'uid': 'MOCK-UID', + 'routeId': 7, + 'timestamp': '2026-06-03T00:00:00.000Z', + 'amount': 10, + 'estimatedAmount': 960, + 'targetAsset': 'ZCHF', + 'fees': {'dfx': 1, 'network': 0.5, 'total': 1.5}, + 'minVolume': 1, + 'maxVolume': 1000, + 'minVolumeTarget': 95, + 'maxVolumeTarget': 95000, + 'ethBalance': 1.0, + 'requiredGasEth': 0.001, + 'isValid': true, + }); + + expect(dto.id, 99); + expect(dto.uid, 'MOCK-UID'); + expect(dto.routeId, 7); + expect(dto.targetAsset, 'ZCHF'); + expect(dto.estimatedAmount, 960); + expect(dto.minVolumeTarget, 95); + expect(dto.isValid, isTrue); + expect(dto.error, isNull); + }); + + test('maps the error code when isValid is false', () { + final dto = RealUnitSwapPaymentInfoDto.fromJson({ + 'id': 1, + 'uid': 'u', + 'routeId': 1, + 'timestamp': '2026-06-03T00:00:00.000Z', + 'amount': 1, + 'estimatedAmount': 1, + 'targetAsset': 'ZCHF', + 'minVolume': 1, + 'maxVolume': 2, + 'minVolumeTarget': 1, + 'maxVolumeTarget': 2, + 'ethBalance': 0, + 'requiredGasEth': 0.001, + 'isValid': false, + 'error': 'LIMIT_EXCEEDED', + }); + + expect(dto.isValid, isFalse); + expect(dto.error, 'LIMIT_EXCEEDED'); + }); + }); + + test('SwapPaymentInfo.fromDto carries the swap-relevant fields', () { + final dto = RealUnitSwapPaymentInfoDto.fromJson({ + 'id': 5, + 'uid': 'u', + 'routeId': 2, + 'timestamp': '2026-06-03T00:00:00.000Z', + 'amount': 10, + 'estimatedAmount': 960, + 'targetAsset': 'ZCHF', + 'minVolume': 1, + 'maxVolume': 1000, + 'minVolumeTarget': 95, + 'maxVolumeTarget': 95000, + 'ethBalance': 0.4, + 'requiredGasEth': 0.002, + 'isValid': true, + }); + + final info = SwapPaymentInfo.fromDto(dto); + + expect(info.id, 5); + expect(info.estimatedAmount, 960); + expect(info.ethBalance, 0.4); + expect(info.requiredGasEth, 0.002); + expect(info.isValid, isTrue); + }); + + test('SwapPaymentInfo equality is value-based (Equatable props)', () { + const a = SwapPaymentInfo( + id: 1, + amount: 10, + estimatedAmount: 960, + targetAsset: 'ZCHF', + ethBalance: 1, + requiredGasEth: 0.001, + isValid: true, + ); + const same = SwapPaymentInfo( + id: 1, + amount: 10, + estimatedAmount: 960, + targetAsset: 'ZCHF', + ethBalance: 1, + requiredGasEth: 0.001, + isValid: true, + ); + const different = SwapPaymentInfo( + id: 2, + amount: 10, + estimatedAmount: 960, + targetAsset: 'ZCHF', + ethBalance: 1, + requiredGasEth: 0.001, + isValid: true, + error: 'LIMIT_EXCEEDED', + ); + + expect(a, equals(same)); + expect(a, isNot(equals(different))); + }); + + test('RealUnitSwapUnsignedTransactionDto.fromJson', () { + final dto = RealUnitSwapUnsignedTransactionDto.fromJson({'swap': '0xswap'}); + expect(dto.swap, '0xswap'); + }); + + test('RealUnitOcpPayDto.toJson', () { + const dto = RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q1'); + expect(dto.toJson(), {'paymentLinkId': 'pl_abc', 'quoteId': 'q1'}); + }); + + test('RealUnitOcpPayUnsignedTransactionDto.fromJson', () { + final dto = RealUnitOcpPayUnsignedTransactionDto.fromJson({ + 'unsignedTx': '0xtx', + 'tokenAddress': '0xzchf', + 'recipient': '0xrecipient', + 'amountWei': '5000000000000000000', + 'chainId': 1, + }); + + expect(dto.unsignedTx, '0xtx'); + expect(dto.tokenAddress, '0xzchf'); + expect(dto.recipient, '0xrecipient'); + expect(dto.amountWei, '5000000000000000000'); + expect(dto.chainId, 1); + }); + + test('RealUnitOcpPaySubmitDto.toJson carries the signed envelope + refs', () { + const dto = RealUnitOcpPaySubmitDto( + unsignedTx: '0xtx', + r: '0xr', + s: '0xs', + v: 27, + paymentLinkId: 'pl_abc', + quoteId: 'q1', + ); + + expect(dto.toJson(), { + 'unsignedTx': '0xtx', + 'r': '0xr', + 's': '0xs', + 'v': 27, + 'paymentLinkId': 'pl_abc', + 'quoteId': 'q1', + }); + }); + + test('RealUnitOcpPayResultDto.fromJson', () { + expect(RealUnitOcpPayResultDto.fromJson({'txId': '0xTxId'}).txId, '0xTxId'); + }); + + group('RealUnitOcpPayStatusDto.fromJson', () { + test('maps each known status', () { + expect( + RealUnitOcpPayStatusDto.fromJson({'status': 'Completed'}).status, + OcpPaymentStatus.completed, + ); + expect( + RealUnitOcpPayStatusDto.fromJson({'status': 'Pending'}).status, + OcpPaymentStatus.pending, + ); + expect( + RealUnitOcpPayStatusDto.fromJson({'status': 'Cancelled'}).status, + OcpPaymentStatus.cancelled, + ); + expect( + RealUnitOcpPayStatusDto.fromJson({'status': 'Expired'}).status, + OcpPaymentStatus.expired, + ); + }); + + test('falls back to unknown for an unmapped status', () { + expect( + RealUnitOcpPayStatusDto.fromJson({'status': 'Whatever'}).status, + OcpPaymentStatus.unknown, + ); + }); + + test('isTerminal / isCompleted predicates', () { + expect(OcpPaymentStatus.completed.isTerminal, isTrue); + expect(OcpPaymentStatus.completed.isCompleted, isTrue); + expect(OcpPaymentStatus.cancelled.isTerminal, isTrue); + expect(OcpPaymentStatus.cancelled.isCompleted, isFalse); + expect(OcpPaymentStatus.expired.isTerminal, isTrue); + expect(OcpPaymentStatus.pending.isTerminal, isFalse); + expect(OcpPaymentStatus.unknown.isTerminal, isFalse); + }); + }); + + group('LnurlpPaymentDto.fromJson', () { + test('maps requestedAmount, quote and ZCHF transfer amounts', () { + final dto = LnurlpPaymentDto.fromJson({ + 'recipient': '0xrecipient', + 'requestedAmount': {'asset': 'CHF', 'amount': 42.5}, + 'quote': {'id': 'quote_xyz', 'expiration': '2026-06-03T12:00:00.000Z'}, + 'transferAmounts': [ + { + 'method': 'Ethereum', + 'assets': [ + {'asset': 'ZCHF', 'amount': 42.7}, + ], + }, + { + 'method': 'Bitcoin', + 'assets': [ + {'asset': 'BTC', 'amount': 0.0005}, + ], + }, + ], + }); + + expect(dto.requestedAmount.asset, 'CHF'); + expect(dto.requestedAmount.amount, 42.5); + expect(dto.quote.id, 'quote_xyz'); + expect(dto.recipient, '0xrecipient'); + expect(dto.transferAmounts, hasLength(2)); + expect(dto.transferAmounts.first.method, 'Ethereum'); + expect(dto.transferAmounts.first.assets.first.asset, 'ZCHF'); + expect(dto.transferAmounts.first.assets.first.amount, 42.7); + }); + + test('tolerates a missing transferAmounts list', () { + final dto = LnurlpPaymentDto.fromJson({ + 'requestedAmount': {'asset': 'CHF', 'amount': 1.0}, + 'quote': {'id': 'q', 'expiration': '2026-06-03T12:00:00.000Z'}, + }); + + expect(dto.transferAmounts, isEmpty); + }); + }); +} diff --git a/test/packages/service/dfx/real_unit_pay_service_test.dart b/test/packages/service/dfx/real_unit_pay_service_test.dart new file mode 100644 index 00000000..fc1c89c7 --- /dev/null +++ b/test/packages/service/dfx/real_unit_pay_service_test.dart @@ -0,0 +1,379 @@ +import 'dart:convert'; + +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/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_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/sell/dto/broadcast_transaction_request_dto.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:web3dart/web3dart.dart'; + +class _MockAppStore extends Mock implements AppStore {} + +class _MockWallet extends Mock implements AWallet {} + +class _MockAccount extends Mock implements AWalletAccount {} + +class _MockCacheRepository extends Mock implements CacheRepository {} + +class _MockWalletService extends Mock implements WalletService {} + +class _StubCreds extends Fake implements CredentialsWithKnownAddress { + @override + EthereumAddress get address => + EthereumAddress.fromHex('0x0000000000000000000000000000000000000001'); +} + +Map _swapInfoJson() => { + 'id': 99, + 'uid': 'MOCK-UID', + 'routeId': 7, + 'timestamp': '2026-06-03T00:00:00.000Z', + 'amount': 10, + 'estimatedAmount': 960, + 'targetAsset': 'ZCHF', + 'fees': {'dfx': 1, 'network': 0.5, 'total': 1.5}, + 'minVolume': 1, + 'maxVolume': 1000, + 'minVolumeTarget': 95, + 'maxVolumeTarget': 95000, + 'ethBalance': 1.0, + 'requiredGasEth': 0.001, + 'isValid': true, +}; + +void main() { + late _MockAppStore appStore; + late _MockWallet wallet; + late _MockAccount account; + late _MockWalletService walletService; + late SessionCache session; + + setUp(() { + appStore = _MockAppStore(); + wallet = _MockWallet(); + account = _MockAccount(); + walletService = _MockWalletService(); + session = SessionCache(_MockCacheRepository()); + session.setAuthToken('jwt-1'); + + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.sessionCache).thenReturn(session); + when(() => appStore.wallet).thenReturn(wallet); + when(() => wallet.currentAccount).thenReturn(account); + when(() => account.primaryAddress).thenReturn(_StubCreds()); + }); + + RealUnitPayService build(http.Client client) { + when(() => appStore.httpClient).thenReturn(client); + return RealUnitPayService(appStore, walletService); + } + + group('getPaymentDetails', () { + test('GETs the public lnurlp endpoint (no auth header) and parses it', () async { + Uri? sentUri; + Map? headers; + final client = MockClient((request) async { + sentUri = request.url; + headers = request.headers; + return http.Response( + jsonEncode({ + 'requestedAmount': {'asset': 'CHF', 'amount': 42.5}, + 'quote': {'id': 'quote_xyz', 'expiration': '2026-06-03T12:00:00.000Z'}, + 'transferAmounts': [ + { + 'method': 'Ethereum', + 'assets': [ + {'asset': 'ZCHF', 'amount': 42.7}, + ], + }, + ], + }), + 200, + ); + }); + + final dto = await build(client).getPaymentDetails('pl_abc'); + + expect(sentUri!.path, '/v1/lnurlp/pl_abc'); + expect(headers!.containsKey('Authorization'), isFalse); + expect(dto.quote.id, 'quote_xyz'); + expect(dto.transferAmounts.first.assets.first.amount, 42.7); + }); + + test('non-200 → ApiException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 404, 'message': 'gone'}), 404), + ); + expect( + () => build(client).getPaymentDetails('pl_abc'), + throwsA(isA()), + ); + }); + }); + + group('getSwapPaymentInfo', () { + test('PUTs /swap with targetAmount and parses the quote', () async { + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response(jsonEncode(_swapInfoJson()), 200); + }); + + final info = await build( + client, + ).getSwapPaymentInfo(const RealUnitSwapDto.fromTargetAmount(95.5)); + + expect(sentUri!.path, '/v1/realunit/swap'); + expect(body!['targetAmount'], 95.5); + expect(body!.containsKey('amount'), isFalse); + expect(info.id, 99); + expect(info.estimatedAmount, 960); + expect(info.isValid, isTrue); + }); + + test('non-200 → ApiException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'bad'}), 400), + ); + expect( + () => build(client).getSwapPaymentInfo(const RealUnitSwapDto.fromAmount(10)), + throwsA(isA()), + ); + }); + }); + + group('createSwapUnsignedTransaction', () { + test('200 → parses swap hex', () async { + Uri? sentUri; + final client = MockClient((request) async { + sentUri = request.url; + return http.Response(jsonEncode({'swap': '0xswap'}), 200); + }); + + final dto = await build(client).createSwapUnsignedTransaction(42); + + expect(sentUri!.path, '/v1/realunit/swap/42/unsigned-transaction'); + expect(dto.swap, '0xswap'); + }); + + test('non-200 → ApiException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'no eth'}), 400), + ); + expect( + () => build(client).createSwapUnsignedTransaction(42), + throwsA(isA()), + ); + }); + }); + + group('broadcastSwapTransaction', () { + test('201 → returns txHash', () async { + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'txHash': '0xabc'}), 201); + }); + + final txHash = await build(client).broadcastSwapTransaction( + 42, + const BroadcastTransactionRequestDto(unsignedTx: '0xtx', r: '0xr', s: '0xs', v: 27), + ); + + expect(sentUri!.path, '/v1/realunit/swap/42/broadcast'); + expect(body!['unsignedTx'], '0xtx'); + expect(body!['v'], 27); + expect(txHash, '0xabc'); + }); + + test('non-200 → ApiException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'bad sig'}), 400), + ); + expect( + () => build(client).broadcastSwapTransaction( + 42, + const BroadcastTransactionRequestDto(unsignedTx: '0xtx', r: '0xr', s: '0xs', v: 27), + ), + throwsA(isA()), + ); + }); + }); + + group('createPayUnsignedTransaction', () { + test('PUTs /pay/unsigned-transaction with the payment refs', () async { + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode({ + 'unsignedTx': '0xtx', + 'tokenAddress': '0xzchf', + 'recipient': '0xrecipient', + 'amountWei': '5000000000000000000', + 'chainId': 1, + }), + 200, + ); + }); + + final dto = await build(client).createPayUnsignedTransaction( + const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q1'), + ); + + expect(sentUri!.path, '/v1/realunit/pay/unsigned-transaction'); + expect(body!['paymentLinkId'], 'pl_abc'); + expect(body!['quoteId'], 'q1'); + expect(dto.recipient, '0xrecipient'); + expect(dto.amountWei, '5000000000000000000'); + }); + + test('400 (mainnet-only fail-fast on testnet) → ApiException', () async { + final client = MockClient( + (_) async => + http.Response(jsonEncode({'statusCode': 400, 'message': 'unsupported method'}), 400), + ); + expect( + () => build(client).createPayUnsignedTransaction( + const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q1'), + ), + throwsA(isA()), + ); + }); + }); + + group('submitPay', () { + test('PUTs /pay/submit and returns txId', () async { + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'txId': '0xTxId'}), 200); + }); + + final txId = await build(client).submitPay( + const RealUnitOcpPaySubmitDto( + unsignedTx: '0xtx', + r: '0xr', + s: '0xs', + v: 27, + paymentLinkId: 'pl_abc', + quoteId: 'q1', + ), + ); + + expect(sentUri!.path, '/v1/realunit/pay/submit'); + expect(body!['paymentLinkId'], 'pl_abc'); + expect(txId, '0xTxId'); + }); + + test('non-200 → ApiException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'fail'}), 400), + ); + expect( + () => build(client).submitPay( + const RealUnitOcpPaySubmitDto( + unsignedTx: '0xtx', + r: '0xr', + s: '0xs', + v: 27, + paymentLinkId: 'pl_abc', + quoteId: 'q1', + ), + ), + throwsA(isA()), + ); + }); + }); + + group('testnet fail-fast (mainnet-only OCP settlement)', () { + test('createPayUnsignedTransaction throws PayUnsupportedEnvironmentException', () async { + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet)); + var clientCalled = false; + final client = MockClient((_) async { + clientCalled = true; + return http.Response('{}', 200); + }); + + await expectLater( + build(client).createPayUnsignedTransaction( + const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q1'), + ), + throwsA(isA()), + ); + expect(clientCalled, isFalse); + }); + + test('submitPay throws PayUnsupportedEnvironmentException without a round-trip', () async { + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet)); + var clientCalled = false; + final client = MockClient((_) async { + clientCalled = true; + return http.Response('{}', 200); + }); + + await expectLater( + build(client).submitPay( + const RealUnitOcpPaySubmitDto( + unsignedTx: '0xtx', + r: '0xr', + s: '0xs', + v: 27, + paymentLinkId: 'pl_abc', + quoteId: 'q1', + ), + ), + throwsA(isA()), + ); + expect(clientCalled, isFalse); + }); + }); + + group('getPayStatus', () { + test('GETs /pay/:id/status and parses the status', () async { + Uri? sentUri; + final client = MockClient((request) async { + sentUri = request.url; + return http.Response(jsonEncode({'status': 'Completed'}), 200); + }); + + final dto = await build(client).getPayStatus('pl_abc'); + + expect(sentUri!.path, '/v1/realunit/pay/pl_abc/status'); + expect(dto.status, OcpPaymentStatus.completed); + }); + + test('non-200 → ApiException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 404, 'message': 'none'}), 404), + ); + expect( + () => build(client).getPayStatus('pl_abc'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/screens/pay/pay_process_cubit_test.dart b/test/screens/pay/pay_process_cubit_test.dart new file mode 100644 index 00000000..ed302683 --- /dev/null +++ b/test/screens/pay/pay_process_cubit_test.dart @@ -0,0 +1,569 @@ +import 'dart:typed_data'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.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/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/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/faucet/faucet_response_dto.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_ocp_pay_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_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/dto/real_unit_swap_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_request_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.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:web3dart/crypto.dart'; +import 'package:web3dart/web3dart.dart'; + +import '../../helper/fake_bitbox_credentials.dart'; + +class _MockPayService extends Mock implements RealUnitPayService {} + +class _MockFaucet extends Mock implements DfxFaucetService {} + +class _MockBlockchain extends Mock implements DfxBlockchainApiService {} + +class _MockWalletService extends Mock implements WalletService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockWallet extends Mock implements AWallet {} + +class _MockAccount extends Mock implements AWalletAccount {} + +/// Credentials whose `signToSignature` throws [UnsupportedError] — the debug +/// wallet's behaviour, used to exercise the in-sign defensive guard. +class _UnsupportedCreds extends Fake implements CredentialsWithKnownAddress { + @override + Future signToSignature(Uint8List payload, {int? chainId, bool isEIP1559 = false}) => + throw UnsupportedError('Debug wallet cannot sign'); +} + +SwapPaymentInfo _swap({ + double ethBalance = 1.0, + double requiredGasEth = 0.001, + bool isValid = true, +}) { + return SwapPaymentInfo.fromDto( + RealUnitSwapPaymentInfoDto( + id: 99, + uid: 'u', + routeId: 7, + timestamp: DateTime.parse('2026-06-03T00:00:00.000Z'), + amount: 10, + estimatedAmount: 960, + targetAsset: 'ZCHF', + minVolume: 1, + maxVolume: 1000, + minVolumeTarget: 95, + maxVolumeTarget: 95000, + ethBalance: ethBalance, + requiredGasEth: requiredGasEth, + isValid: isValid, + ), + ); +} + +LnurlpPaymentDto _details({required DateTime expiration, String quoteId = 'quote_fresh'}) { + return LnurlpPaymentDto( + requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 42.5), + quote: LnurlpQuoteDto(id: quoteId, expiration: expiration), + recipient: '0xrecipient', + transferAmounts: const [ + LnurlpTransferAmountDto( + method: 'Ethereum', + assets: [LnurlpTransferAssetDto(asset: 'ZCHF', amount: 42.7)], + ), + ], + ); +} + +const _unsignedPay = RealUnitOcpPayUnsignedTransactionDto( + // A short EIP-1559-style payload; signToSignature only keccak-hashes it. + unsignedTx: '0x02f8', + tokenAddress: '0xzchf', + recipient: '0xrecipient', + amountWei: '5000000000000000000', + chainId: 11155111, +); + +void main() { + late _MockPayService payService; + late _MockFaucet faucet; + late _MockBlockchain blockchain; + late _MockWalletService walletService; + late _MockAppStore appStore; + late _MockWallet wallet; + late _MockAccount account; + + setUpAll(() { + registerFallbackValue(const RealUnitSwapDto.fromAmount(1)); + registerFallbackValue(const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q')); + registerFallbackValue( + const BroadcastTransactionRequestDto(unsignedTx: '', r: '', s: '', v: 0), + ); + registerFallbackValue( + const RealUnitOcpPaySubmitDto( + unsignedTx: '', + r: '', + s: '', + v: 0, + paymentLinkId: 'pl_abc', + quoteId: 'q', + ), + ); + }); + + setUp(() { + payService = _MockPayService(); + faucet = _MockFaucet(); + blockchain = _MockBlockchain(); + walletService = _MockWalletService(); + appStore = _MockAppStore(); + wallet = _MockWallet(); + account = _MockAccount(); + + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.primaryAddress).thenReturn('0xwallet'); + when(() => appStore.wallet).thenReturn(wallet); + when(() => wallet.walletType).thenReturn(WalletType.software); + when(() => wallet.currentAccount).thenReturn(account); + when(() => account.primaryAddress).thenReturn(FakeBitboxCredentials(signDelay: Duration.zero)); + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); + when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); + }); + + PayProcessCubit build({double zchfNeeded = 42.7}) => PayProcessCubit( + payService: payService, + faucetService: faucet, + blockchainService: blockchain, + walletService: walletService, + appStore: appStore, + paymentLinkId: 'pl_abc', + zchfNeeded: zchfNeeded, + ); + + void wireHappyPath() { + when(() => payService.getSwapPaymentInfo(any())).thenAnswer((_) async => _swap()); + when(() => payService.createSwapUnsignedTransaction(any())).thenAnswer( + (_) async => const RealUnitSwapUnsignedTransactionDto(swap: '0x02f8aa'), + ); + when( + () => payService.broadcastSwapTransaction(any(), any()), + ).thenAnswer((_) async => '0xswaptx'); + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => _details(expiration: DateTime.now().add(const Duration(minutes: 5))), + ); + when( + () => payService.createPayUnsignedTransaction(any()), + ).thenAnswer((_) async => _unsignedPay); + when(() => payService.submitPay(any())).thenAnswer((_) async => '0xpaytx'); + } + + // 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 mock `thenAnswer` future + every zero-delay sign timer) until the + // cubit settles. + void drain(FakeAsync async) { + for (var i = 0; i < 40; i++) { + async.flushMicrotasks(); + async.elapse(Duration.zero); + } + } + + test('debug wallet → signatureUnsupported before any network call', () async { + when(() => wallet.walletType).thenReturn(WalletType.debug); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as PayProcessFailure; + expect(state.reason, PayProcessFailureReason.signatureUnsupported); + verifyNever(() => payService.getSwapPaymentInfo(any())); + await cubit.close(); + }); + + test('invalid swap quote → insufficientZchf', () async { + when(() => payService.getSwapPaymentInfo(any())).thenAnswer((_) async => _swap(isValid: false)); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as PayProcessFailure; + expect(state.reason, PayProcessFailureReason.insufficientZchf); + await cubit.close(); + }); + + test('swap sizes the target with a slippage buffer over the ZCHF needed', () async { + wireHappyPath(); + when(() => payService.getPayStatus('pl_abc')).thenAnswer( + (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed), + ); + RealUnitSwapDto? sentDto; + when(() => payService.getSwapPaymentInfo(any())).thenAnswer((invocation) async { + sentDto = invocation.positionalArguments.first as RealUnitSwapDto; + return _swap(); + }); + + final cubit = build(zchfNeeded: 100); + await cubit.start(); + + // After start() resolves the chain the pay tx has been submitted. + expect(cubit.state, isA()); + // 100 * 1.01 slippage buffer. + expect(sentDto!.targetAmount, closeTo(101, 0.0001)); + expect(sentDto!.amount, isNull); + await cubit.close(); + }); + + test('happy path: swap → refresh quote → pay → polled Completed → success', () async { + fakeAsync((async) { + wireHappyPath(); + when(() => payService.getPayStatus('pl_abc')).thenAnswer( + (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed), + ); + + final cubit = build(); + cubit.start(); + drain(async); + + // Pay submitted → polling status. + expect(cubit.state, isA()); + + // First status poll @ 3s returns Completed → success. + async.elapse(const Duration(seconds: 3)); + drain(async); + expect(cubit.state, isA()); + + cubit.close(); + async.flushTimers(); + }); + }); + + test('re-fetched quote sends a fresh quoteId into the pay step', () async { + wireHappyPath(); + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => + _details(expiration: DateTime.now().add(const Duration(minutes: 5)), quoteId: 'q_fresh2'), + ); + RealUnitOcpPayDto? payDto; + when(() => payService.createPayUnsignedTransaction(any())).thenAnswer((invocation) async { + payDto = invocation.positionalArguments.first as RealUnitOcpPayDto; + return _unsignedPay; + }); + + final cubit = build(); + final settled = cubit.stream.firstWhere((s) => s is PayProcessAwaitingSettlement); + await cubit.start(); + await settled; + + expect(payDto!.quoteId, 'q_fresh2'); + await cubit.close(); + }); + + test('quote expired between swap and pay → quoteExpired', () async { + wireHappyPath(); + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => _details(expiration: DateTime.now().subtract(const Duration(minutes: 1))), + ); + + final cubit = build(); + final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + await cubit.start(); + final state = await failed as PayProcessFailure; + + expect(state.reason, PayProcessFailureReason.quoteExpired); + verifyNever(() => payService.createPayUnsignedTransaction(any())); + await cubit.close(); + }); + + test('pay submit failure → payFailed', () async { + wireHappyPath(); + when(() => payService.submitPay(any())).thenThrow(Exception('settlement rejected')); + + final cubit = build(); + final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + await cubit.start(); + final state = await failed as PayProcessFailure; + + expect(state.reason, PayProcessFailureReason.payFailed); + await cubit.close(); + }); + + test('terminal non-completed status (Cancelled) → payFailed', () async { + fakeAsync((async) { + wireHappyPath(); + when(() => payService.getPayStatus('pl_abc')).thenAnswer( + (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.cancelled), + ); + + final cubit = build(); + cubit.start(); + drain(async); + expect(cubit.state, isA()); + + async.elapse(const Duration(seconds: 3)); + drain(async); + final state = cubit.state as PayProcessFailure; + expect(state.reason, PayProcessFailureReason.payFailed); + + cubit.close(); + async.flushTimers(); + }); + }); + + test('status polling ignores a transient error then settles', () async { + fakeAsync((async) { + wireHappyPath(); + var call = 0; + when(() => payService.getPayStatus('pl_abc')).thenAnswer((_) async { + call++; + if (call == 1) throw Exception('rpc 503'); + return const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed); + }); + + final cubit = build(); + cubit.start(); + drain(async); + + // 1st poll throws → still awaiting. + async.elapse(const Duration(seconds: 3)); + drain(async); + expect(cubit.state, isA()); + + // 2nd poll completes → success. + async.elapse(const Duration(seconds: 3)); + drain(async); + expect(cubit.state, isA()); + + cubit.close(); + async.flushTimers(); + }); + }); + + test('low ETH balance → faucet → eth polling crosses threshold → swap proceeds', () async { + fakeAsync((async) { + wireHappyPath(); + when( + () => payService.getSwapPaymentInfo(any()), + ).thenAnswer((_) async => _swap(ethBalance: 0, requiredGasEth: 0.001)); + when( + () => faucet.requestFaucet(), + ).thenAnswer((_) async => const FaucetResponseDto(txId: '0xf', amount: 0.01)); + var balanceCall = 0; + when(() => blockchain.getEthBalance(any())).thenAnswer((_) async { + balanceCall++; + return balanceCall == 1 ? 0.0 : 0.01; + }); + when(() => payService.getPayStatus('pl_abc')).thenAnswer( + (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed), + ); + + final cubit = build(); + cubit.start(); + drain(async); + expect(cubit.state, isA()); + + // 1st eth poll @ 5s — still 0. + async.elapse(const Duration(seconds: 5)); + drain(async); + expect(cubit.state, isA()); + + // 2nd eth poll @ 10s — funded → swap runs through to settlement polling. + async.elapse(const Duration(seconds: 5)); + drain(async); + expect(cubit.state, isA()); + + // status poll completes the flow. + async.elapse(const Duration(seconds: 3)); + drain(async); + expect(cubit.state, isA()); + + cubit.close(); + async.flushTimers(); + }); + }); + + test('faucet request failure → insufficientEth', () async { + when( + () => payService.getSwapPaymentInfo(any()), + ).thenAnswer((_) async => _swap(ethBalance: 0, requiredGasEth: 0.001)); + when(() => faucet.requestFaucet()).thenThrow(Exception('faucet down')); + + final cubit = build(); + final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + await cubit.start(); + final state = await failed as PayProcessFailure; + + expect(state.reason, PayProcessFailureReason.insufficientEth); + await cubit.close(); + }); + + test('UnsupportedError while signing → signatureUnsupported', () async { + wireHappyPath(); + // Wallet reports software type (passes the start() gate) but the credentials + // throw UnsupportedError on sign — exercises the in-sign defensive guard. + when(() => account.primaryAddress).thenReturn(_UnsupportedCreds()); + + final cubit = build(); + final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + await cubit.start(); + final state = await failed as PayProcessFailure; + + expect(state.reason, PayProcessFailureReason.signatureUnsupported); + await cubit.close(); + }); + + test('swap quote fetch failure → generic', () async { + when(() => payService.getSwapPaymentInfo(any())).thenThrow(Exception('api 500')); + + final cubit = build(); + final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + await cubit.start(); + final state = await failed as PayProcessFailure; + + expect(state.reason, PayProcessFailureReason.generic); + await cubit.close(); + }); + + test('BitBox disconnect during the swap sign → bitboxRequired', () async { + wireHappyPath(); + when(() => account.primaryAddress).thenReturn( + FakeBitboxCredentials(behavior: FakeBitboxBehavior.disconnect, signDelay: Duration.zero), + ); + + final cubit = build(); + final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + await cubit.start(); + final state = await failed as PayProcessFailure; + + expect(state.reason, PayProcessFailureReason.bitboxRequired); + await cubit.close(); + }); + + test('generic sign failure during the swap → generic', () async { + wireHappyPath(); + when(() => account.primaryAddress).thenReturn( + FakeBitboxCredentials(behavior: FakeBitboxBehavior.malformed, signDelay: Duration.zero), + ); + + final cubit = build(); + final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + await cubit.start(); + final state = await failed as PayProcessFailure; + + expect(state.reason, PayProcessFailureReason.generic); + await cubit.close(); + }); + + test('quote re-fetch failure between swap and pay → quoteExpired', () async { + wireHappyPath(); + when(() => payService.getPaymentDetails('pl_abc')).thenThrow(Exception('lnurlp 500')); + + final cubit = build(); + final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + await cubit.start(); + final state = await failed as PayProcessFailure; + + expect(state.reason, PayProcessFailureReason.quoteExpired); + await cubit.close(); + }); + + test('pay step on testnet (service fail-fast) → payUnsupportedEnvironment', () async { + wireHappyPath(); + when( + () => payService.createPayUnsignedTransaction(any()), + ).thenThrow(const PayUnsupportedEnvironmentException()); + + final cubit = build(); + final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + await cubit.start(); + final state = await failed as PayProcessFailure; + + expect(state.reason, PayProcessFailureReason.payUnsupportedEnvironment); + await cubit.close(); + }); + + test('BitBox disconnect during the pay sign → bitboxRequired', () async { + wireHappyPath(); + // First sign (swap) succeeds; the second sign (pay) reports a dropped BLE + // link, exercising the pay-step BitboxNotConnectedException branch. + final creds = _CountingSignCreds( + throwOnCall: 2, + error: const BitboxNotConnectedException(), + ); + when(() => account.primaryAddress).thenReturn(creds); + + final cubit = build(); + final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + await cubit.start(); + final state = await failed as PayProcessFailure; + + expect(creds.calls, 2); + expect(state.reason, PayProcessFailureReason.bitboxRequired); + await cubit.close(); + }); + + test('non-signing wallet detected only at the pay sign → signatureUnsupported', () async { + wireHappyPath(); + // Swap sign succeeds; the pay sign hits a non-signing credential + // (UnsupportedError), exercising the pay-step signatureUnsupported branch. + final creds = _CountingSignCreds( + throwOnCall: 2, + error: UnsupportedError('cannot sign'), + ); + when(() => account.primaryAddress).thenReturn(creds); + + final cubit = build(); + final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + await cubit.start(); + final state = await failed as PayProcessFailure; + + expect(creds.calls, 2); + expect(state.reason, PayProcessFailureReason.signatureUnsupported); + await cubit.close(); + }); +} + +/// Credentials that produce a real signature for every sign except the +/// [throwOnCall]-th, which throws [error]. Lets a test target the swap (call 1) +/// vs. the pay (call 2) sign deterministically. +class _CountingSignCreds extends Fake implements CredentialsWithKnownAddress { + _CountingSignCreds({required this.throwOnCall, required this.error}); + + final int throwOnCall; + final Object error; + int calls = 0; + + @override + EthereumAddress get address => + EthereumAddress.fromHex('0x9F5713dEAcb8e9CaB6c2D3FaE1aFc2715F8D2D71'); + + @override + Future signToSignature( + Uint8List payload, { + int? chainId, + bool isEIP1559 = false, + }) async { + calls++; + if (calls == throwOnCall) throw error; + return EthPrivateKey.fromHex( + 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612', + ).signToSignature(payload, chainId: chainId, isEIP1559: isEIP1559); + } +} diff --git a/test/screens/pay/pay_process_state_test.dart b/test/screens/pay/pay_process_state_test.dart new file mode 100644 index 00000000..28713411 --- /dev/null +++ b/test/screens/pay/pay_process_state_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart'; + +void main() { + group('PayProcessState equality (Equatable props)', () { + test('progress states with no fields expose empty props and compare by type', () { + // Reading `.props` directly evaluates the inherited base getter (const + // canonicalization would otherwise make `==` short-circuit via identical). + expect(const PayProcessPreparingSwap().props, isEmpty); + expect(const PayProcessWaitingForEth().props, isEmpty); + expect(const PayProcessInitial().props, isEmpty); + expect(const PayProcessSwapping().props, isEmpty); + expect(const PayProcessRefreshingQuote().props, isEmpty); + expect(const PayProcessPaying().props, isEmpty); + expect(const PayProcessSuccess().props, isEmpty); + expect( + const PayProcessPreparingSwap(), + isNot(equals(const PayProcessWaitingForEth())), + ); + }); + + test('PayProcessAwaitingSettlement is keyed on txId', () { + expect( + const PayProcessAwaitingSettlement('0xtx'), + const PayProcessAwaitingSettlement('0xtx'), + ); + expect( + const PayProcessAwaitingSettlement('0xtx'), + isNot(equals(const PayProcessAwaitingSettlement('0xother'))), + ); + expect(const PayProcessAwaitingSettlement('0xtx').props, ['0xtx']); + }); + + test('PayProcessFailure is keyed on reason + message', () { + expect( + const PayProcessFailure(PayProcessFailureReason.payFailed), + const PayProcessFailure(PayProcessFailureReason.payFailed), + ); + expect( + const PayProcessFailure(PayProcessFailureReason.payFailed), + isNot(equals(const PayProcessFailure(PayProcessFailureReason.quoteExpired))), + ); + expect( + const PayProcessFailure(PayProcessFailureReason.generic, message: 'boom').props, + [PayProcessFailureReason.generic, 'boom'], + ); + }); + }); +} diff --git a/test/screens/pay/pay_quote_cubit_test.dart b/test/screens/pay/pay_quote_cubit_test.dart new file mode 100644 index 00000000..a3401632 --- /dev/null +++ b/test/screens/pay/pay_quote_cubit_test.dart @@ -0,0 +1,99 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.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/real_unit_pay_service.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart'; + +class _MockPayService extends Mock implements RealUnitPayService {} + +LnurlpPaymentDto _details({ + required DateTime expiration, + bool withEthZchf = true, + double zchf = 42.7, +}) { + return LnurlpPaymentDto( + requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 42.5), + quote: LnurlpQuoteDto(id: 'quote_xyz', expiration: expiration), + recipient: '0xrecipient', + transferAmounts: [ + if (withEthZchf) + LnurlpTransferAmountDto( + method: 'Ethereum', + assets: [LnurlpTransferAssetDto(asset: 'ZCHF', amount: zchf)], + ) + else + const LnurlpTransferAmountDto( + method: 'Bitcoin', + assets: [LnurlpTransferAssetDto(asset: 'BTC', amount: 0.0005)], + ), + ], + ); +} + +void main() { + late _MockPayService payService; + + setUp(() => payService = _MockPayService()); + + PayQuoteCubit build() => PayQuoteCubit(payService, 'pl_abc'); + + blocTest( + 'a fresh quote with an Ethereum/ZCHF method emits PayQuoteReady', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => _details(expiration: DateTime.now().add(const Duration(minutes: 5))), + ); + }, + act: (cubit) => cubit.load(), + expect: () => [isA(), isA()], + verify: (cubit) { + final state = cubit.state as PayQuoteReady; + expect(state.quoteId, 'quote_xyz'); + expect(state.fiatAsset, 'CHF'); + expect(state.fiatAmount, 42.5); + expect(state.zchfAmount, 42.7); + }, + ); + + blocTest( + 'an expired quote emits PayQuoteExpired', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => _details(expiration: DateTime.now().subtract(const Duration(minutes: 1))), + ); + }, + act: (cubit) => cubit.load(), + expect: () => [isA(), isA()], + ); + + blocTest( + 'a link without an Ethereum/ZCHF method emits PayQuoteUnavailable', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => _details( + expiration: DateTime.now().add(const Duration(minutes: 5)), + withEthZchf: false, + ), + ); + }, + act: (cubit) => cubit.load(), + expect: () => [isA(), isA()], + ); + + blocTest( + 'a service error emits PayQuoteError', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_abc')).thenThrow( + const ApiException(code: 'X', message: 'boom'), + ); + }, + act: (cubit) => cubit.load(), + expect: () => [isA(), isA()], + ); +} diff --git a/test/screens/pay/pay_scan_cubit_test.dart b/test/screens/pay/pay_scan_cubit_test.dart new file mode 100644 index 00000000..5d17648f --- /dev/null +++ b/test/screens/pay/pay_scan_cubit_test.dart @@ -0,0 +1,51 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart'; + +void main() { + // LUD-01 bech32 of `https://api.dfx.swiss/v1/lnurlp/pl_abc123`. + const lnurl = 'LNURL1DP68GURN8GHJ7CTSDYHXGENC9EEHW6TNWVHHVVF0D3H82UNVWQHHQMZLV93XXVFJXV5T0E5A'; + + group('PayScanCubit', () { + test('starts in PayScanScanning', () { + expect(PayScanCubit().state, isA()); + }); + + blocTest( + 'a valid LNURL emits PayScanDecoded with the id', + build: PayScanCubit.new, + act: (cubit) => cubit.onCodeDetected(lnurl), + verify: (cubit) { + final state = cubit.state as PayScanDecoded; + expect(state.link.id, 'pl_abc123'); + }, + ); + + blocTest( + 'an invalid code emits PayScanInvalid', + build: PayScanCubit.new, + act: (cubit) => cubit.onCodeDetected('not-a-payment-code'), + expect: () => [isA()], + ); + + blocTest( + 'ignores further detections once decoded (no re-emit)', + build: PayScanCubit.new, + act: (cubit) { + cubit.onCodeDetected(lnurl); + cubit.onCodeDetected(lnurl); + }, + expect: () => [isA()], + ); + + blocTest( + 'reset returns to PayScanScanning', + build: PayScanCubit.new, + act: (cubit) { + cubit.onCodeDetected('bad'); + cubit.reset(); + }, + expect: () => [isA(), isA()], + ); + }); +} From 83dd855b14827e552fd2be81bce89420634cd1fe Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:48:43 +0200 Subject: [PATCH 2/7] =?UTF-8?q?fix(pay):=20make=20the=20two-leg=20swap?= =?UTF-8?q?=E2=86=92pay=20flow=20fund-safe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address reviewer findings on the irreversible REALU→ZCHF swap → OCP pay flow so a failed pay leg can never strand the user or force a re-swap. Fund safety / orchestration: - Hoist the mainnet-only environment gate to the very start of the flow (PayProcessCubit.start and PayQuoteCubit.load), gated off the new RealUnitPayService.isPaySupportedEnvironment getter. The swap can no longer run on an environment where the pay leg cannot settle; the service keeps assertPaySupported as defense-in-depth. - Add a pay-only retry after a successful swap: track swap completion + acquired ZCHF in cubit state and expose retryPay(), which re-quotes + signs + submits WITHOUT re-swapping (mirrors SellBitboxDepositRetry). A failed pay surfaces the new PayProcessPayRetry state instead of a terminal failure, so it never forces a re-scan → re-swap. - Distinguish genuine quote expiry (expiration.isBefore) from transient fetch/submit errors; both route to the pay-only retry, neither to a re-scan. Terminal non-completed settlement is retryable too. - Widen the swap headroom buffer 1.01 → 1.03 (documented) and add a typed insufficient-ZCHF-after-swap retry state when the fresh settlement amount exceeds the acquired ZCHF, instead of a server-side failure. Parsing robustness: - lnurlp DTO: parse transfer-asset amount as nullable (optional on the non-priced path) and remove the dead recipient field (a backend object, never read, that threw when populated). - Remove the dead RealUnitSwapDto.fromAmount constructor and its coverage-ignore; the flow only uses fromTargetAmount. Quality: - Fix import ordering in real_unit_pay_service. Tests: bloc_test cases for env-unsupported-before-swap, pay-only retry, transient-fetch → retry (not re-scan), insufficient-ZCHF typed state, and retryPay-never-re-swaps; nullable-amount + object-recipient DTO parsing; isPaySupportedEnvironment. i18n payRetry* keys added to both ARB files. --- assets/languages/strings_de.arb | 6 +- assets/languages/strings_en.arb | 6 +- .../payment/pay/dto/lnurlp_payment_dto.dart | 21 ++- .../payment/pay/dto/real_unit_swap_dto.dart | 10 +- .../service/dfx/real_unit_pay_service.dart | 26 +-- .../cubits/pay_process/pay_process_cubit.dart | 120 ++++++++++--- .../cubits/pay_process/pay_process_state.dart | 45 ++++- .../pay/cubits/pay_quote/pay_quote_cubit.dart | 10 ++ .../pay/cubits/pay_quote/pay_quote_state.dart | 6 + lib/screens/pay/pay_process_page.dart | 71 +++++++- lib/screens/pay/pay_quote_page.dart | 3 + .../dfx/models/payment/pay/pay_dtos_test.dart | 48 +++++- .../dfx/real_unit_pay_service_test.dart | 16 +- test/screens/pay/pay_process_cubit_test.dart | 161 +++++++++++++----- test/screens/pay/pay_process_state_test.dart | 23 ++- test/screens/pay/pay_quote_cubit_test.dart | 19 ++- 16 files changed, 484 insertions(+), 107 deletions(-) diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 10eadec3..bc0b3530 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -173,7 +173,6 @@ "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.", - "payFailurePayFailed": "Die Zahlung konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.", "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", @@ -190,6 +189,11 @@ "payQuoteUnavailable": "Für diesen Zahlungscode ist keine ZCHF-Zahlung verfügbar.", "payQuoteZchfNeeded": "Benötigte ZCHF", "payRefreshingQuote": "Angebot wird aktualisiert", + "payRetryButton": "Zahlung erneut versuchen", + "payRetryInsufficientZchf": "Der Preis hat sich geändert und die getauschten ZCHF decken diese Zahlung nicht mehr. Ihre ZCHF bleiben in Ihrer Wallet – versuchen Sie es erneut, um ein neues Angebot zu erhalten.", + "payRetryQuoteExpired": "Das Zahlungsangebot ist vor dem Abschluss abgelaufen. Ihre getauschten ZCHF bleiben in Ihrer Wallet – versuchen Sie es erneut, um ein neues Angebot zu erhalten und zu bezahlen.", + "payRetryTitle": "Schließen Sie Ihre Zahlung ab", + "payRetryTransient": "Die Zahlung konnte nicht abgeschlossen werden, aber Ihre getauschten ZCHF bleiben in Ihrer Wallet. Versuchen Sie es erneut, um ohne erneuten Tausch zu bezahlen.", "payScanInvalid": "Dies ist kein gültiger RealUnit-Zahlungscode.", "payScanTitle": "Zahlungscode scannen", "paySuccess": "Zahlung erfolgreich", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index f1e5df20..19b0a59a 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -173,7 +173,6 @@ "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.", - "payFailurePayFailed": "The payment could not be completed. Please try again.", "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", @@ -190,6 +189,11 @@ "payQuoteUnavailable": "No ZCHF payment is available for this payment code.", "payQuoteZchfNeeded": "ZCHF needed", "payRefreshingQuote": "Refreshing quote", + "payRetryButton": "Retry payment", + "payRetryInsufficientZchf": "The price moved and the swapped ZCHF no longer covers this payment. Your ZCHF stays in your wallet — retry to fetch a new quote.", + "payRetryQuoteExpired": "The payment quote expired before settling. Your swapped ZCHF stays in your wallet — retry to fetch a new quote and pay.", + "payRetryTitle": "Finish your payment", + "payRetryTransient": "The payment could not be completed, but your swapped ZCHF stays in your wallet. Retry to pay without swapping again.", "payScanInvalid": "This is not a valid RealUnit payment code.", "payScanTitle": "Scan payment code", "paySuccess": "Payment successful", diff --git a/lib/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart index 32d3eadb..4f923a57 100644 --- a/lib/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart +++ b/lib/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart @@ -2,10 +2,15 @@ /// no auth). Carries the requested fiat amount and the active quote the app /// needs to size the swap and later settle the payment. Only the fields the pay /// flow consumes are mapped. +/// +/// The backend `recipient` field is a structured `PaymentLinkRecipientDto` +/// object, not a string, and the flow never surfaces a merchant name (the real +/// settlement recipient comes from the pay/unsigned-transaction response). It is +/// therefore not mapped here — eagerly casting it `as String?` threw a +/// `TypeError` whenever the backend populated it. class LnurlpPaymentDto { final LnurlpRequestedAmountDto requestedAmount; final LnurlpQuoteDto quote; - final String? recipient; /// Per-method/chain transfer amounts. The Ethereum entry lists the exact ZCHF /// amount the app must transfer; the app does not compute it locally. @@ -15,7 +20,6 @@ class LnurlpPaymentDto { required this.requestedAmount, required this.quote, required this.transferAmounts, - this.recipient, }); factory LnurlpPaymentDto.fromJson(Map json) { @@ -25,7 +29,6 @@ class LnurlpPaymentDto { json['requestedAmount'] as Map, ), quote: LnurlpQuoteDto.fromJson(json['quote'] as Map), - recipient: json['recipient'] as String?, transferAmounts: transfers .map((e) => LnurlpTransferAmountDto.fromJson(e as Map)) .toList(), @@ -80,14 +83,20 @@ class LnurlpTransferAmountDto { class LnurlpTransferAssetDto { final String asset; - final double amount; - const LnurlpTransferAssetDto({required this.asset, required this.amount}); + /// Optional on the backend (`amount?`): the non-priced display path emits + /// amount-less asset entries. Parsed as nullable so reading the whole quote + /// never throws — the pay flow only requires the amount for the asset it + /// actually transfers (ZCHF on Ethereum), filtered before it is read. + final double? amount; + + const LnurlpTransferAssetDto({required this.asset, this.amount}); factory LnurlpTransferAssetDto.fromJson(Map json) { + final amount = json['amount'] as num?; return LnurlpTransferAssetDto( asset: json['asset'] as String, - amount: (json['amount'] as num).toDouble(), + amount: amount?.toDouble(), ); } } diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart index 33ba336c..e16c9578 100644 --- a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart +++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart @@ -9,13 +9,9 @@ class RealUnitSwapDto { /// Target amount in ZCHF (alternative to [amount]). final double? targetAmount; - // Part of the amount-XOR-targetAmount contract. The OCP pay flow always sizes - // the swap by ZCHF target (fromTargetAmount); this constructor is exercised - // via toJson in unit tests but const-constructed there, so its body never - // registers a runtime line hit. - const RealUnitSwapDto.fromAmount(int this.amount) // coverage:ignore-line - : targetAmount = null; - + // The OCP pay flow always sizes the swap by ZCHF target (fromTargetAmount); + // `amount` stays in the body only to document the backend's XOR contract and + // is always null on the wire here. const RealUnitSwapDto.fromTargetAmount(double this.targetAmount) : amount = null; Map toJson() => { diff --git a/lib/packages/service/dfx/real_unit_pay_service.dart b/lib/packages/service/dfx/real_unit_pay_service.dart index cee92568..6b83e874 100644 --- a/lib/packages/service/dfx/real_unit_pay_service.dart +++ b/lib/packages/service/dfx/real_unit_pay_service.dart @@ -13,9 +13,9 @@ import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real 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/dto/real_unit_swap_payment_info_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_request_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_response_dto.dart'; -import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart'; /// Backend client for the Open CryptoPay pay flow (DFXswiss/api #3819, all under /// `/v1/realunit/...`). Subclasses [DFXAuthService] for the JWT handshake + @@ -103,7 +103,7 @@ class RealUnitPayService extends DFXAuthService { Future createPayUnsignedTransaction( RealUnitOcpPayDto dto, ) async { - _assertPaySupported(); + assertPaySupported(); final uri = buildUri(host, _payUnsignedTxPath); final response = await authenticatedPut( uri, @@ -120,7 +120,7 @@ class RealUnitPayService extends DFXAuthService { } Future submitPay(RealUnitOcpPaySubmitDto dto) async { - _assertPaySupported(); + assertPaySupported(); final uri = buildUri(host, _paySubmitPath); final response = await authenticatedPut( uri, @@ -148,13 +148,19 @@ class RealUnitPayService extends DFXAuthService { ); } - /// The OCP payment-link engine settles on mainnet only. On testnet the - /// pay/* endpoints fail fast server-side with a 400; we mirror that as a - /// typed, surfaced failure before the round-trip rather than parsing the - /// backend error body. This is a backend-environment capability gate keyed - /// off [ApiConfig], not local business logic. - void _assertPaySupported() { - if (appStore.apiConfig.networkMode.isTestnet) { + /// Whether the current backend environment can settle an OCP payment. The + /// payment-link engine is mainnet-only; on testnet the pay/* endpoints fail + /// fast server-side with a 400. This is environment-static (keyed off + /// [ApiConfig]), so the flow can read it BEFORE the irreversible REALU→ZCHF + /// swap and refuse to swap on an environment where the pay leg can never + /// settle. Not local business logic — purely a capability gate. + bool get isPaySupportedEnvironment => !appStore.apiConfig.networkMode.isTestnet; + + /// Defense-in-depth mirror of [isPaySupportedEnvironment] on the pay/* calls: + /// even though the flow gates up-front, surface the typed failure before the + /// round-trip rather than parsing the backend error body. + void assertPaySupported() { + if (!isPaySupportedEnvironment) { throw const PayUnsupportedEnvironmentException(); } } 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 f761b0fe..9870e468 100644 --- a/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart +++ b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart @@ -9,6 +9,7 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service. import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.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_ocp_pay_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart'; @@ -45,12 +46,30 @@ 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]. + bool _swapCompleted = false; + + /// ZCHF acquired by the (completed) swap — the backend `estimatedAmount` of + /// the swap quote. Used to detect when a freshly re-fetched settlement amount + /// can no longer be covered by what we actually hold. + double _acquiredZchf = 0; + Timer? _ethPollingTimer; Timer? _statusPollingTimer; - /// Small buffer over the OCP ZCHF amount so the swap target covers the OCP - /// fee/min-fee and price slippage between quoting and settling. - static const _slippageBuffer = 1.01; + /// Headroom over the OCP ZCHF amount when sizing the swap target. The swap is + /// quoted/broadcast against the ORIGINAL OCP quote, but the pay step settles + /// the EXACT amount of a FRESHLY re-fetched quote; in between, the OCP price + /// (CHF→ZCHF) and the swap rate can both move. A 1% buffer left no margin for + /// 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 + /// is caught explicitly and surfaced as a retryable + /// [PayRetryReason.insufficientZchf] rather than a server-side failure. + static const _slippageBuffer = 1.03; static const _ethPollInterval = Duration(seconds: 5); static const _statusPollInterval = Duration(seconds: 3); @@ -74,6 +93,15 @@ class PayProcessCubit extends Cubit { /// Entry point — called by the view once the user confirms the quote. Future start() async { + // Environment capability gate — checked BEFORE any on-chain action. The + // REALU→ZCHF swap is irreversible; if OCP settlement can never succeed on + // this environment (mainnet-only), refuse here so the user is never swapped + // into ZCHF and then told "mainnet only". This is environment-static, so it + // is safe (and required) to evaluate before the swap is signed/broadcast. + if (!_payService.isPaySupportedEnvironment) { + emit(const PayProcessFailure(PayProcessFailureReason.payUnsupportedEnvironment)); + return; + } if (_appStore.wallet.walletType == WalletType.debug) { emit(const PayProcessFailure(PayProcessFailureReason.signatureUnsupported)); return; @@ -143,6 +171,10 @@ class PayProcessCubit extends Cubit { final unsigned = await _payService.createSwapUnsignedTransaction(swap.id); final signed = await _signTransaction(unsigned.swap); await _payService.broadcastSwapTransaction(swap.id, signed); + // The swap is now irreversible — the user holds ZCHF. From here every + // recovery path retries the PAY leg only; the swap is never redone. + _swapCompleted = true; + _acquiredZchf = swap.estimatedAmount; await _refreshQuoteAndPay(); } on PaySignatureUnsupportedException { emit(const PayProcessFailure(PayProcessFailureReason.signatureUnsupported)); @@ -153,20 +185,56 @@ class PayProcessCubit extends Cubit { } } + /// Retries the pay leg ONLY, after a successful swap. Re-fetches the OCP quote + /// and re-runs sign + submit; it never re-swaps (guarded by [_swapCompleted]), + /// so the ZCHF already in the wallet is reused and REALU is never + /// double-converted. Wired to the retry action on [PayProcessPayRetry]. + Future retryPay() async { + if (!_swapCompleted) return; + await _refreshQuoteAndPay(); + } + /// Re-reads the OCP quote so the pay step uses a fresh quoteId — the swap may - /// have taken longer than the original quote's validity window. + /// have taken longer than the original quote's validity window. Runs both on + /// the first pay attempt (right after the swap) and on every [retryPay]. + /// + /// A GENUINE expiry (the explicit `expiration.isBefore(now)` check) and a + /// TRANSIENT fetch error are kept distinct: both are recoverable by retrying + /// the pay leg, so neither forces a re-scan → re-swap. Future _refreshQuoteAndPay() async { + final LnurlpPaymentDto details; try { emit(const PayProcessRefreshingQuote()); - final details = await _payService.getPaymentDetails(_paymentLinkId); - if (details.quote.expiration.isBefore(DateTime.now())) { - emit(const PayProcessFailure(PayProcessFailureReason.quoteExpired)); - return; - } - await _executePay(details.quote.id); + details = await _payService.getPaymentDetails(_paymentLinkId); } catch (e) { - emit(PayProcessFailure(PayProcessFailureReason.quoteExpired, message: e.toString())); + // Transient/network error fetching the quote — NOT a genuine expiry. + // Retry the pay leg; the swapped ZCHF stays in the wallet. + emit(PayProcessPayRetry(PayRetryReason.transient, message: e.toString())); + return; + } + + if (details.quote.expiration.isBefore(DateTime.now())) { + emit(const PayProcessPayRetry(PayRetryReason.quoteExpired)); + return; + } + + // Guard the slippage boundary: the swap acquired [_acquiredZchf], but the + // fresh quote may now demand more ZCHF than that. Settling it would fail + // server-side AFTER the irreversible swap, so surface a typed, retryable + // state (re-quote may land within the held ZCHF) instead of an opaque + // failure. The leftover ZCHF stays in the wallet. + final freshZchf = _zchfTransferAmount(details); + if (freshZchf != null && freshZchf > _acquiredZchf) { + emit( + PayProcessPayRetry( + PayRetryReason.insufficientZchf, + message: 'fresh settlement $freshZchf ZCHF exceeds acquired $_acquiredZchf ZCHF', + ), + ); + return; } + + await _executePay(details.quote.id); } Future _executePay(String quoteId) async { @@ -189,15 +257,26 @@ class PayProcessCubit extends Cubit { ); emit(PayProcessAwaitingSettlement(txId)); _startStatusPolling(); - } on PayUnsupportedEnvironmentException { - emit(const PayProcessFailure(PayProcessFailureReason.payUnsupportedEnvironment)); - } on PaySignatureUnsupportedException { - emit(const PayProcessFailure(PayProcessFailureReason.signatureUnsupported)); - } on BitboxNotConnectedException { - emit(const PayProcessFailure(PayProcessFailureReason.bitboxRequired)); } catch (e) { - emit(PayProcessFailure(PayProcessFailureReason.payFailed, message: e.toString())); + // The swap already happened; the user holds ZCHF. Any pay-leg failure + // here (signing dropped, BitBox disconnect, transient submit error, + // settlement rejected) is recoverable by retrying the pay leg — never by + // re-swapping. Surface the retryable state rather than a terminal failure. + emit(PayProcessPayRetry(PayRetryReason.transient, message: e.toString())); + } + } + + /// The ZCHF amount listed for the Ethereum transfer method in a fresh quote, + /// or null if the link no longer offers a priced Ethereum/ZCHF method. Mirrors + /// [PayQuoteCubit]'s selection — the app never computes the amount locally. + static double? _zchfTransferAmount(LnurlpPaymentDto details) { + for (final transfer in details.transferAmounts) { + if (transfer.method.toLowerCase() != 'ethereum') continue; + for (final asset in transfer.assets) { + if (asset.asset.toUpperCase() == 'ZCHF') return asset.amount; + } } + return null; } void _startStatusPolling() { @@ -210,7 +289,10 @@ class PayProcessCubit extends Cubit { if (status.status.isCompleted) { emit(const PayProcessSuccess()); } else { - emit(const PayProcessFailure(PayProcessFailureReason.payFailed)); + // The engine reached a terminal non-completed status (e.g. the quote + // expired or was cancelled before it settled). The user still holds + // the swapped ZCHF, so this is recoverable by retrying the pay leg. + emit(const PayProcessPayRetry(PayRetryReason.transient)); } } catch (_) { // keep polling on transient errors 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 6973324e..f7894c83 100644 --- a/lib/screens/pay/cubits/pay_process/pay_process_state.dart +++ b/lib/screens/pay/cubits/pay_process/pay_process_state.dart @@ -10,15 +10,9 @@ enum PayProcessFailureReason { /// Not enough ETH to cover gas and the faucet top-up did not arrive. insufficientEth, - /// The OCP quote expired between the swap and the pay step. - quoteExpired, - - /// Open CryptoPay settlement failed (rejected by the engine or a terminal - /// non-completed status). - payFailed, - /// Open CryptoPay settlement is unavailable on the current backend - /// environment (mainnet-only; fails fast on testnet). + /// environment (mainnet-only; checked BEFORE the swap so it never strands the + /// user in ZCHF). payUnsupportedEnvironment, /// The active wallet mode cannot sign transactions (debug wallet). @@ -31,6 +25,24 @@ enum PayProcessFailureReason { generic, } +/// Why the pay leg failed AFTER the REALU→ZCHF swap already succeeded. The user +/// holds ZCHF, so recovery must retry the pay leg ONLY (re-quote + sign + +/// submit) — never the swap. Each reason maps to a localized message. +enum PayRetryReason { + /// The OCP quote expired between the swap and the pay step. Re-quoting is + /// safe — the swapped ZCHF stays in the wallet. + quoteExpired, + + /// A transient/network error while re-fetching the quote or settling. Not a + /// genuine expiry; retrying the pay leg is the correct recovery. + transient, + + /// The freshly re-fetched settlement amount exceeds the ZCHF acquired by the + /// swap (price moved more than the swap headroom buffer). Re-quoting may land + /// within the held ZCHF; the leftover ZCHF stays in the wallet meanwhile. + insufficientZchf, +} + sealed class PayProcessState extends Equatable { const PayProcessState(); @@ -76,6 +88,23 @@ class PayProcessSuccess extends PayProcessState { const PayProcessSuccess(); } +/// The swap succeeded (ZCHF is in the wallet) but the pay leg failed. Recoverable +/// by retrying the pay leg ONLY — the view calls [PayProcessCubit.retryPay], +/// which re-quotes + signs + submits without ever re-swapping. This is the key +/// fund-safety state: a failed pay no longer forces a re-scan → re-swap (which +/// would double-convert REALU). +class PayProcessPayRetry extends PayProcessState { + final PayRetryReason reason; + + /// Diagnostic detail for logs — not the user-facing copy. + final String? message; + + const PayProcessPayRetry(this.reason, {this.message}); + + @override + List get props => [reason, message]; +} + class PayProcessFailure extends PayProcessState { final PayProcessFailureReason reason; 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 c4f1a848..6180400c 100644 --- a/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart +++ b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart @@ -18,6 +18,16 @@ class PayQuoteCubit extends Cubit { Future load() async { emit(const PayQuoteLoading()); + + // Gate the irreversible flow up-front: if OCP settlement can never succeed + // on this environment, surface it now — before the user can confirm a quote + // and trigger the REALU→ZCHF swap. The swap must never run where the pay + // leg cannot settle. + if (!_payService.isPaySupportedEnvironment) { + emit(const PayQuoteUnsupportedEnvironment()); + return; + } + try { final details = await _payService.getPaymentDetails(_paymentLinkId); diff --git a/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart b/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart index 0984f573..9125644e 100644 --- a/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart +++ b/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart @@ -40,6 +40,12 @@ class PayQuoteUnavailable extends PayQuoteState { const PayQuoteUnavailable(); } +/// OCP settlement is unavailable on the current backend environment +/// (mainnet-only). Surfaced before the swap so it can never run on testnet. +class PayQuoteUnsupportedEnvironment extends PayQuoteState { + const PayQuoteUnsupportedEnvironment(); +} + class PayQuoteError extends PayQuoteState { final String message; diff --git a/lib/screens/pay/pay_process_page.dart b/lib/screens/pay/pay_process_page.dart index 2e56b990..0d7f9fb9 100644 --- a/lib/screens/pay/pay_process_page.dart +++ b/lib/screens/pay/pay_process_page.dart @@ -44,6 +44,10 @@ class PayProcessView extends StatelessWidget { @override Widget build(BuildContext context) { return BlocConsumer( + listenWhen: (previous, current) => + current is PayProcessSuccess || + current is PayProcessFailure || + current is PayProcessPayRetry, listener: (context, state) async { if (state is PayProcessSuccess) { await _showResultSheet( @@ -52,6 +56,10 @@ class PayProcessView extends StatelessWidget { title: S.of(context).paySuccess, description: S.of(context).paySuccessDescription, ); + } else if (state is PayProcessPayRetry) { + // The swap already succeeded — offer to retry the PAY leg only. The + // ZCHF stays in the wallet; this never re-swaps. + await _showRetrySheet(context, state.reason); } else if (state is PayProcessFailure) { await _showResultSheet( context, @@ -93,14 +101,13 @@ class PayProcessView extends StatelessWidget { PayProcessPaying() => S.of(context).payPaying, PayProcessAwaitingSettlement() => S.of(context).payAwaitingSettlement, PayProcessSuccess() => S.of(context).paySuccess, + PayProcessPayRetry() => S.of(context).payRetryTitle, PayProcessFailure() => S.of(context).payFailureTitle, }; String _failureMessage(BuildContext context, PayProcessFailureReason reason) => switch (reason) { PayProcessFailureReason.insufficientZchf => S.of(context).payFailureInsufficientZchf, PayProcessFailureReason.insufficientEth => S.of(context).payFailureInsufficientEth, - PayProcessFailureReason.quoteExpired => S.of(context).payFailureQuoteExpired, - PayProcessFailureReason.payFailed => S.of(context).payFailurePayFailed, PayProcessFailureReason.payUnsupportedEnvironment => S.of(context).payFailureUnsupportedEnvironment, PayProcessFailureReason.signatureUnsupported => S.of(context).payFailureSignatureUnsupported, @@ -108,6 +115,12 @@ class PayProcessView extends StatelessWidget { PayProcessFailureReason.generic => S.of(context).payFailureGeneric, }; + String _retryMessage(BuildContext context, PayRetryReason reason) => switch (reason) { + PayRetryReason.quoteExpired => S.of(context).payRetryQuoteExpired, + PayRetryReason.transient => S.of(context).payRetryTransient, + PayRetryReason.insufficientZchf => S.of(context).payRetryInsufficientZchf, + }; + Future _showResultSheet( BuildContext context, { required IconData icon, @@ -144,4 +157,58 @@ class PayProcessView extends StatelessWidget { ); if (context.mounted) Navigator.of(context).pop(); } + + /// Recovery sheet shown after a successful swap when the pay leg failed. The + /// primary action retries the PAY leg only ([PayProcessCubit.retryPay]) — the + /// swap is never redone, so the ZCHF already held is reused. Dismissing leaves + /// that ZCHF safely in the wallet. + Future _showRetrySheet(BuildContext context, PayRetryReason reason) async { + final cubit = context.read(); + // The sheet returns true when the user retries (keep the page) and false + // when they close (leave the flow); a barrier dismissal yields null. + final retry = await showModalBottomSheet( + context: context, + isDismissible: false, + builder: (sheetContext) => SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 24, + children: [ + const Icon(Icons.replay_rounded, color: RealUnitColors.realUnitBlue, size: 64), + Text( + S.of(sheetContext).payRetryTitle, + style: Theme.of(sheetContext).textTheme.headlineMedium, + ), + Text( + _retryMessage(sheetContext, reason), + textAlign: TextAlign.center, + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + FilledButton( + onPressed: () => Navigator.of(sheetContext).pop(true), + child: Text(S.of(sheetContext).payRetryButton), + ), + TextButton( + onPressed: () => Navigator.of(sheetContext).pop(false), + child: Text(S.of(sheetContext).close), + ), + ], + ), + ), + ), + ); + + if (retry == true) { + // Retry the PAY leg only — never re-swaps. Keep the page so the next + // attempt surfaces its own result. + await cubit.retryPay(); + } else if (context.mounted) { + // Closed: leave the flow. The swapped ZCHF stays safely in the wallet. + Navigator.of(context).pop(); + } + } } diff --git a/lib/screens/pay/pay_quote_page.dart b/lib/screens/pay/pay_quote_page.dart index fc77fed4..7708b29a 100644 --- a/lib/screens/pay/pay_quote_page.dart +++ b/lib/screens/pay/pay_quote_page.dart @@ -38,6 +38,9 @@ class PayQuoteView extends StatelessWidget { PayQuoteReady() => _PayQuoteReadyView(state: state), PayQuoteExpired() => _PayQuoteMessage(message: S.of(context).payFailureQuoteExpired), PayQuoteUnavailable() => _PayQuoteMessage(message: S.of(context).payQuoteUnavailable), + PayQuoteUnsupportedEnvironment() => _PayQuoteMessage( + message: S.of(context).payFailureUnsupportedEnvironment, + ), PayQuoteError() => _PayQuoteMessage(message: S.of(context).payFailureGeneric), }, ), diff --git a/test/packages/service/dfx/models/payment/pay/pay_dtos_test.dart b/test/packages/service/dfx/models/payment/pay/pay_dtos_test.dart index 5d192b69..eab27d41 100644 --- a/test/packages/service/dfx/models/payment/pay/pay_dtos_test.dart +++ b/test/packages/service/dfx/models/payment/pay/pay_dtos_test.dart @@ -12,11 +12,7 @@ import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_pay void main() { group('RealUnitSwapDto', () { - test('fromAmount serialises only amount', () { - expect(const RealUnitSwapDto.fromAmount(10).toJson(), {'amount': 10}); - }); - - test('fromTargetAmount serialises only targetAmount', () { + test('fromTargetAmount serialises only targetAmount (no amount key)', () { expect( const RealUnitSwapDto.fromTargetAmount(95.5).toJson(), {'targetAmount': 95.5}, @@ -230,7 +226,6 @@ void main() { group('LnurlpPaymentDto.fromJson', () { test('maps requestedAmount, quote and ZCHF transfer amounts', () { final dto = LnurlpPaymentDto.fromJson({ - 'recipient': '0xrecipient', 'requestedAmount': {'asset': 'CHF', 'amount': 42.5}, 'quote': {'id': 'quote_xyz', 'expiration': '2026-06-03T12:00:00.000Z'}, 'transferAmounts': [ @@ -252,7 +247,6 @@ void main() { expect(dto.requestedAmount.asset, 'CHF'); expect(dto.requestedAmount.amount, 42.5); expect(dto.quote.id, 'quote_xyz'); - expect(dto.recipient, '0xrecipient'); expect(dto.transferAmounts, hasLength(2)); expect(dto.transferAmounts.first.method, 'Ethereum'); expect(dto.transferAmounts.first.assets.first.asset, 'ZCHF'); @@ -267,5 +261,45 @@ void main() { expect(dto.transferAmounts, isEmpty); }); + + test('parses amount-less asset entries (non-priced display path) as null', () { + final dto = LnurlpPaymentDto.fromJson({ + 'requestedAmount': {'asset': 'CHF', 'amount': 1.0}, + 'quote': {'id': 'q', 'expiration': '2026-06-03T12:00:00.000Z'}, + 'transferAmounts': [ + { + 'method': 'Ethereum', + 'assets': [ + // Optional `amount?` omitted by the backend. + {'asset': 'ZCHF'}, + ], + }, + ], + }); + + expect(dto.transferAmounts.first.assets.first.asset, 'ZCHF'); + expect(dto.transferAmounts.first.assets.first.amount, isNull); + }); + + test('ignores the structured recipient object instead of throwing on it', () { + // The backend `recipient` is a PaymentLinkRecipientDto object, not a + // String; reading the quote must not throw on it (the field is unused). + final dto = LnurlpPaymentDto.fromJson({ + 'recipient': {'name': 'Acme GmbH', 'address': 'Bahnhofstrasse 1'}, + 'requestedAmount': {'asset': 'CHF', 'amount': 42.5}, + 'quote': {'id': 'quote_xyz', 'expiration': '2026-06-03T12:00:00.000Z'}, + 'transferAmounts': [ + { + 'method': 'Ethereum', + 'assets': [ + {'asset': 'ZCHF', 'amount': 42.7}, + ], + }, + ], + }); + + expect(dto.quote.id, 'quote_xyz'); + expect(dto.transferAmounts.first.assets.first.amount, 42.7); + }); }); } diff --git a/test/packages/service/dfx/real_unit_pay_service_test.dart b/test/packages/service/dfx/real_unit_pay_service_test.dart index fc1c89c7..98d8f772 100644 --- a/test/packages/service/dfx/real_unit_pay_service_test.dart +++ b/test/packages/service/dfx/real_unit_pay_service_test.dart @@ -153,7 +153,7 @@ void main() { (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'bad'}), 400), ); expect( - () => build(client).getSwapPaymentInfo(const RealUnitSwapDto.fromAmount(10)), + () => build(client).getSwapPaymentInfo(const RealUnitSwapDto.fromTargetAmount(95.5)), throwsA(isA()), ); }); @@ -309,6 +309,20 @@ void main() { }); }); + group('isPaySupportedEnvironment (up-front capability gate)', () { + test('is true on mainnet', () { + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + final client = MockClient((_) async => http.Response('{}', 200)); + expect(build(client).isPaySupportedEnvironment, isTrue); + }); + + test('is false on testnet', () { + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet)); + final client = MockClient((_) async => http.Response('{}', 200)); + expect(build(client).isPaySupportedEnvironment, isFalse); + }); + }); + group('testnet fail-fast (mainnet-only OCP settlement)', () { test('createPayUnsignedTransaction throws PayUnsupportedEnvironmentException', () async { when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet)); diff --git a/test/screens/pay/pay_process_cubit_test.dart b/test/screens/pay/pay_process_cubit_test.dart index ed302683..21027f0d 100644 --- a/test/screens/pay/pay_process_cubit_test.dart +++ b/test/screens/pay/pay_process_cubit_test.dart @@ -9,7 +9,6 @@ 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/exceptions/bitbox_exception.dart'; -import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/models/faucet/faucet_response_dto.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_ocp_pay_dto.dart'; @@ -78,15 +77,18 @@ SwapPaymentInfo _swap({ ); } -LnurlpPaymentDto _details({required DateTime expiration, String quoteId = 'quote_fresh'}) { +LnurlpPaymentDto _details({ + required DateTime expiration, + String quoteId = 'quote_fresh', + double zchf = 42.7, +}) { return LnurlpPaymentDto( requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 42.5), quote: LnurlpQuoteDto(id: quoteId, expiration: expiration), - recipient: '0xrecipient', - transferAmounts: const [ + transferAmounts: [ LnurlpTransferAmountDto( method: 'Ethereum', - assets: [LnurlpTransferAssetDto(asset: 'ZCHF', amount: 42.7)], + assets: [LnurlpTransferAssetDto(asset: 'ZCHF', amount: zchf)], ), ], ); @@ -111,7 +113,7 @@ void main() { late _MockAccount account; setUpAll(() { - registerFallbackValue(const RealUnitSwapDto.fromAmount(1)); + registerFallbackValue(const RealUnitSwapDto.fromTargetAmount(1)); registerFallbackValue(const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q')); registerFallbackValue( const BroadcastTransactionRequestDto(unsignedTx: '', r: '', s: '', v: 0), @@ -139,6 +141,9 @@ void main() { when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); when(() => appStore.primaryAddress).thenReturn('0xwallet'); + // Default: the environment can settle OCP (mainnet). The up-front gate in + // start() reads this before any on-chain action. + when(() => payService.isPaySupportedEnvironment).thenReturn(true); when(() => appStore.wallet).thenReturn(wallet); when(() => wallet.walletType).thenReturn(WalletType.software); when(() => wallet.currentAccount).thenReturn(account); @@ -225,8 +230,9 @@ void main() { // After start() resolves the chain the pay tx has been submitted. expect(cubit.state, isA()); - // 100 * 1.01 slippage buffer. - expect(sentDto!.targetAmount, closeTo(101, 0.0001)); + // 100 * 1.03 swap headroom buffer (covers ordinary CHF→ZCHF / swap-rate + // drift between scan and settle). + expect(sentDto!.targetAmount, closeTo(103, 0.0001)); expect(sentDto!.amount, isNull); await cubit.close(); }); @@ -276,36 +282,40 @@ void main() { await cubit.close(); }); - test('quote expired between swap and pay → quoteExpired', () async { + test('quote expired between swap and pay → pay-only retry (no re-scan)', () async { wireHappyPath(); when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( (_) async => _details(expiration: DateTime.now().subtract(const Duration(minutes: 1))), ); final cubit = build(); - final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); await cubit.start(); - final state = await failed as PayProcessFailure; + final state = await retry as PayProcessPayRetry; - expect(state.reason, PayProcessFailureReason.quoteExpired); + // Genuine expiry surfaces as a retryable state — NOT a terminal failure — + // because the swap already ran. The pay leg is never submitted here. + expect(state.reason, PayRetryReason.quoteExpired); verifyNever(() => payService.createPayUnsignedTransaction(any())); await cubit.close(); }); - test('pay submit failure → payFailed', () async { + test('pay submit failure after swap → retry (transient), not terminal', () async { wireHappyPath(); when(() => payService.submitPay(any())).thenThrow(Exception('settlement rejected')); final cubit = build(); - final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); await cubit.start(); - final state = await failed as PayProcessFailure; + final state = await retry as PayProcessPayRetry; - expect(state.reason, PayProcessFailureReason.payFailed); + // The swap is done; a failed pay must NOT force a re-swap. + expect(state.reason, PayRetryReason.transient); + expect(cubit.state, isNot(isA())); await cubit.close(); }); - test('terminal non-completed status (Cancelled) → payFailed', () async { + test('terminal non-completed status (Cancelled) → pay-only retry', () async { fakeAsync((async) { wireHappyPath(); when(() => payService.getPayStatus('pl_abc')).thenAnswer( @@ -319,8 +329,10 @@ void main() { async.elapse(const Duration(seconds: 3)); drain(async); - final state = cubit.state as PayProcessFailure; - expect(state.reason, PayProcessFailureReason.payFailed); + // A cancelled settlement after the swap leaves the user holding ZCHF — it + // is recoverable by retrying the pay leg, not a terminal failure. + final state = cubit.state as PayProcessPayRetry; + expect(state.reason, PayRetryReason.transient); cubit.close(); async.flushTimers(); @@ -471,38 +483,42 @@ void main() { await cubit.close(); }); - test('quote re-fetch failure between swap and pay → quoteExpired', () async { + test('transient quote re-fetch failure after swap → retry (not re-scan)', () async { wireHappyPath(); when(() => payService.getPaymentDetails('pl_abc')).thenThrow(Exception('lnurlp 500')); final cubit = build(); - final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); await cubit.start(); - final state = await failed as PayProcessFailure; + final state = await retry as PayProcessPayRetry; - expect(state.reason, PayProcessFailureReason.quoteExpired); + // A transient fetch error is NOT a genuine expiry — it routes to the + // pay-only retry, never to a re-scan → re-swap. + expect(state.reason, PayRetryReason.transient); await cubit.close(); }); - test('pay step on testnet (service fail-fast) → payUnsupportedEnvironment', () async { + test('unsupported environment → fails BEFORE any swap (no on-chain action)', () async { wireHappyPath(); - when( - () => payService.createPayUnsignedTransaction(any()), - ).thenThrow(const PayUnsupportedEnvironmentException()); + when(() => payService.isPaySupportedEnvironment).thenReturn(false); final cubit = build(); - final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); await cubit.start(); - final state = await failed as PayProcessFailure; + final state = cubit.state as PayProcessFailure; expect(state.reason, PayProcessFailureReason.payUnsupportedEnvironment); + // The irreversible swap must never run on an unsupported environment. + verifyNever(() => payService.getSwapPaymentInfo(any())); + verifyNever(() => payService.createSwapUnsignedTransaction(any())); + verifyNever(() => payService.broadcastSwapTransaction(any(), any())); await cubit.close(); }); - test('BitBox disconnect during the pay sign → bitboxRequired', () async { + test('BitBox disconnect during the pay sign (after swap) → pay-only retry', () async { wireHappyPath(); // First sign (swap) succeeds; the second sign (pay) reports a dropped BLE - // link, exercising the pay-step BitboxNotConnectedException branch. + // link. Because the swap already happened, this is a retryable pay-leg + // failure rather than a terminal one. final creds = _CountingSignCreds( throwOnCall: 2, error: const BitboxNotConnectedException(), @@ -510,19 +526,86 @@ void main() { when(() => account.primaryAddress).thenReturn(creds); final cubit = build(); - final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); await cubit.start(); - final state = await failed as PayProcessFailure; + final state = await retry as PayProcessPayRetry; expect(creds.calls, 2); - expect(state.reason, PayProcessFailureReason.bitboxRequired); + expect(state.reason, PayRetryReason.transient); + await cubit.close(); + }); + + test('insufficient ZCHF after swap (fresh amount > acquired) → typed retry', () async { + wireHappyPath(); + // Swap acquires estimatedAmount=960 ZCHF, but the fresh quote now demands + // 1000 ZCHF — more than was swapped. Surface the typed, retryable state. + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => _details( + expiration: DateTime.now().add(const Duration(minutes: 5)), + zchf: 1000, + ), + ); + + 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.insufficientZchf); + // The pay leg is never attempted — the swapped ZCHF stays in the wallet. + verifyNever(() => payService.createPayUnsignedTransaction(any())); await cubit.close(); }); - test('non-signing wallet detected only at the pay sign → signatureUnsupported', () async { + test('retryPay re-runs the pay leg only — never re-swaps', () async { + wireHappyPath(); + // First pass: quote re-fetch throws → PayProcessPayRetry. + var detailsCall = 0; + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer((_) async { + detailsCall++; + if (detailsCall == 1) throw Exception('lnurlp 500'); + return _details(expiration: DateTime.now().add(const Duration(minutes: 5))); + }); + when(() => payService.getPayStatus('pl_abc')).thenAnswer( + (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed), + ); + + final cubit = build(); + final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); + await cubit.start(); + await retry; + expect(cubit.state, isA()); + + // Retry the pay leg: it must re-fetch the quote + submit WITHOUT re-swapping. + final settled = cubit.stream.firstWhere((s) => s is PayProcessAwaitingSettlement); + await cubit.retryPay(); + await settled; + + expect(cubit.state, isA()); + // The swap legs ran EXACTLY ONCE over the whole flow — the retry reused the + // already-acquired ZCHF and never re-swapped (the key fund-safety guarantee). + verify(() => payService.createSwapUnsignedTransaction(any())).called(1); + verify(() => payService.broadcastSwapTransaction(any(), any())).called(1); + // The pay leg's submit ran once (only on the successful retry). + verify(() => payService.submitPay(any())).called(1); + await cubit.close(); + }); + + test('retryPay is a no-op before a swap has completed', () async { + wireHappyPath(); + + final cubit = build(); + // Never started → swap not completed → retry must not touch the network. + await cubit.retryPay(); + + verifyNever(() => payService.getPaymentDetails(any())); + await cubit.close(); + }); + + test('non-signing wallet detected only at the pay sign (after swap) → retry', () async { wireHappyPath(); // Swap sign succeeds; the pay sign hits a non-signing credential - // (UnsupportedError), exercising the pay-step signatureUnsupported branch. + // (UnsupportedError). Post-swap, this is a retryable pay-leg failure. final creds = _CountingSignCreds( throwOnCall: 2, error: UnsupportedError('cannot sign'), @@ -530,12 +613,12 @@ void main() { when(() => account.primaryAddress).thenReturn(creds); final cubit = build(); - final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); await cubit.start(); - final state = await failed as PayProcessFailure; + final state = await retry as PayProcessPayRetry; expect(creds.calls, 2); - expect(state.reason, PayProcessFailureReason.signatureUnsupported); + expect(state.reason, PayRetryReason.transient); await cubit.close(); }); } diff --git a/test/screens/pay/pay_process_state_test.dart b/test/screens/pay/pay_process_state_test.dart index 28713411..354720b6 100644 --- a/test/screens/pay/pay_process_state_test.dart +++ b/test/screens/pay/pay_process_state_test.dart @@ -33,17 +33,32 @@ void main() { test('PayProcessFailure is keyed on reason + message', () { expect( - const PayProcessFailure(PayProcessFailureReason.payFailed), - const PayProcessFailure(PayProcessFailureReason.payFailed), + const PayProcessFailure(PayProcessFailureReason.generic), + const PayProcessFailure(PayProcessFailureReason.generic), ); expect( - const PayProcessFailure(PayProcessFailureReason.payFailed), - isNot(equals(const PayProcessFailure(PayProcessFailureReason.quoteExpired))), + const PayProcessFailure(PayProcessFailureReason.generic), + isNot(equals(const PayProcessFailure(PayProcessFailureReason.insufficientEth))), ); expect( const PayProcessFailure(PayProcessFailureReason.generic, message: 'boom').props, [PayProcessFailureReason.generic, 'boom'], ); }); + + test('PayProcessPayRetry is keyed on reason + message', () { + expect( + const PayProcessPayRetry(PayRetryReason.transient), + const PayProcessPayRetry(PayRetryReason.transient), + ); + expect( + const PayProcessPayRetry(PayRetryReason.transient), + isNot(equals(const PayProcessPayRetry(PayRetryReason.quoteExpired))), + ); + expect( + const PayProcessPayRetry(PayRetryReason.insufficientZchf, message: 'short').props, + [PayRetryReason.insufficientZchf, 'short'], + ); + }); }); } diff --git a/test/screens/pay/pay_quote_cubit_test.dart b/test/screens/pay/pay_quote_cubit_test.dart index a3401632..d0579eea 100644 --- a/test/screens/pay/pay_quote_cubit_test.dart +++ b/test/screens/pay/pay_quote_cubit_test.dart @@ -16,7 +16,6 @@ LnurlpPaymentDto _details({ return LnurlpPaymentDto( requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 42.5), quote: LnurlpQuoteDto(id: 'quote_xyz', expiration: expiration), - recipient: '0xrecipient', transferAmounts: [ if (withEthZchf) LnurlpTransferAmountDto( @@ -35,10 +34,26 @@ LnurlpPaymentDto _details({ void main() { late _MockPayService payService; - setUp(() => payService = _MockPayService()); + setUp(() { + payService = _MockPayService(); + // Default: the environment can settle OCP (mainnet). load() checks this + // up-front before fetching the quote. + when(() => payService.isPaySupportedEnvironment).thenReturn(true); + }); PayQuoteCubit build() => PayQuoteCubit(payService, 'pl_abc'); + blocTest( + 'an unsupported environment emits PayQuoteUnsupportedEnvironment without fetching', + build: build, + setUp: () { + when(() => payService.isPaySupportedEnvironment).thenReturn(false); + }, + act: (cubit) => cubit.load(), + expect: () => [isA(), isA()], + verify: (_) => verifyNever(() => payService.getPaymentDetails(any())), + ); + blocTest( 'a fresh quote with an Ethereum/ZCHF method emits PayQuoteReady', build: build, From eee435e978555e4c6d0d4d3feb203539b5b1619f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:57:32 +0200 Subject: [PATCH 3/7] test(pay): add golden + widget tests for the three pay screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the OCP pay flow's scan / quote / process pages with visual-regression Goldens and full widget tests so the pages are at 100% line coverage (in addition to the already-covered cubits/services). Goldens (test/goldens/screens/pay/, baselines under goldens/macos/): - pay_scan: scanning state with the camera-preview placeholder. The mobile_scanner method + event channels are stubbed via a new stubMobileScannerChannel() helper so the live-camera widget settles into a deterministic placeholder instead of throwing MissingPluginException — matching the @no-integration-test note on pay_scan_page.dart (the live camera is exercised only on a device). - pay_quote: loading, ready (CHF amount + ZCHF needed), expired and unsupported-environment states. - pay_process: swapping, awaiting-settlement and pay-retry states. Widget tests (test/screens/pay/) drive every PayScanView / PayQuoteView / PayProcessView state with mocked cubits, assert the rendered copy, and exercise the button taps (scan onDetect, quote confirm navigation, the process success/failure/retry sheets and their retry/close actions) dispatching to the mocked cubits. Baselines regenerated here are host-local; dispatch golden-regenerate.yaml on the branch to record the authoritative dfx01 baselines for the Visual Regression gate. --- .../pay_process_page_awaiting_settlement.png | Bin 0 -> 8160 bytes .../macos/pay_process_page_pay_retry.png | Bin 0 -> 8621 bytes .../macos/pay_process_page_swapping.png | Bin 0 -> 8868 bytes .../goldens/macos/pay_quote_page_expired.png | Bin 0 -> 12327 bytes .../goldens/macos/pay_quote_page_loading.png | Bin 0 -> 3647 bytes .../goldens/macos/pay_quote_page_ready.png | Bin 0 -> 20050 bytes ...pay_quote_page_unsupported_environment.png | Bin 0 -> 10516 bytes .../goldens/macos/pay_scan_page_scanning.png | Bin 0 -> 3607 bytes .../screens/pay/pay_process_golden_test.dart | 78 +++++ .../screens/pay/pay_quote_golden_test.dart | 92 ++++++ .../screens/pay/pay_scan_golden_test.dart | 45 +++ test/helper/golden_plugin_stubs.dart | 46 ++- test/screens/pay/pay_process_page_test.dart | 278 ++++++++++++++++++ test/screens/pay/pay_quote_page_test.dart | 154 ++++++++++ test/screens/pay/pay_scan_page_test.dart | 119 ++++++++ 15 files changed, 810 insertions(+), 2 deletions(-) create mode 100644 test/goldens/screens/pay/goldens/macos/pay_process_page_awaiting_settlement.png create mode 100644 test/goldens/screens/pay/goldens/macos/pay_process_page_pay_retry.png create mode 100644 test/goldens/screens/pay/goldens/macos/pay_process_page_swapping.png create mode 100644 test/goldens/screens/pay/goldens/macos/pay_quote_page_expired.png create mode 100644 test/goldens/screens/pay/goldens/macos/pay_quote_page_loading.png create mode 100644 test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png create mode 100644 test/goldens/screens/pay/goldens/macos/pay_quote_page_unsupported_environment.png create mode 100644 test/goldens/screens/pay/goldens/macos/pay_scan_page_scanning.png create mode 100644 test/goldens/screens/pay/pay_process_golden_test.dart create mode 100644 test/goldens/screens/pay/pay_quote_golden_test.dart create mode 100644 test/goldens/screens/pay/pay_scan_golden_test.dart create mode 100644 test/screens/pay/pay_process_page_test.dart create mode 100644 test/screens/pay/pay_quote_page_test.dart create mode 100644 test/screens/pay/pay_scan_page_test.dart diff --git a/test/goldens/screens/pay/goldens/macos/pay_process_page_awaiting_settlement.png b/test/goldens/screens/pay/goldens/macos/pay_process_page_awaiting_settlement.png new file mode 100644 index 0000000000000000000000000000000000000000..f180456ff2ac97868195cda0718d56bf42b2f70e GIT binary patch literal 8160 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 PRjxR>J2qUp{>%RWL_omA literal 0 HcmV?d00001 diff --git a/test/goldens/screens/pay/goldens/macos/pay_process_page_pay_retry.png b/test/goldens/screens/pay/goldens/macos/pay_process_page_pay_retry.png new file mode 100644 index 0000000000000000000000000000000000000000..9c782538de3ce630bbedf2327a852fa9faa28cd7 GIT binary patch literal 8621 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 O#ns8vk?_OiU;iKezh%n+ literal 0 HcmV?d00001 diff --git a/test/goldens/screens/pay/goldens/macos/pay_quote_page_loading.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_loading.png new file mode 100644 index 0000000000000000000000000000000000000000..66fa82dc26b731e6e44bcbcfcaf697e9c0906dca GIT binary patch literal 3647 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 G<^KY-eqfvc literal 0 HcmV?d00001 diff --git a/test/goldens/screens/pay/goldens/macos/pay_quote_page_unsupported_environment.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_unsupported_environment.png new file mode 100644 index 0000000000000000000000000000000000000000..4c79db84888f48cb3088581ee84a21da94ff5dd7 GIT binary patch literal 10516 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 AGynhq literal 0 HcmV?d00001 diff --git a/test/goldens/screens/pay/pay_process_golden_test.dart b/test/goldens/screens/pay/pay_process_golden_test.dart new file mode 100644 index 00000000..80f8d4f3 --- /dev/null +++ b/test/goldens/screens/pay/pay_process_golden_test.dart @@ -0,0 +1,78 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_process_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockPayProcessCubit extends MockCubit implements PayProcessCubit {} + +void main() { + late _MockPayProcessCubit processCubit; + + setUp(() { + processCubit = _MockPayProcessCubit(); + when(() => processCubit.state).thenReturn(const PayProcessInitial()); + }); + + // PayProcessPage resolves its cubit from getIt and calls start(); the golden + // renders PayProcessView directly with a mocked cubit. Terminal states + // (success/failure/retry) are surfaced via modal sheets from the listener, + // not the build tree — exercised in the widget test. The build tree shows the + // in-progress indicator with a per-state label, captured here. + group('$PayProcessView', () { + goldenTest( + 'in-progress swapping state', + fileName: 'pay_process_page_swapping', + constraints: phoneConstraints, + // The CupertinoActivityIndicator animates forever, so pumpAndSettle would + // time out; pumpOnce captures the first frame. + pumpBeforeTest: pumpOnce, + builder: () { + when(() => processCubit.state).thenReturn(const PayProcessSwapping()); + return wrapForGolden( + BlocProvider.value( + value: processCubit, + child: const PayProcessView(), + ), + ); + }, + ); + + goldenTest( + 'awaiting settlement state', + fileName: 'pay_process_page_awaiting_settlement', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () { + when(() => processCubit.state).thenReturn(const PayProcessAwaitingSettlement('0xtx')); + return wrapForGolden( + BlocProvider.value( + value: processCubit, + child: const PayProcessView(), + ), + ); + }, + ); + + goldenTest( + 'pay-retry state label', + fileName: 'pay_process_page_pay_retry', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () { + when( + () => processCubit.state, + ).thenReturn(const PayProcessPayRetry(PayRetryReason.quoteExpired)); + return wrapForGolden( + BlocProvider.value( + value: processCubit, + child: const PayProcessView(), + ), + ); + }, + ); + }); +} diff --git a/test/goldens/screens/pay/pay_quote_golden_test.dart b/test/goldens/screens/pay/pay_quote_golden_test.dart new file mode 100644 index 00000000..f0172da3 --- /dev/null +++ b/test/goldens/screens/pay/pay_quote_golden_test.dart @@ -0,0 +1,92 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_quote_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockPayQuoteCubit extends MockCubit implements PayQuoteCubit {} + +void main() { + late _MockPayQuoteCubit quoteCubit; + + setUp(() { + quoteCubit = _MockPayQuoteCubit(); + when(() => quoteCubit.state).thenReturn(const PayQuoteLoading()); + }); + + // PayQuotePage resolves its cubit from getIt and calls load(); the golden + // renders PayQuoteView directly with a mocked cubit so every state is + // deterministic without the service/DI graph. + group('$PayQuoteView', () { + goldenTest( + 'loading state', + fileName: 'pay_quote_page_loading', + constraints: phoneConstraints, + // The CupertinoActivityIndicator animates forever, so pumpAndSettle + // would time out; pumpOnce captures the first frame. + pumpBeforeTest: pumpOnce, + builder: () => wrapForGolden( + BlocProvider.value( + value: quoteCubit, + child: const PayQuoteView(), + ), + ), + ); + + goldenTest( + 'ready quote with CHF amount and ZCHF needed', + fileName: 'pay_quote_page_ready', + constraints: phoneConstraints, + builder: () { + when(() => quoteCubit.state).thenReturn( + const PayQuoteReady( + paymentLinkId: 'pl_abc', + quoteId: 'quote_xyz', + fiatAsset: 'CHF', + fiatAmount: 42.5, + zchfAmount: 42.7, + ), + ); + return wrapForGolden( + BlocProvider.value( + value: quoteCubit, + child: const PayQuoteView(), + ), + ); + }, + ); + + goldenTest( + 'unsupported environment message', + fileName: 'pay_quote_page_unsupported_environment', + constraints: phoneConstraints, + builder: () { + when(() => quoteCubit.state).thenReturn(const PayQuoteUnsupportedEnvironment()); + return wrapForGolden( + BlocProvider.value( + value: quoteCubit, + child: const PayQuoteView(), + ), + ); + }, + ); + + goldenTest( + 'expired quote message', + fileName: 'pay_quote_page_expired', + constraints: phoneConstraints, + builder: () { + when(() => quoteCubit.state).thenReturn(const PayQuoteExpired()); + return wrapForGolden( + BlocProvider.value( + value: quoteCubit, + child: const PayQuoteView(), + ), + ); + }, + ); + }); +} diff --git a/test/goldens/screens/pay/pay_scan_golden_test.dart b/test/goldens/screens/pay/pay_scan_golden_test.dart new file mode 100644 index 00000000..dacdb430 --- /dev/null +++ b/test/goldens/screens/pay/pay_scan_golden_test.dart @@ -0,0 +1,45 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockPayScanCubit extends MockCubit implements PayScanCubit {} + +void main() { + late _MockPayScanCubit scanCubit; + + setUpAll(() { + // The QR scanner is camera-coupled (mobile_scanner). The stub answers the + // permission handshake so the preview settles into its deterministic + // placeholder state instead of throwing MissingPluginException — the live + // camera carries the `@no-integration-test` note on pay_scan_page.dart. + stubMobileScannerChannel(); + }); + + setUp(() { + scanCubit = _MockPayScanCubit(); + when(() => scanCubit.state).thenReturn(const PayScanScanning()); + }); + + group('$PayScanView', () { + goldenTest( + 'scanning state with camera preview placeholder', + fileName: 'pay_scan_page_scanning', + constraints: phoneConstraints, + // The camera preview never reaches an `isInitialized` frame headlessly, + // so pumpAndSettle (default in precacheImages) would await a settle that + // never comes. pumpOnce captures the deterministic placeholder frame. + pumpBeforeTest: pumpOnce, + builder: () => wrapForGolden( + BlocProvider.value( + value: scanCubit, + child: const PayScanView(), + ), + ), + ); + }); +} diff --git a/test/helper/golden_plugin_stubs.dart b/test/helper/golden_plugin_stubs.dart index c1e4e2f5..22754bc8 100644 --- a/test/helper/golden_plugin_stubs.dart +++ b/test/helper/golden_plugin_stubs.dart @@ -7,9 +7,51 @@ import 'package:flutter_test/flutter_test.dart'; /// /// Call from `setUpAll`. void stubNoScreenshotChannel() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('com.flutterplaza.no_screenshot_methods'), (call) async => true, ); } + +/// Stub the `mobile_scanner` plugin's method + event channels so the camera +/// preview renders a deterministic state in headless widget/golden tests. +/// +/// The QR scanner is camera/MethodChannel-coupled — the live preview has no +/// headless representation and `MobileScanner.initState` fires +/// `controller.start()` against the platform channel. This stub answers the +/// permission handshake (`state` → undetermined, `request` → not granted) so +/// `MobileScannerController.start()` settles into its permission-denied error +/// state. The widget then paints its default error placeholder (a black +/// `ColoredBox` with a centered error icon) instead of throwing +/// `MissingPluginException` — a stable, deterministic preview-placeholder +/// state that mirrors the `@no-integration-test` note on `pay_scan_page.dart` +/// (the live camera is exercised only on a real device). +/// +/// Call from `setUpAll`. +void stubMobileScannerChannel() { + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel('dev.steenbakker.mobile_scanner/scanner/method'), + (call) async { + switch (call.method) { + // Camera authorization is undetermined … + case 'state': + return 0; + // … and the follow-up request is not granted, so start() settles into + // the permission-denied placeholder without touching a real camera. + case 'request': + return false; + default: + return null; + } + }, + ); + // PayScanView wires an onDetect callback, so MobileScanner subscribes to the + // controller's barcode stream (the event channel) in initState. Install a + // no-op stream handler that never emits, so the `listen` does not throw + // MissingPluginException and no synthetic barcode ever fires. + messenger.setMockStreamHandler( + const EventChannel('dev.steenbakker.mobile_scanner/scanner/event'), + MockStreamHandler.inline(onListen: (arguments, sink) {}), + ); +} diff --git a/test/screens/pay/pay_process_page_test.dart b/test/screens/pay/pay_process_page_test.dart new file mode 100644 index 00000000..291221dd --- /dev/null +++ b/test/screens/pay/pay_process_page_test.dart @@ -0,0 +1,278 @@ +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'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/config/api_config.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/wallet_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_process_page.dart'; + +import '../../helper/helper.dart'; + +class _MockPayProcessCubit extends MockCubit implements PayProcessCubit {} + +class _MockPayService extends Mock implements RealUnitPayService {} + +class _MockFaucetService extends Mock implements DfxFaucetService {} + +class _MockBlockchainService extends Mock implements DfxBlockchainApiService {} + +class _MockWalletService extends Mock implements WalletService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +class _MockWallet extends Mock implements SoftwareWallet {} + +void main() { + late _MockPayProcessCubit processCubit; + + setUpAll(() { + final getIt = GetIt.instance; + // PayProcessPage resolves a full service graph from getIt and calls + // start(). A debug wallet makes start() settle immediately + // (signatureUnsupported) without touching the chain. + final payService = _MockPayService(); + when(() => payService.isPaySupportedEnvironment).thenReturn(true); + getIt.registerSingleton(payService); + getIt.registerSingleton(_MockFaucetService()); + getIt.registerSingleton(_MockBlockchainService()); + getIt.registerSingleton(_MockWalletService()); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + final wallet = _MockWallet(); + when(() => wallet.walletType).thenReturn(WalletType.debug); + when(() => appStore.wallet).thenReturn(wallet); + when(() => appStore.apiConfig).thenReturn(apiConfig); + getIt.registerSingleton(appStore); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + processCubit = _MockPayProcessCubit(); + when(() => processCubit.state).thenReturn(const PayProcessInitial()); + when(() => processCubit.retryPay()).thenAnswer((_) async {}); + }); + + Widget buildSubject() => BlocProvider.value( + value: processCubit, + child: const PayProcessView(), + ); + + group('$PayProcessPage', () { + testWidgets('builds its own cubit and renders $PayProcessView', (tester) async { + await tester.pumpApp(const PayProcessPage(paymentLinkId: 'pl_abc', zchfNeeded: 42.7)); + // start() runs and emits a failure on the debug wallet; pump a frame to + // let the cubit settle (the sheet animation is not awaited here). + await tester.pump(); + + expect(find.byType(PayProcessView), findsOne); + }); + }); + + group('$PayProcessView progress labels', () { + Future expectLabel(WidgetTester tester, PayProcessState state, String label) async { + when(() => processCubit.state).thenReturn(state); + await tester.pumpApp(buildSubject()); + + expect(find.byType(CupertinoActivityIndicator), findsOne); + expect(find.text(label), findsOne); + } + + testWidgets('initial shows preparing-swap', (tester) async { + await expectLabel(tester, const PayProcessInitial(), S.current.payPreparingSwap); + }); + + testWidgets('preparing-swap label', (tester) async { + await expectLabel(tester, const PayProcessPreparingSwap(), S.current.payPreparingSwap); + }); + + testWidgets('waiting-for-eth label', (tester) async { + await expectLabel(tester, const PayProcessWaitingForEth(), S.current.payWaitingForEth); + }); + + testWidgets('swapping label', (tester) async { + await expectLabel(tester, const PayProcessSwapping(), S.current.paySwapping); + }); + + testWidgets('refreshing-quote label', (tester) async { + await expectLabel(tester, const PayProcessRefreshingQuote(), S.current.payRefreshingQuote); + }); + + testWidgets('paying label', (tester) async { + await expectLabel(tester, const PayProcessPaying(), S.current.payPaying); + }); + + testWidgets('awaiting-settlement label', (tester) async { + await expectLabel( + tester, + const PayProcessAwaitingSettlement('0xtx'), + S.current.payAwaitingSettlement, + ); + }); + + testWidgets('success label', (tester) async { + await expectLabel(tester, const PayProcessSuccess(), S.current.paySuccess); + }); + + testWidgets('pay-retry label', (tester) async { + await expectLabel( + tester, + const PayProcessPayRetry(PayRetryReason.quoteExpired), + S.current.payRetryTitle, + ); + }); + + testWidgets('failure label', (tester) async { + await expectLabel( + tester, + const PayProcessFailure(PayProcessFailureReason.generic), + S.current.payFailureTitle, + ); + }); + }); + + // The result/retry sheets are modal bottom sheets shown from the listener. + // The PayProcessView keeps a CupertinoActivityIndicator animating behind the + // sheet, so pumpAndSettle never settles; pump fixed frames to open the sheet. + // A phone-sized surface keeps the taller retry sheet from overflowing the + // default 800x600 test viewport (mirrors the logout-sheet test convention). + Future pumpWithState(WidgetTester tester, PayProcessState terminal) async { + tester.view.physicalSize = const Size(1200, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + whenListen( + processCubit, + Stream.fromIterable([terminal]), + initialState: const PayProcessSwapping(), + ); + await tester.pumpApp(buildSubject()); + 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()); + + expect(find.text(S.current.paySuccessDescription), findsOne); + expect(find.byIcon(Icons.check_circle_rounded), findsOne); + expect(find.text(S.current.close), findsOne); + + // Tapping close pops the sheet and then pops the page. + await tester.tap(find.text(S.current.close)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + 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, + const PayProcessFailure(PayProcessFailureReason.insufficientEth), + ); + + expect(find.text(S.current.payFailureInsufficientEth), findsOne); + }); + + testWidgets('unsupported-environment failure message', (tester) async { + await pumpWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.payUnsupportedEnvironment), + ); + + expect(find.text(S.current.payFailureUnsupportedEnvironment), findsOne); + }); + + testWidgets('signature-unsupported failure message', (tester) async { + await pumpWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.signatureUnsupported), + ); + + expect(find.text(S.current.payFailureSignatureUnsupported), findsOne); + }); + + testWidgets('bitbox-required failure message', (tester) async { + await pumpWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.bitboxRequired), + ); + + expect(find.text(S.current.payFailureBitboxRequired), findsOne); + }); + + testWidgets('generic failure message', (tester) async { + await pumpWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.generic), + ); + + expect(find.text(S.current.payFailureGeneric), findsOne); + }); + }); + + group('$PayProcessView retry sheet', () { + testWidgets('pay-retry emits a retry sheet whose primary action calls retryPay', ( + tester, + ) async { + await pumpWithState(tester, const PayProcessPayRetry(PayRetryReason.quoteExpired)); + + expect(find.text(S.current.payRetryQuoteExpired), findsOne); + expect(find.byIcon(Icons.replay_rounded), findsOne); + + await tester.tap(find.text(S.current.payRetryButton)); + await tester.pump(); + + verify(() => processCubit.retryPay()).called(1); + }); + + testWidgets('retry sheet close action dismisses without retrying', (tester) async { + await pumpWithState(tester, const PayProcessPayRetry(PayRetryReason.transient)); + + expect(find.text(S.current.payRetryTransient), findsOne); + + await tester.tap(find.text(S.current.close)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + verifyNever(() => processCubit.retryPay()); + expect(find.text(S.current.payRetryTransient), findsNothing); + }); + + testWidgets('insufficient-zchf retry reason shows its message', (tester) async { + await pumpWithState(tester, const PayProcessPayRetry(PayRetryReason.insufficientZchf)); + + expect(find.text(S.current.payRetryInsufficientZchf), findsOne); + }); + }); +} diff --git a/test/screens/pay/pay_quote_page_test.dart b/test/screens/pay/pay_quote_page_test.dart new file mode 100644 index 00000000..55d8508b --- /dev/null +++ b/test/screens/pay/pay_quote_page_test.dart @@ -0,0 +1,154 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/config/api_config.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/wallet_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_process_page.dart'; +import 'package:realunit_wallet/screens/pay/pay_quote_page.dart'; + +import '../../helper/helper.dart'; + +class _MockPayQuoteCubit extends MockCubit implements PayQuoteCubit {} + +class _MockPayService extends Mock implements RealUnitPayService {} + +class _MockFaucetService extends Mock implements DfxFaucetService {} + +class _MockBlockchainService extends Mock implements DfxBlockchainApiService {} + +class _MockWalletService extends Mock implements WalletService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +class _MockWallet extends Mock implements SoftwareWallet {} + +void main() { + late _MockPayQuoteCubit quoteCubit; + + const ready = PayQuoteReady( + paymentLinkId: 'pl_abc', + quoteId: 'quote_xyz', + fiatAsset: 'CHF', + fiatAmount: 42.5, + zchfAmount: 42.7, + ); + + setUpAll(() { + final getIt = GetIt.instance; + + // PayQuotePage resolves the pay service from getIt and calls load(); an + // unsupported environment short-circuits load() without any network. + final payService = _MockPayService(); + when(() => payService.isPaySupportedEnvironment).thenReturn(false); + getIt.registerSingleton(payService); + + // The confirm button pushes PayProcessPage, which resolves a full service + // graph from getIt and calls start(). A debug wallet makes start() settle + // immediately (signatureUnsupported) without touching the chain. + getIt.registerSingleton(_MockFaucetService()); + getIt.registerSingleton(_MockBlockchainService()); + getIt.registerSingleton(_MockWalletService()); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + final wallet = _MockWallet(); + when(() => wallet.walletType).thenReturn(WalletType.debug); + when(() => appStore.wallet).thenReturn(wallet); + when(() => appStore.apiConfig).thenReturn(apiConfig); + getIt.registerSingleton(appStore); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + quoteCubit = _MockPayQuoteCubit(); + when(() => quoteCubit.state).thenReturn(const PayQuoteLoading()); + }); + + Widget buildSubject() => BlocProvider.value( + value: quoteCubit, + child: const PayQuoteView(), + ); + + group('$PayQuotePage', () { + testWidgets('builds its own cubit and renders $PayQuoteView', (tester) async { + await tester.pumpApp(const PayQuotePage(paymentLinkId: 'pl_abc')); + + expect(find.byType(PayQuoteView), findsOne); + }); + }); + + group('$PayQuoteView', () { + testWidgets('loading state shows a $CupertinoActivityIndicator', (tester) async { + when(() => quoteCubit.state).thenReturn(const PayQuoteLoading()); + await tester.pumpApp(buildSubject()); + + expect(find.byType(CupertinoActivityIndicator), findsOne); + }); + + testWidgets('ready state shows the CHF amount, ZCHF needed and confirm button', (tester) async { + when(() => quoteCubit.state).thenReturn(ready); + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payQuoteSummary('42.50', 'CHF')), findsOne); + expect(find.text('42.50 CHF'), findsOne); + expect(find.text('42.70 ZCHF'), findsOne); + expect(find.text(S.current.payConfirmButton), findsOne); + }); + + testWidgets('confirm button navigates to the process step', (tester) async { + when(() => quoteCubit.state).thenReturn(ready); + await tester.pumpApp(buildSubject()); + + await tester.tap(find.text(S.current.payConfirmButton)); + // The process page renders a CupertinoActivityIndicator that animates + // forever, so pumpAndSettle would time out; pump fixed frames to drive + // the push transition instead. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byType(PayProcessView), findsOne); + }); + + testWidgets('expired state shows the re-scan message', (tester) async { + when(() => quoteCubit.state).thenReturn(const PayQuoteExpired()); + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payFailureQuoteExpired), findsOne); + }); + + testWidgets('unavailable state shows the unavailable message', (tester) async { + when(() => quoteCubit.state).thenReturn(const PayQuoteUnavailable()); + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payQuoteUnavailable), findsOne); + }); + + testWidgets('unsupported-environment state shows the environment message', (tester) async { + when(() => quoteCubit.state).thenReturn(const PayQuoteUnsupportedEnvironment()); + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payFailureUnsupportedEnvironment), findsOne); + }); + + testWidgets('error state shows the generic failure message', (tester) async { + when(() => quoteCubit.state).thenReturn(const PayQuoteError('boom')); + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payFailureGeneric), findsOne); + }); + }); +} diff --git a/test/screens/pay/pay_scan_page_test.dart b/test/screens/pay/pay_scan_page_test.dart new file mode 100644 index 00000000..15b6da80 --- /dev/null +++ b/test/screens/pay/pay_scan_page_test.dart @@ -0,0 +1,119 @@ +import 'package:bloc_test/bloc_test.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'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/lnurl_decoder.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_quote_page.dart'; +import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; + +import '../../helper/helper.dart'; + +class _MockPayScanCubit extends MockCubit implements PayScanCubit {} + +class _MockPayService extends Mock implements RealUnitPayService {} + +void main() { + late _MockPayScanCubit scanCubit; + + setUpAll(() { + // pay_scan_page.dart carries the `@no-integration-test` note: the live + // camera is exercised only on a real device. The stub keeps the headless + // preview deterministic and free of MissingPluginException. + stubMobileScannerChannel(); + + // The decoded-link navigation pushes PayQuotePage, which resolves the pay + // service from getIt and triggers a load(); register a mock so the pushed + // route builds. The load is gated off via an unsupported environment so no + // network is touched. + final payService = _MockPayService(); + when(() => payService.isPaySupportedEnvironment).thenReturn(false); + GetIt.instance.registerSingleton(payService); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + scanCubit = _MockPayScanCubit(); + when(() => scanCubit.state).thenReturn(const PayScanScanning()); + when(() => scanCubit.reset()).thenReturn(null); + }); + + Widget buildSubject() => BlocProvider.value( + value: scanCubit, + child: const PayScanView(), + ); + + group('$PayScanPage', () { + testWidgets('builds its own cubit and renders $PayScanView', (tester) async { + await tester.pumpApp(const PayScanPage()); + + expect(find.byType(PayScanView), findsOne); + }); + }); + + group('$PayScanView', () { + testWidgets('renders the scan title and the scanner preview', (tester) async { + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payScanTitle), findsOne); + expect(find.byType(MobileScanner), findsOne); + }); + + testWidgets('onDetect forwards a scanned raw value to the cubit', (tester) async { + when(() => scanCubit.onCodeDetected(any())).thenReturn(null); + + await tester.pumpApp(buildSubject()); + + final scanner = tester.widget(find.byType(MobileScanner)); + // A capture with no barcodes is ignored (rawValue is null) … + scanner.onDetect!(const BarcodeCapture()); + // … while a barcode with a raw value is forwarded to the cubit. + scanner.onDetect!( + const BarcodeCapture(barcodes: [Barcode(rawValue: 'lnurl_raw')]), + ); + + verify(() => scanCubit.onCodeDetected('lnurl_raw')).called(1); + }); + + testWidgets('an invalid scan shows a snackbar and resets the cubit', (tester) async { + whenListen( + scanCubit, + Stream.fromIterable([const PayScanInvalid('bad code')]), + initialState: const PayScanScanning(), + ); + + await tester.pumpApp(buildSubject()); + await tester.pump(); + + expect(find.byType(SnackBar), findsOne); + expect(find.text(S.current.payScanInvalid), findsOne); + verify(() => scanCubit.reset()).called(1); + }); + + testWidgets('a decoded link navigates to the quote step and resets the cubit', (tester) async { + final link = DecodedPaymentLink( + id: 'pl_abc123', + lnurlpUrl: Uri.parse('https://api.dfx.swiss/v1/lnurlp/pl_abc123'), + ); + whenListen( + scanCubit, + Stream.fromIterable([PayScanDecoded(link)]), + initialState: const PayScanScanning(), + ); + + await tester.pumpApp(buildSubject()); + await tester.pumpAndSettle(); + + // The quote step is pushed and rendered; the cubit is reset so returning + // to the scanner re-arms detection. + expect(find.byType(PayQuoteView), findsOne); + verify(() => scanCubit.reset()).called(1); + }); + }); +} From 1f654a042d548c55b872d3a7953c9a40bd20e8ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:17:23 +0200 Subject: [PATCH 4/7] test(goldens): regenerate baselines on dfx01 --- .../home/goldens/macos/home_page_loaded.png | Bin 27435 -> 27456 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/test/goldens/screens/home/goldens/macos/home_page_loaded.png b/test/goldens/screens/home/goldens/macos/home_page_loaded.png index 84de9124736de948b97c23e0dc5d0f8dccbcd585..309a550884849ec8b4b0d5d775fb1e20c1d0cbc8 100644 GIT binary patch literal 27456 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 literal 27435 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 Date: Thu, 4 Jun 2026 15:59:37 +0200 Subject: [PATCH 5/7] test(pay): cover changed lib lines to 100% Add widget/unit tests for the changed lib lines that the existing pay-flow suite did not yet exercise: - DashboardActions: render + tap-routes the buy/sell/pay action buttons, covering the three Expanded(ActionButton) subtrees and their onPressed push closures. - setupServices: resolve the newly registered RealUnitPayService factory, covering its registration and construction closure in di.dart. - routerConfig /pay route: drive the real router to the pay route so the GoRoute builder closure that returns PayScanPage is executed. AppRoutes.pay is a compile-time const field (no instrumentable line); it is exercised at runtime by the above tests. --- .../sections/dashboard_actions_test.dart | 111 ++++++++++++++++++ test/setup/di_pay_service_test.dart | 51 ++++++++ test/setup/routing/pay_route_test.dart | 65 ++++++++++ 3 files changed, 227 insertions(+) create mode 100644 test/screens/dashboard/widgets/sections/dashboard_actions_test.dart create mode 100644 test/setup/di_pay_service_test.dart create mode 100644 test/setup/routing/pay_route_test.dart diff --git a/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart new file mode 100644 index 00000000..43eb3975 --- /dev/null +++ b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/dashboard/widgets/sections/dashboard_actions.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; +import 'package:realunit_wallet/widgets/action_button.dart'; + +void main() { + late List pushedRoutes; + + setUp(() { + pushedRoutes = []; + }); + + // Routes the three action buttons can push. Each target records the + // pushed route name so the `onPressed` closures are both executed and + // asserted, instead of only painted. + GoRouter buildRouter() { + GoRoute target(String name, String path) => GoRoute( + name: name, + path: path, + builder: (_, _) { + pushedRoutes.add(name); + return Scaffold(body: Text('ROUTE:$name')); + }, + ); + + return GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const Scaffold(body: DashboardActions()), + ), + target(AppRoutes.buy, '/buy'), + target(AppRoutes.sell, '/sell'), + target(AppRoutes.pay, '/pay'), + ], + ); + } + + Future pumpActions(WidgetTester tester) async { + final router = buildRouter(); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ); + await tester.pumpAndSettle(); + } + + Finder actionButtonByLabel(String label) => find.byWidgetPredicate( + (w) => w is ActionButton && w.label == label, + ); + + group('$DashboardActions', () { + testWidgets('renders the buy, sell and pay action buttons', (tester) async { + await pumpActions(tester); + + expect(actionButtonByLabel(S.current.buy), findsOneWidget); + expect(actionButtonByLabel(S.current.sell), findsOneWidget); + expect(actionButtonByLabel(S.current.pay), findsOneWidget); + // Each button is laid out inside an Expanded so the row divides + // the available width into three equal slots. + expect(find.byType(Expanded), findsNWidgets(3)); + }); + + testWidgets('renders the expected icons for each action', (tester) async { + await pumpActions(tester); + + expect(find.byIcon(Icons.add_circle_rounded), findsOneWidget); + expect(find.byIcon(Icons.do_not_disturb_on_rounded), findsOneWidget); + expect(find.byIcon(Icons.qr_code_scanner_rounded), findsOneWidget); + }); + + testWidgets('buy button pushes the buy route', (tester) async { + await pumpActions(tester); + + await tester.tap(actionButtonByLabel(S.current.buy)); + await tester.pumpAndSettle(); + + expect(pushedRoutes, [AppRoutes.buy]); + }); + + testWidgets('sell button pushes the sell route', (tester) async { + await pumpActions(tester); + + await tester.tap(actionButtonByLabel(S.current.sell)); + await tester.pumpAndSettle(); + + expect(pushedRoutes, [AppRoutes.sell]); + }); + + testWidgets('pay button pushes the pay route', (tester) async { + await pumpActions(tester); + + await tester.tap(actionButtonByLabel(S.current.pay)); + await tester.pumpAndSettle(); + + expect(pushedRoutes, [AppRoutes.pay]); + }); + }); +} diff --git a/test/setup/di_pay_service_test.dart b/test/setup/di_pay_service_test.dart new file mode 100644 index 00000000..f8d88e2a --- /dev/null +++ b/test/setup/di_pay_service_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/repository/settings_repository.dart'; +import 'package:realunit_wallet/packages/repository/wallet_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/setup/di.dart'; + +class _MockAppStore extends Mock implements AppStore {} + +class _MockBalanceRepository extends Mock implements BalanceRepository {} + +class _MockSettingsRepository extends Mock implements SettingsRepository {} + +class _MockWalletRepository extends Mock implements WalletRepository {} + +void main() { + // The pay flow's backend client is wired through `setupServices()` as a + // factory: `() => RealUnitPayService(getIt(), getIt())`. + // This test exercises that exact registration and then resolves the factory. + // + // `setupServices()` constructs `BalanceService` (eager singleton) up front, + // so `AppStore` + `BalanceRepository` must already be registered. Resolving + // `RealUnitPayService` then pulls the lazy `WalletService`, whose own + // dependencies bottom out at `WalletRepository` + `SettingsRepository` + + // `AppStore` (`BitboxService` is registered by `setupServices()` itself). + // Registering those leaf mocks keeps the whole chain construct-only — none + // of the mocked collaborators perform I/O on construction. + setUp(() { + getIt.reset(); + getIt.registerSingleton(_MockAppStore()); + getIt.registerSingleton(_MockBalanceRepository()); + getIt.registerSingleton(_MockSettingsRepository()); + getIt.registerSingleton(_MockWalletRepository()); + }); + + tearDown(() => getIt.reset()); + + test('setupServices registers a resolvable RealUnitPayService factory', () { + setupServices(); + + expect(getIt.isRegistered(), isTrue); + + final service = getIt(); + expect(service, isA()); + + // registerFactory hands back a fresh instance on every resolution. + expect(identical(service, getIt()), isFalse); + }); +} diff --git a/test/setup/routing/pay_route_test.dart b/test/setup/routing/pay_route_test.dart new file mode 100644 index 00000000..4cdb9a70 --- /dev/null +++ b/test/setup/routing/pay_route_test.dart @@ -0,0 +1,65 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; +import 'package:realunit_wallet/setup/routing/router_config.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; + +import '../../helper/helper.dart'; + +class _MockHomeBloc extends MockBloc implements HomeBloc {} + +void main() { + late _MockHomeBloc homeBloc; + + setUpAll(() { + // PayScanPage embeds a MobileScanner; the stub keeps the headless camera + // preview deterministic and free of MissingPluginException. + stubMobileScannerChannel(); + }); + + setUp(() { + homeBloc = _MockHomeBloc(); + when(() => homeBloc.state).thenReturn(const HomeState()); + }); + + // Mirrors the production wiring in main.dart: the routed pages read their + // blocs from above MaterialApp.router, so HomeBloc (used by the initial + // /home route) is provided here. Navigation then drives the real + // `routerConfig` to the /pay GoRoute under test. + Future pumpRouter(WidgetTester tester) async { + await tester.pumpWidget( + BlocProvider.value( + value: homeBloc, + child: MaterialApp.router( + routerConfig: routerConfig, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('the pay route builds PayScanPage', (tester) async { + await pumpRouter(tester); + + routerConfig.goNamed(AppRoutes.pay); + await tester.pumpAndSettle(); + + expect(find.byType(PayScanPage), findsOneWidget); + expect(find.byType(PayScanView), findsOneWidget); + + // Restore the router to its initial location so the global singleton + // does not leak the /pay location into any later test. + addTearDown(() => routerConfig.goNamed(AppRoutes.home)); + }); +} From 3a2edee0acd621820cb4721d347bffaa82d47fb5 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:17:26 +0200 Subject: [PATCH 6/7] feat(pay): consume real OCP quote on testnet, drop client env-gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DFX backend now settles Open CryptoPay on every environment (Sepolia off-PRD, mainnet+L2 on PRD; DFXswiss/api #3819, verified by a real Sepolia OCP payment). The client must no longer pre-decide that testnet is unsupported — that was an API-as-authority anti-pattern and is now wrong. Remove the environment capability gate end to end: - RealUnitPayService.isPaySupportedEnvironment getter, assertPaySupported(), and the per-call defensive guards on createPayUnsignedTransaction/submitPay - PayUnsupportedEnvironmentException (now unreachable; dropped from the exception-surface guard list) - PayQuoteUnsupportedEnvironment state + the up-front load() gate - PayProcessFailureReason.payUnsupportedEnvironment + the up-front start() gate - payFailureUnsupportedEnvironment i18n key (en + de) and the view branches The flow now always requests the real quote; a typed backend error surfaces through the existing failure states. Fund safety is untouched: start() still gates the debug wallet before any on-chain action, and the post-swap pay-only-retry path is unchanged. Replace the unsupported-environment fallback golden with a real OCP-quote golden built from the captured Sepolia run (CHF 2.00 -> 2.0 ZCHF). Drive the pay_quote cubit/widget/golden tests and the pay/unsigned-transaction service test with the real fixture values. --- assets/languages/strings_de.arb | 1 - assets/languages/strings_en.arb | 1 - .../exceptions/payment/pay_exceptions.dart | 12 --- .../service/dfx/real_unit_pay_service.dart | 20 ----- .../cubits/pay_process/pay_process_cubit.dart | 15 ++-- .../cubits/pay_process/pay_process_state.dart | 5 -- .../pay/cubits/pay_quote/pay_quote_cubit.dart | 9 -- .../pay/cubits/pay_quote/pay_quote_state.dart | 6 -- lib/screens/pay/pay_process_page.dart | 2 - lib/screens/pay/pay_quote_page.dart | 3 - .../goldens/macos/pay_quote_page_ready.png | Bin 20050 -> 19609 bytes ...pay_quote_page_unsupported_environment.png | Bin 10516 -> 0 bytes .../screens/pay/pay_quote_golden_test.dart | 26 ++---- .../exceptions/exception_surface_test.dart | 1 - .../dfx/real_unit_pay_service_test.dart | 84 ++++-------------- test/screens/pay/pay_process_cubit_test.dart | 19 ---- test/screens/pay/pay_process_page_test.dart | 10 --- test/screens/pay/pay_quote_cubit_test.dart | 39 +++----- test/screens/pay/pay_quote_page_test.dart | 33 ++++--- test/screens/pay/pay_scan_page_test.dart | 11 ++- 20 files changed, 66 insertions(+), 231 deletions(-) delete mode 100644 test/goldens/screens/pay/goldens/macos/pay_quote_page_unsupported_environment.png diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index bc0b3530..7784bbea 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -176,7 +176,6 @@ "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", - "payFailureUnsupportedEnvironment": "Open CryptoPay ist nur im Mainnet verfügbar.", "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.", "payoutAccountAdd": "Auszahlungskonto hinzufügen", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 19b0a59a..d6152ae7 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -176,7 +176,6 @@ "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", - "payFailureUnsupportedEnvironment": "Open CryptoPay is only available on mainnet.", "paymentInformationFailed": "An error occurred while getting the payment information.", "paymentInformationFailedDescription": "Please try again later. If the error persists, contact our support team.", "payoutAccountAdd": "Add payout account", diff --git a/lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart b/lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart index 4872f492..13dd475a 100644 --- a/lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart +++ b/lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart @@ -13,18 +13,6 @@ class InvalidPaymentLinkException implements Exception { String toString() => 'InvalidPaymentLinkException: $reason'; } -/// The Open CryptoPay settlement is not available on the current backend -/// environment. The payment-link engine is mainnet-only, so `pay/submit` and -/// `pay/unsigned-transaction` fail fast on dev.api.dfx.swiss (Sepolia). -class PayUnsupportedEnvironmentException implements Exception { - const PayUnsupportedEnvironmentException(); - - @override - String toString() => - 'PayUnsupportedEnvironmentException: Open CryptoPay settlement is only ' - 'available on mainnet'; -} - /// The loaded wallet cannot produce EIP-1559 signatures (today: the debug /// wallet). The pay flow needs to sign the swap and pay transactions locally, /// so it cannot proceed in this wallet mode. diff --git a/lib/packages/service/dfx/real_unit_pay_service.dart b/lib/packages/service/dfx/real_unit_pay_service.dart index 6b83e874..535d4f47 100644 --- a/lib/packages/service/dfx/real_unit_pay_service.dart +++ b/lib/packages/service/dfx/real_unit_pay_service.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:realunit_wallet/packages/config/api_config.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; -import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.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_ocp_pay_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart'; @@ -103,7 +102,6 @@ class RealUnitPayService extends DFXAuthService { Future createPayUnsignedTransaction( RealUnitOcpPayDto dto, ) async { - assertPaySupported(); final uri = buildUri(host, _payUnsignedTxPath); final response = await authenticatedPut( uri, @@ -120,7 +118,6 @@ class RealUnitPayService extends DFXAuthService { } Future submitPay(RealUnitOcpPaySubmitDto dto) async { - assertPaySupported(); final uri = buildUri(host, _paySubmitPath); final response = await authenticatedPut( uri, @@ -148,23 +145,6 @@ class RealUnitPayService extends DFXAuthService { ); } - /// Whether the current backend environment can settle an OCP payment. The - /// payment-link engine is mainnet-only; on testnet the pay/* endpoints fail - /// fast server-side with a 400. This is environment-static (keyed off - /// [ApiConfig]), so the flow can read it BEFORE the irreversible REALU→ZCHF - /// swap and refuse to swap on an environment where the pay leg can never - /// settle. Not local business logic — purely a capability gate. - bool get isPaySupportedEnvironment => !appStore.apiConfig.networkMode.isTestnet; - - /// Defense-in-depth mirror of [isPaySupportedEnvironment] on the pay/* calls: - /// even though the flow gates up-front, surface the typed failure before the - /// round-trip rather than parsing the backend error body. - void assertPaySupported() { - if (!isPaySupportedEnvironment) { - throw const PayUnsupportedEnvironmentException(); - } - } - Never _throwApi(String body, int statusCode) { final errorJson = jsonDecode(body) as Map; throw ApiException.fromJson(errorJson, httpStatusCode: statusCode); 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 9870e468..d1d59e81 100644 --- a/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart +++ b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart @@ -93,15 +93,12 @@ class PayProcessCubit extends Cubit { /// Entry point — called by the view once the user confirms the quote. Future start() async { - // Environment capability gate — checked BEFORE any on-chain action. The - // REALU→ZCHF swap is irreversible; if OCP settlement can never succeed on - // this environment (mainnet-only), refuse here so the user is never swapped - // into ZCHF and then told "mainnet only". This is environment-static, so it - // is safe (and required) to evaluate before the swap is signed/broadcast. - if (!_payService.isPaySupportedEnvironment) { - emit(const PayProcessFailure(PayProcessFailureReason.payUnsupportedEnvironment)); - return; - } + // Capability gate — checked BEFORE any on-chain action: the debug wallet + // cannot produce EIP-1559 signatures, so the irreversible REALU→ZCHF swap + // must never run on it. The backend settles OCP on every environment + // (Sepolia off-PRD, mainnet+L2 on PRD), so there is no environment gate + // here — the flow requests the real quote and surfaces a typed backend + // error if one ever comes back. if (_appStore.wallet.walletType == WalletType.debug) { emit(const PayProcessFailure(PayProcessFailureReason.signatureUnsupported)); return; 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 f7894c83..fe836311 100644 --- a/lib/screens/pay/cubits/pay_process/pay_process_state.dart +++ b/lib/screens/pay/cubits/pay_process/pay_process_state.dart @@ -10,11 +10,6 @@ enum PayProcessFailureReason { /// Not enough ETH to cover gas and the faucet top-up did not arrive. insufficientEth, - /// Open CryptoPay settlement is unavailable on the current backend - /// environment (mainnet-only; checked BEFORE the swap so it never strands the - /// user in ZCHF). - payUnsupportedEnvironment, - /// The active wallet mode cannot sign transactions (debug wallet). signatureUnsupported, 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 6180400c..fb75ef48 100644 --- a/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart +++ b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart @@ -19,15 +19,6 @@ class PayQuoteCubit extends Cubit { Future load() async { emit(const PayQuoteLoading()); - // Gate the irreversible flow up-front: if OCP settlement can never succeed - // on this environment, surface it now — before the user can confirm a quote - // and trigger the REALU→ZCHF swap. The swap must never run where the pay - // leg cannot settle. - if (!_payService.isPaySupportedEnvironment) { - emit(const PayQuoteUnsupportedEnvironment()); - return; - } - try { final details = await _payService.getPaymentDetails(_paymentLinkId); diff --git a/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart b/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart index 9125644e..0984f573 100644 --- a/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart +++ b/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart @@ -40,12 +40,6 @@ class PayQuoteUnavailable extends PayQuoteState { const PayQuoteUnavailable(); } -/// OCP settlement is unavailable on the current backend environment -/// (mainnet-only). Surfaced before the swap so it can never run on testnet. -class PayQuoteUnsupportedEnvironment extends PayQuoteState { - const PayQuoteUnsupportedEnvironment(); -} - class PayQuoteError extends PayQuoteState { final String message; diff --git a/lib/screens/pay/pay_process_page.dart b/lib/screens/pay/pay_process_page.dart index 0d7f9fb9..ace5bdc4 100644 --- a/lib/screens/pay/pay_process_page.dart +++ b/lib/screens/pay/pay_process_page.dart @@ -108,8 +108,6 @@ class PayProcessView extends StatelessWidget { String _failureMessage(BuildContext context, PayProcessFailureReason reason) => switch (reason) { PayProcessFailureReason.insufficientZchf => S.of(context).payFailureInsufficientZchf, PayProcessFailureReason.insufficientEth => S.of(context).payFailureInsufficientEth, - PayProcessFailureReason.payUnsupportedEnvironment => - S.of(context).payFailureUnsupportedEnvironment, PayProcessFailureReason.signatureUnsupported => S.of(context).payFailureSignatureUnsupported, PayProcessFailureReason.bitboxRequired => S.of(context).payFailureBitboxRequired, PayProcessFailureReason.generic => S.of(context).payFailureGeneric, diff --git a/lib/screens/pay/pay_quote_page.dart b/lib/screens/pay/pay_quote_page.dart index 7708b29a..fc77fed4 100644 --- a/lib/screens/pay/pay_quote_page.dart +++ b/lib/screens/pay/pay_quote_page.dart @@ -38,9 +38,6 @@ class PayQuoteView extends StatelessWidget { PayQuoteReady() => _PayQuoteReadyView(state: state), PayQuoteExpired() => _PayQuoteMessage(message: S.of(context).payFailureQuoteExpired), PayQuoteUnavailable() => _PayQuoteMessage(message: S.of(context).payQuoteUnavailable), - PayQuoteUnsupportedEnvironment() => _PayQuoteMessage( - message: S.of(context).payFailureUnsupportedEnvironment, - ), PayQuoteError() => _PayQuoteMessage(message: S.of(context).payFailureGeneric), }, ), 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 c3ed67d3c5b5bff0a543afc361fcd82f59386319..d58c5b28195200f91d86a0e97d8cfebe62f7e864 100644 GIT binary patch literal 19609 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 literal 20050 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 G<^KY-eqfvc diff --git a/test/goldens/screens/pay/goldens/macos/pay_quote_page_unsupported_environment.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_unsupported_environment.png deleted file mode 100644 index 4c79db84888f48cb3088581ee84a21da94ff5dd7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10516 zcmeHtS6EZ&+BRK^SEaQF@DjfEXY~dX0{w)Q~As1Of_5 zFCtYE$f%Hvv`8<3L=z?S7y_gx-&!91XaB+ezO%{6y0Wgda=q_U?&rSmyjfQ+J1Km3 z=sP(%IfaWqIb4;K+x1XRPX6a_cLR64BfrxDF8_`>f6@J0;FJ38jZEPCu9&M%=jED4 zw3g-M4$ED1IOm>Ny1tN5I=TILi4*?aK5d6&r7P!lDXFNO(mjOv26ISxeBOobLS0*= z;b*6Z7apapZv^nFHmVwrYg!l=V-~zC92HJpx@J9=qV}xw-eVIUHpo#Y?Cvfl-Cnxv zuTm>wOmFp&pcHP!@^km_@KBX|0q48}R)+Tdb=O@7b-ADZ%gZI#9XaVl8xRqZ&9!}wHV4&$j$cYqn zhragdz?6x>M6snWdD8|uIs53}KkDw2`{9k;|M$}Cz&?!8w8$fh#jL#KR|x~17kjT2 zp5nST7m)rkgOk^BLohef8!`>tr!L~sMSA*sjgh0- zH8nLhv-U||dbYFjmTu#;;(y9$&N?_;T=t^nQ6W1gEnk(Kifdgo$U@5~ z8DkL(_%`fd2K>O=hg;aOKmK5vjtTRD$IyaNTjQ-ay`0C`+=wrCpz#}dj+jf84XDb_ zxK!k13$3>j^&x~WL8ck|nl_94y+uMz4hBhARZ^l0%gZyHaC2t-&2S3*5UhLkBYz^J z854A01R>so+&}+RuOo`C5#`%ySSY(T zUiZmOOG+kK$DEs&wo@Eit6VPcTv=_uwf>M#hRka}IDf=Y>-~vnLb5~KO`2)JX}Gep zXM@#Kx6I7Mwb?=@W|+z_LgGIb6*Sk?NfU-r46{bGU9nNr1QP#UE7MESMp=J~paa1R z2AI$f_aEh(W&4qGvtuZB%$fob4ae(OJ!+WyyP9Gml@xEIwYm;HWQGlL==;@+)#p+9 zaKehx^&gY{-XE3>i3!b3#*4Ov4l=tQ+Q1oXjv&X2gd?V(?v*LRfm~Vz0ozRc@=h%n ze&Isuru{VO%MoO+NPR-KwnTenKQH-U!dV(OB0EKQGoIyfuWSg{*_BsLP9*VS)9EIP zcz+KM2e(lUgfjnNu+knsPTP#*t&W=rOJ@yqb(_>tLE=feECmc!>QFMsVfo z_Gk^D=5k7!U$yP<^gW3>Fup*ZL$pgkh!;PyLSBz~C@BH;Tg@MGB0Jza8WpW}8maN5 z?8#>GH>_;dnLwg0TtuPvEv1B|GNup}5r2~00(*^mWv!x+ z{*~#F$vF%MCjEFqNr{7L7ALhuc=Qz8rj1l|$v7<>A<1C0?V?XdY7Pd^oidvzG>Vaz4xkiLFazk=6(tfxUq|A1LD znTOu_G4+dZaAn>?wuMY^qM92p_dlMP&uNeRsRi87oz0**c^2^jKuae82o*Pr5~kVTfBIbzTn{kAG` zYq>C(_C~cOWO~E05|N>ve2&<4YvRM);c4-3Do5F(-^aFlg0@F{u00D3;5BsV_a&Xt z*eaI+#>4uD&(D}`Nt=`QrHhszA;QuNEeO}7t>sCB=rsD$679LYgM)bScNRlXSarc+ z!j2A$=jI#p#db~vB<A#Y>D1Lv1wgeut8w&aqxxxP-*|*w1Y$6F|{2iBWmGAgfrnxP}qDDg^6;U z&O?ox9j%2o}`)oj8|UE=y3DBdKF4S3qJO=q`xib5)}V# zyvf*Q95?j~9{{ooYiN!RYBb5s-(L$i*NG`<*3htko185+uT9^T6iO2FIcVGOT}mGH zM|JwEdSQD{R#5^~I%P|=*afGtmls+yGfx_v%6c55M!s-=B#V-I&^e-AQFlcZo%k`oO+1#52ocn6qv9#i8HBuHzfN+;IuwDHZfNuRCua9DdI}Y? zi2Rvt)a)_&%9Gg@U0s&NW(GNfh8pTEXanvd!jUvtX=ME=6iIK-)jFxa(>hBE=r~7ua@nE_nff-IszZAO3^9V*5Dpp$eoWoR$MvR$Lke%wX@Gyw;`0lE!xP&Ce*oQ&bqpcCc2#}m|vK5y}Gid zCRtTVg-N2sAN#$S1;&NXJ!YbD)c!&{R7{`LEPo+oAY_N|+|9=)^N&A-nmsqr>d83N zlG;7_^_h04bgh6#s&O7^wp}0i{XTQ@@!nuY+w?8?hp-_ZEpR47($D$!BC5xkns7nl zIz^Or`uo(j~0@L_q9 zVghkeAFAgDooUEQ?0W1#7ic;%sc>^@gmW1Rol zE~E`m$xGAKPWO4n1m4RdsKK~OAZjmz?lW@WqH{yV#2=zHOzO@JLK=F>J2w{+w==t> z13|!udLDx3FE{I8{$McpXtb=boh2BsKx@UQ86e+(&NqwJk{97rnI|ZDih<}P6;%)7 z-g4t`)O_;WrR2h8AROXxFIa$x`(ala!x=3@8Y4N`>b9xxgPNOxd=}aX?pg90RwO4_ zJg9!J0EZ8rA{>j`snuGRmYb)zo%Zk6Tc`$$6)k?fz}b9cWxP1x9Q;L)7QDHY)Dk5c zv8M2@&7Oih>=Ehd?hvWAj~t&*CcFiRR;*AX0QzilB)uoaVGTZvk+8ch0Hztfz4U$a zNHIL6x5`D+>FJfsIL*zKIHH~H_-94SPOqV|PSXzdya)c&;&uFvJEXvu?grUDWoVe^ zoUy3Z)S4298TakXh4tDrn(s5ZT&w|3y6Z$jd4DV%;chLX$qGrYgfOtZp^Hkvoc>(wkV9B>C4=A;loIhyfcP;!eM7(w{Q+|WD5im!Fr>#ihMmi$~JG_ZOF>5og zpv49G7X&nycA}|CtTStL*3hu4~5=nJfsOMUJLnguq zlI-sEUh9%9hL%b|G=(e=w3Ablwrx9{R{v6?#Eh%}%sNf9^%HA3Ev(bK>xRdDqnL?f zEOOiI-Vj=a>S!lV!#HAYJ~LC@ybC=P;Elfi-Ge%BBI;UXVuxdvPR%R67CXxN|2O}|bQA(sspzqWS;0sS$O6x8Q&P+7y zXh-jvAr1Q-)vZWj5wa5j%wJ}Oxs*8Mgt;KROs0vwR>88xvgf=8XO@AXVH4LUi`LU! zRj!yj@^ouy$~dspU6R+)JGptk9o7?v)2>!h^2+TxtdqL=VCD;{;N~d72b{EEZ}j(D z71{3Z9=64M$^2tV6hp@gR_HwIqvT3}x>(2E)I>}n8(N1Ruy}bR>#kUkGnXwu9jWGx zW9;dMW?BakH9iIMhDJZLX70bDzx_p# zW_qVBV=s?AZZa@%sJ|;~yb{Sa37-FT1+BOp7B-NZ#2FNo=WTALfh?@;*6itccEk+p zj&%pj;F2?!e<*(fP1ISI!e3Ph6==;miN|q)zyQq8XKzqkOE-gVPIttqsHuS&UX3)b z1zFa2aKy*@w`FcvE$gpQPYhxV%^l5lgkMc)Py0MK9rE{IF}Kix?H$5zdOUL8 zvTd4XwK~HB_WuZQbV4u6xrZ9+q@eAMcSOAa=r{;}WMHr{{bWq#(e{{aC?A?G@%|at zeg?-IaJ$cRO0d=9sx@bFu7zEw92+~T(-QY$Z0wJ&?T4c%W{Oi^46~ZVq+HGfsR@UP zmabYGIVkiF*%Pj7H(dGqNX@J`>4iN|{FTqj)FhpUN-8QU)Qvw8)S*F+4S{%LV5%G_ z7q#*xQ)mjv$Sf#Yk{G-4-n>^RP@LXy6#@`9|76v=n9TTzhaRT06MChu=1^DGAJGL` zN~n)_X~qd3v%O#32*uHd%ZR20js9wC@GHEfynKYY!{n|#kw)|A3CGUM>p;234K9w> zw3${7l@{i$+Ld+3MMDgV%`kKr%qo!H^|{EX`a)|JDxKaM{AR++_~WG2=+l6bIW?;M zWF)cKt@YHC!CfVumzrCAio>cj+{T`wQ2jI+96qH$U zjx~`N1E?_E$v)}Z_!6LJK|pGo%?Snw`J|8Xts3ALPzxy!NsSh{dFP)V(4Y1sTGb?- zTZq?!xy(OnKsh*^w!GOoTq;nY_R6YnOuWD6c2b2wmvUc&J#k-H9>5j=iZhQ^#Ph!@ z3MSl~U9pCKaQ#&$pnkfnYN!Z3!!^@0$O=dq7)QLW-3U;62(|;LsrWmtXic82xp@`E zM`fIKI$M^$CE$0K!_<35mb4%}wHq0A-#s`lCNE(78KH>akzYbNIdrLAu*{feW_Mds zbJB>e1_UR>-UobPCt3+>V`djd=&)Qg z^w$Z2m+83L!D?R@#!c3|Zi%Cgcz4Bm?mJN+W~%_!3zI_z;eT zdF#O=gizz~*Ds5#5kcY);fhm93ECohOJMxFzluzwqE)_okUahC{^|;VN(<}hUz4-2 zTJm+fj8Bh261#r6-tJMCOZ`Exa!4*kN;!p%wF{Ij&qt3`oPgyq+P@iO*0%f^GFunN z+3GrF9J9gbOr1U9J3X>q5mn;ZlC-)5XbQUh#(4c~#`c@O$1RD1Vs(okNAX(OiOXMZ z)?;++&VY!%M+c(DJU;Su|ML&@8Lv+S{^Vq}4InO%2^+!uGw)U@X69ua`Jx6pD|P^R zDO*xUs5uJ%c_-@t{RhL>$D z0-$FBVyz03Hm}%6A^i-X z{z0)FT1#0OJ!U6;UC-RuBfG*P6k^p?Trs%Rzs;T|Yu}dWdp2WhTMtf1=PUil{5NFe z&n4=1cg9sY^?S;)sOVIrsh$C*Ts%mhK({!xgf1E(Q{XmRpZ0P-C>uFAH~@N<^W2rD z1v*EuR!+&0tB{}GlH6H>>ARe1o_ZdT7@g$Jes#08yOjyqn?#oq(>xwLA;N|;-`6E^ z;(4;K5EFk?0W1w>RpWN<$6@`GdV1Lm`-R_j)28Hs*25rEKU0`2o}CiW?t|4NFgx}w zi5AbPgC3aL@!?$1Ou*XM+C}$#+yq7E!+>{CcehD=gybf1r4o|8U$m=?B3(~l)VbL+ zrZgR$k_5dotqE);*t7OH(FkDQn2A=6af2bg8?QLmDha7@|4x~SdPdAMwVH~G6{bk> zb0SSG!}<~LN1h})a{EN3BLCl<(Oa~Ji#YiM`#rB-^%o^&F>6tkjtbV7*4^FQRpRo_ zLmyvqzLdxBDyt!~Vc)O^&Jd_J+vImGw-z22nZ`x$TSHGB!TvkBdbT?)oM0IdHUw`w zyJ!%o1p0Qn(=hV46W`qac73iR`aN_q;g@&gED|kE71Z9S*(32o1%RsuQqtoXy0EHT zIh0b?!vWeIu(}2TYl+m?ueN17YyZo&H~2dcdAW?8CQE$mGL6zU7u9Fj4O@87=WVp`*U`R=R30ud%ko2ZiC?s`UgPu4cGEcfZJMIFQg%x% z`|Txz5{sOAM}<5Pm}=9#sg{nYU`YnP_nV}TV-4L6iB)u zI0B@f(dgsZIXPI%JxHxEDHF7N_A1v`7ZHGNa*T+$nEH`|%mukUTS`tFZHij8hGV4( zvea;Y^T_M#Yj*&J$0*Av!@L_`1c@Iw217CRu;1qa{>{&!HZ=#tGpnLF>*Z4Og9d)u z$NKw?Yh<$=j$$Cr?idUh1c{8g7EQYGL7sFm^-JF#}Mv9`oe$L&%k zs1i>DW{G2<&0zq0j-vCS1nmanFCF@)PURWmE@NAi!=5F41l8tzpT=lo4F05{Vf1?~ z0;))W#V0HukWd=S+E?oU?gSVNj`;C<3)Mz?mlbX1W&6mCdDLvdaamH(n<3D*>jJ1N z=d&vjCfwOjg2u1s?MkEMt~L+Q4~PH^%PTM_CPlG{dWPD`=|4X<{^}NvCwVp{*#aoc zC|Gsu&p;Av!0LbrJ|At_*tQ6NO6?8t0+%n{K0(Yn@8K~YyZv`p;%|zps7>#X55EGw z_md)9WCY-mzSz+Wsvhz6mYB_oSc0g(wFqV_C9*Eba#RH{5kU%VZ?vSQJ*O>6IAm)Z zeW@*KTn`xjp-M_9Pizxsn$8abYsgTW8yPJTZT+k~W_k*t0?hYQ1&zL5&}(yMl}1fC zCu`jbz)5E8fu$TiXc(Z4vup+^g6|65!OWMzdwShCc9+8#XNQ;>85ook&%om+OF*!e9aJyW-lRMoNI#g3+lNR`MV%+7SV*C%gaS8GBsq24( zoXwgQ&=YO%Z}}u)onkibIW>f}MXew>LtS3MeHU2$WEwgY9CrX5_tPa@+;$$@yBfez z7j?^^?pPaJP^*9}4ImSs;bYy7j)A6Nn{dHn6R(T`V-BoD@^*KLnSRc5{V7L}$r6*) zo{kqA^8_ zxi9A5@4j3!m=SP5Cc(3g>M|-VSoU5{iV$_IA)}v5-BM`4(DiI=q)|ZMA8lF03X4TFMd7Sl}NDj20N7bkWCd zzUhxfYJwwRkVH|_B)d=K3Do(2PW)z-h`j*lxky@f4=v&t{IL~mqf6An-*RfoaW-7@ zN)cvHt}-F$+X?=>vZpAnS>cq=#!p;7FTlvn!{Mbp%iiogDIV&4lo zXeoAOnJ{)B3!3&LuoJ%Rkvgjuk=cdt$I1qNra1^6I%;?6O5~+q quoteCubit.state).thenReturn( const PayQuoteReady( - paymentLinkId: 'pl_abc', - quoteId: 'quote_xyz', + paymentLinkId: 'pl_realunit_ocp_sepolia', + quoteId: 'plq_realunit_ocp_sepolia', fiatAsset: 'CHF', - fiatAmount: 42.5, - zchfAmount: 42.7, + fiatAmount: 2, + zchfAmount: 2.0, ), ); return wrapForGolden( @@ -59,21 +62,6 @@ void main() { }, ); - goldenTest( - 'unsupported environment message', - fileName: 'pay_quote_page_unsupported_environment', - constraints: phoneConstraints, - builder: () { - when(() => quoteCubit.state).thenReturn(const PayQuoteUnsupportedEnvironment()); - return wrapForGolden( - BlocProvider.value( - value: quoteCubit, - child: const PayQuoteView(), - ), - ); - }, - ); - goldenTest( 'expired quote message', fileName: 'pay_quote_page_expired', diff --git a/test/packages/service/dfx/exceptions/exception_surface_test.dart b/test/packages/service/dfx/exceptions/exception_surface_test.dart index 65625b44..48c7994d 100644 --- a/test/packages/service/dfx/exceptions/exception_surface_test.dart +++ b/test/packages/service/dfx/exceptions/exception_surface_test.dart @@ -26,7 +26,6 @@ void main() { currentLevel: 0, ), const InvalidPaymentLinkException('test'), - const PayUnsupportedEnvironmentException(), const PaySignatureUnsupportedException(), ]; diff --git a/test/packages/service/dfx/real_unit_pay_service_test.dart b/test/packages/service/dfx/real_unit_pay_service_test.dart index 98d8f772..39ec0132 100644 --- a/test/packages/service/dfx/real_unit_pay_service_test.dart +++ b/test/packages/service/dfx/real_unit_pay_service_test.dart @@ -9,7 +9,6 @@ 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/exceptions/api_exception.dart'; -import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart'; @@ -220,6 +219,9 @@ void main() { }); group('createPayUnsignedTransaction', () { + // Real Sepolia OCP capture (DFXswiss/api #3819, ocp_tx_details_sepolia.json): + // a CHF 2.00 link settling 2.0 ZCHF (2000000000000000000 wei) to the DFX + // deposit recipient on chain 11155111. test('PUTs /pay/unsigned-transaction with the payment refs', () async { Uri? sentUri; Map? body; @@ -228,28 +230,31 @@ void main() { body = jsonDecode(request.body) as Map; return http.Response( jsonEncode({ - 'unsignedTx': '0xtx', - 'tokenAddress': '0xzchf', - 'recipient': '0xrecipient', - 'amountWei': '5000000000000000000', - 'chainId': 1, + 'unsignedTx': + '0x02f87483aa36a782019b8502284a84ae8502284a84ae830186a094d3117681ca462268048f57d106d312ba0b1215ea80b844a9059cbb000000000000000000000000fb2a9731cda8b3fca015723ef40f310c1e48366b0000000000000000000000000000000000000000000000001bc16d674ec80000c0', + 'tokenAddress': '0xD3117681cA462268048f57D106d312Ba0b1215eA', + 'recipient': '0xfB2a9731cdA8b3FCa015723EF40f310C1E48366b', + 'amountWei': '2000000000000000000', + 'chainId': 11155111, }), 200, ); }); final dto = await build(client).createPayUnsignedTransaction( - const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q1'), + const RealUnitOcpPayDto(paymentLinkId: 'pl_realunit_ocp_sepolia', quoteId: 'q1'), ); expect(sentUri!.path, '/v1/realunit/pay/unsigned-transaction'); - expect(body!['paymentLinkId'], 'pl_abc'); + expect(body!['paymentLinkId'], 'pl_realunit_ocp_sepolia'); expect(body!['quoteId'], 'q1'); - expect(dto.recipient, '0xrecipient'); - expect(dto.amountWei, '5000000000000000000'); + expect(dto.recipient, '0xfB2a9731cdA8b3FCa015723EF40f310C1E48366b'); + expect(dto.tokenAddress, '0xD3117681cA462268048f57D106d312Ba0b1215eA'); + expect(dto.amountWei, '2000000000000000000'); + expect(dto.chainId, 11155111); }); - test('400 (mainnet-only fail-fast on testnet) → ApiException', () async { + test('non-200 → ApiException', () async { final client = MockClient( (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'unsupported method'}), 400), @@ -309,63 +314,6 @@ void main() { }); }); - group('isPaySupportedEnvironment (up-front capability gate)', () { - test('is true on mainnet', () { - when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); - final client = MockClient((_) async => http.Response('{}', 200)); - expect(build(client).isPaySupportedEnvironment, isTrue); - }); - - test('is false on testnet', () { - when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet)); - final client = MockClient((_) async => http.Response('{}', 200)); - expect(build(client).isPaySupportedEnvironment, isFalse); - }); - }); - - group('testnet fail-fast (mainnet-only OCP settlement)', () { - test('createPayUnsignedTransaction throws PayUnsupportedEnvironmentException', () async { - when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet)); - var clientCalled = false; - final client = MockClient((_) async { - clientCalled = true; - return http.Response('{}', 200); - }); - - await expectLater( - build(client).createPayUnsignedTransaction( - const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q1'), - ), - throwsA(isA()), - ); - expect(clientCalled, isFalse); - }); - - test('submitPay throws PayUnsupportedEnvironmentException without a round-trip', () async { - when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet)); - var clientCalled = false; - final client = MockClient((_) async { - clientCalled = true; - return http.Response('{}', 200); - }); - - await expectLater( - build(client).submitPay( - const RealUnitOcpPaySubmitDto( - unsignedTx: '0xtx', - r: '0xr', - s: '0xs', - v: 27, - paymentLinkId: 'pl_abc', - quoteId: 'q1', - ), - ), - throwsA(isA()), - ); - expect(clientCalled, isFalse); - }); - }); - group('getPayStatus', () { test('GETs /pay/:id/status and parses the status', () async { Uri? sentUri; diff --git a/test/screens/pay/pay_process_cubit_test.dart b/test/screens/pay/pay_process_cubit_test.dart index 21027f0d..ad632b97 100644 --- a/test/screens/pay/pay_process_cubit_test.dart +++ b/test/screens/pay/pay_process_cubit_test.dart @@ -141,9 +141,6 @@ void main() { when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); when(() => appStore.primaryAddress).thenReturn('0xwallet'); - // Default: the environment can settle OCP (mainnet). The up-front gate in - // start() reads this before any on-chain action. - when(() => payService.isPaySupportedEnvironment).thenReturn(true); when(() => appStore.wallet).thenReturn(wallet); when(() => wallet.walletType).thenReturn(WalletType.software); when(() => wallet.currentAccount).thenReturn(account); @@ -498,22 +495,6 @@ void main() { await cubit.close(); }); - test('unsupported environment → fails BEFORE any swap (no on-chain action)', () async { - wireHappyPath(); - when(() => payService.isPaySupportedEnvironment).thenReturn(false); - - final cubit = build(); - await cubit.start(); - - final state = cubit.state as PayProcessFailure; - expect(state.reason, PayProcessFailureReason.payUnsupportedEnvironment); - // The irreversible swap must never run on an unsupported environment. - verifyNever(() => payService.getSwapPaymentInfo(any())); - verifyNever(() => payService.createSwapUnsignedTransaction(any())); - verifyNever(() => payService.broadcastSwapTransaction(any(), any())); - await cubit.close(); - }); - test('BitBox disconnect during the pay sign (after swap) → pay-only retry', () async { wireHappyPath(); // First sign (swap) succeeds; the second sign (pay) reports a dropped BLE diff --git a/test/screens/pay/pay_process_page_test.dart b/test/screens/pay/pay_process_page_test.dart index 291221dd..bff92a33 100644 --- a/test/screens/pay/pay_process_page_test.dart +++ b/test/screens/pay/pay_process_page_test.dart @@ -44,7 +44,6 @@ void main() { // start(). A debug wallet makes start() settle immediately // (signatureUnsupported) without touching the chain. final payService = _MockPayService(); - when(() => payService.isPaySupportedEnvironment).thenReturn(true); getIt.registerSingleton(payService); getIt.registerSingleton(_MockFaucetService()); getIt.registerSingleton(_MockBlockchainService()); @@ -204,15 +203,6 @@ void main() { expect(find.text(S.current.payFailureInsufficientEth), findsOne); }); - testWidgets('unsupported-environment failure message', (tester) async { - await pumpWithState( - tester, - const PayProcessFailure(PayProcessFailureReason.payUnsupportedEnvironment), - ); - - expect(find.text(S.current.payFailureUnsupportedEnvironment), findsOne); - }); - testWidgets('signature-unsupported failure message', (tester) async { await pumpWithState( tester, diff --git a/test/screens/pay/pay_quote_cubit_test.dart b/test/screens/pay/pay_quote_cubit_test.dart index d0579eea..91d8b34b 100644 --- a/test/screens/pay/pay_quote_cubit_test.dart +++ b/test/screens/pay/pay_quote_cubit_test.dart @@ -8,14 +8,17 @@ import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dar class _MockPayService extends Mock implements RealUnitPayService {} +// Real Sepolia OCP capture (DFXswiss/api #3819): a CHF 2.00 payment link whose +// Ethereum method settles 2.0 ZCHF. The cubit reads these amounts verbatim from +// the public lnurlp quote — it never computes them. LnurlpPaymentDto _details({ required DateTime expiration, bool withEthZchf = true, - double zchf = 42.7, + double zchf = 2.0, }) { return LnurlpPaymentDto( - requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 42.5), - quote: LnurlpQuoteDto(id: 'quote_xyz', expiration: expiration), + requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 2), + quote: LnurlpQuoteDto(id: 'plq_realunit_ocp_sepolia', expiration: expiration), transferAmounts: [ if (withEthZchf) LnurlpTransferAmountDto( @@ -36,29 +39,15 @@ void main() { setUp(() { payService = _MockPayService(); - // Default: the environment can settle OCP (mainnet). load() checks this - // up-front before fetching the quote. - when(() => payService.isPaySupportedEnvironment).thenReturn(true); }); - PayQuoteCubit build() => PayQuoteCubit(payService, 'pl_abc'); - - blocTest( - 'an unsupported environment emits PayQuoteUnsupportedEnvironment without fetching', - build: build, - setUp: () { - when(() => payService.isPaySupportedEnvironment).thenReturn(false); - }, - act: (cubit) => cubit.load(), - expect: () => [isA(), isA()], - verify: (_) => verifyNever(() => payService.getPaymentDetails(any())), - ); + PayQuoteCubit build() => PayQuoteCubit(payService, 'pl_realunit_ocp_sepolia'); blocTest( 'a fresh quote with an Ethereum/ZCHF method emits PayQuoteReady', build: build, setUp: () { - when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenAnswer( (_) async => _details(expiration: DateTime.now().add(const Duration(minutes: 5))), ); }, @@ -66,10 +55,10 @@ void main() { expect: () => [isA(), isA()], verify: (cubit) { final state = cubit.state as PayQuoteReady; - expect(state.quoteId, 'quote_xyz'); + expect(state.quoteId, 'plq_realunit_ocp_sepolia'); expect(state.fiatAsset, 'CHF'); - expect(state.fiatAmount, 42.5); - expect(state.zchfAmount, 42.7); + expect(state.fiatAmount, 2); + expect(state.zchfAmount, 2.0); }, ); @@ -77,7 +66,7 @@ void main() { 'an expired quote emits PayQuoteExpired', build: build, setUp: () { - when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenAnswer( (_) async => _details(expiration: DateTime.now().subtract(const Duration(minutes: 1))), ); }, @@ -89,7 +78,7 @@ void main() { 'a link without an Ethereum/ZCHF method emits PayQuoteUnavailable', build: build, setUp: () { - when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenAnswer( (_) async => _details( expiration: DateTime.now().add(const Duration(minutes: 5)), withEthZchf: false, @@ -104,7 +93,7 @@ void main() { 'a service error emits PayQuoteError', build: build, setUp: () { - when(() => payService.getPaymentDetails('pl_abc')).thenThrow( + when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenThrow( const ApiException(code: 'X', message: 'boom'), ); }, diff --git a/test/screens/pay/pay_quote_page_test.dart b/test/screens/pay/pay_quote_page_test.dart index 55d8508b..d366ad7b 100644 --- a/test/screens/pay/pay_quote_page_test.dart +++ b/test/screens/pay/pay_quote_page_test.dart @@ -7,6 +7,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/config/api_config.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.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'; @@ -38,21 +39,26 @@ class _MockWallet extends Mock implements SoftwareWallet {} void main() { late _MockPayQuoteCubit quoteCubit; + // Real Sepolia OCP capture (DFXswiss/api #3819): CHF 2.00 → 2.0 ZCHF on the + // Ethereum method. const ready = PayQuoteReady( - paymentLinkId: 'pl_abc', - quoteId: 'quote_xyz', + paymentLinkId: 'pl_realunit_ocp_sepolia', + quoteId: 'plq_realunit_ocp_sepolia', fiatAsset: 'CHF', - fiatAmount: 42.5, - zchfAmount: 42.7, + fiatAmount: 2, + zchfAmount: 2.0, ); setUpAll(() { final getIt = GetIt.instance; - // PayQuotePage resolves the pay service from getIt and calls load(); an - // unsupported environment short-circuits load() without any network. + // PayQuotePage resolves the pay service from getIt and calls load(); the + // load throws a typed error here so the pushed route builds deterministically + // (rendering PayQuoteError) without a live backend. final payService = _MockPayService(); - when(() => payService.isPaySupportedEnvironment).thenReturn(false); + when(() => payService.getPaymentDetails(any())).thenThrow( + const ApiException(code: 'TEST', message: 'no backend in widget test'), + ); getIt.registerSingleton(payService); // The confirm button pushes PayProcessPage, which resolves a full service @@ -103,9 +109,9 @@ void main() { when(() => quoteCubit.state).thenReturn(ready); await tester.pumpApp(buildSubject()); - expect(find.text(S.current.payQuoteSummary('42.50', 'CHF')), findsOne); - expect(find.text('42.50 CHF'), findsOne); - expect(find.text('42.70 ZCHF'), findsOne); + 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.payConfirmButton), findsOne); }); @@ -137,13 +143,6 @@ void main() { expect(find.text(S.current.payQuoteUnavailable), findsOne); }); - testWidgets('unsupported-environment state shows the environment message', (tester) async { - when(() => quoteCubit.state).thenReturn(const PayQuoteUnsupportedEnvironment()); - await tester.pumpApp(buildSubject()); - - expect(find.text(S.current.payFailureUnsupportedEnvironment), findsOne); - }); - testWidgets('error state shows the generic failure message', (tester) async { when(() => quoteCubit.state).thenReturn(const PayQuoteError('boom')); await tester.pumpApp(buildSubject()); diff --git a/test/screens/pay/pay_scan_page_test.dart b/test/screens/pay/pay_scan_page_test.dart index 15b6da80..b8c486d4 100644 --- a/test/screens/pay/pay_scan_page_test.dart +++ b/test/screens/pay/pay_scan_page_test.dart @@ -6,6 +6,7 @@ import 'package:get_it/get_it.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/lnurl_decoder.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart'; @@ -28,11 +29,13 @@ void main() { stubMobileScannerChannel(); // The decoded-link navigation pushes PayQuotePage, which resolves the pay - // service from getIt and triggers a load(); register a mock so the pushed - // route builds. The load is gated off via an unsupported environment so no - // network is touched. + // service from getIt and triggers a load(); register a mock whose quote + // fetch throws so the pushed route builds deterministically (rendering the + // PayQuoteError message) without touching a live backend. final payService = _MockPayService(); - when(() => payService.isPaySupportedEnvironment).thenReturn(false); + when(() => payService.getPaymentDetails(any())).thenThrow( + const ApiException(code: 'TEST', message: 'no backend in widget test'), + ); GetIt.instance.registerSingleton(payService); }); From 0c19f4496a52d124312555c5811df6cb4217932b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:30:21 +0200 Subject: [PATCH 7/7] test(pay): order imports + fix stale fixture comment --- test/packages/service/dfx/real_unit_pay_service_test.dart | 2 +- test/screens/pay/pay_quote_page_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/packages/service/dfx/real_unit_pay_service_test.dart b/test/packages/service/dfx/real_unit_pay_service_test.dart index 39ec0132..2717993f 100644 --- a/test/packages/service/dfx/real_unit_pay_service_test.dart +++ b/test/packages/service/dfx/real_unit_pay_service_test.dart @@ -219,7 +219,7 @@ void main() { }); group('createPayUnsignedTransaction', () { - // Real Sepolia OCP capture (DFXswiss/api #3819, ocp_tx_details_sepolia.json): + // Values from the real Sepolia OCP testnet run (DFXswiss/api #3819): // a CHF 2.00 link settling 2.0 ZCHF (2000000000000000000 wei) to the DFX // deposit recipient on chain 11155111. test('PUTs /pay/unsigned-transaction with the payment refs', () async { diff --git a/test/screens/pay/pay_quote_page_test.dart b/test/screens/pay/pay_quote_page_test.dart index d366ad7b..6e1952bc 100644 --- a/test/screens/pay/pay_quote_page_test.dart +++ b/test/screens/pay/pay_quote_page_test.dart @@ -7,8 +7,8 @@ import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/config/api_config.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; -import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.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/wallet_service.dart';