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/8] 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 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 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/8] =?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/8] 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 ca728813d8de06ac6f365558455306705e4781bf Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:46:53 +0200 Subject: [PATCH 4/8] feat: RealUnit wallet-to-wallet (W2W) gasless transfer flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a wallet-to-wallet send flow that transfers REALU to another wallet (recipient via QR scan or manual entry), consuming the gasless EIP-7702 transfer endpoints from DFXswiss/api#3820. The transfer is gasless: DFX pays gas from a dedicated W2W gas wallet via EIP-7702, so the app signs an EIP-712 delegation + an EIP-7702 authorization exactly like the SOFTWARE gasless sell confirm — reusing eip712_signer.dart / eip7702_signer.dart and the wallet unlock/lock boundary. The capability gate (software-only signing) surfaces a dedicated unsupported state for debug/BitBox wallets; the flow is not otherwise branched on wallet type. - Steps: enter/scan recipient -> amount (whole REALU shares, vs available balance) -> confirm -> sign + PUT /transfer then /transfer/:id/confirm -> success (txHash) / typed failures (unsupported wallet, signature cancelled, invalid request, gas-funding-unavailable from the API 503). - Page + Cubit per step with separate state files. - Reuse the QR scanner: extract a shared QrScannerView wrapping MobileScanner and refactor the pay scan page onto it (no scanner duplication). - RealUnitTransferService (extends DFXAuthService) with the two endpoints, fromJson DTOs, and typed exceptions enumerated in exception_surface_test. - Navigation: AppRoutes.send + GoRoute('/send') + a dashboard Send action. - i18n: new keys in both ARBs, German + English. - bloc_test/mocktail cubit tests for every step + typed failure, widget tests for the pages, 100% scoped coverage, golden tests + baselines. --- assets/languages/strings_de.arb | 28 ++ assets/languages/strings_en.arb | 28 ++ .../payment/transfer_exceptions.dart | 51 +++ .../transfer/dto/real_unit_transfer_dto.dart | 19 + .../real_unit_transfer_eip7702_data_dto.dart | 67 ++++ .../real_unit_transfer_payment_info_dto.dart | 38 ++ .../dfx/real_unit_transfer_service.dart | 194 ++++++++++ .../widgets/sections/dashboard_actions.dart | 11 + lib/screens/pay/pay_scan_page.dart | 9 +- .../cubits/send_amount/send_amount_cubit.dart | 60 +++ .../cubits/send_amount/send_amount_state.dart | 37 ++ .../send_process/send_process_cubit.dart | 90 +++++ .../send_process/send_process_state.dart | 66 ++++ .../send_recipient/send_recipient_cubit.dart | 55 +++ .../send_recipient/send_recipient_state.dart | 33 ++ lib/screens/send/send_amount_page.dart | 147 ++++++++ lib/screens/send/send_confirm_page.dart | 85 +++++ lib/screens/send/send_process_page.dart | 134 +++++++ lib/screens/send/send_recipient_page.dart | 114 ++++++ lib/setup/di.dart | 4 + lib/setup/routing/router_config.dart | 7 + lib/setup/routing/routes/app_routes.dart | 1 + lib/widgets/scanner/qr_scanner_view.dart | 30 ++ .../goldens/macos/send_amount_page_empty.png | Bin 0 -> 13451 bytes .../macos/send_amount_page_insufficient.png | Bin 0 -> 16707 bytes .../send/goldens/macos/send_confirm_page.png | Bin 0 -> 24324 bytes .../macos/send_process_page_signing.png | Bin 0 -> 10320 bytes .../macos/send_recipient_page_empty.png | Bin 0 -> 18297 bytes .../screens/send/send_golden_test.dart | 161 +++++++++ .../exceptions/exception_surface_test.dart | 4 + .../payment/transfer/transfer_dtos_test.dart | 118 ++++++ .../dfx/real_unit_transfer_service_test.dart | 342 ++++++++++++++++++ .../send/cubits/send_amount_cubit_test.dart | 87 +++++ .../send/cubits/send_process_cubit_test.dart | 262 ++++++++++++++ .../cubits/send_recipient_cubit_test.dart | 73 ++++ test/screens/send/send_amount_page_test.dart | 120 ++++++ test/screens/send/send_confirm_page_test.dart | 71 ++++ test/screens/send/send_process_page_test.dart | 183 ++++++++++ .../send/send_recipient_page_test.dart | 181 +++++++++ 39 files changed, 2904 insertions(+), 6 deletions(-) create mode 100644 lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart create mode 100644 lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart create mode 100644 lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart create mode 100644 lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart create mode 100644 lib/packages/service/dfx/real_unit_transfer_service.dart create mode 100644 lib/screens/send/cubits/send_amount/send_amount_cubit.dart create mode 100644 lib/screens/send/cubits/send_amount/send_amount_state.dart create mode 100644 lib/screens/send/cubits/send_process/send_process_cubit.dart create mode 100644 lib/screens/send/cubits/send_process/send_process_state.dart create mode 100644 lib/screens/send/cubits/send_recipient/send_recipient_cubit.dart create mode 100644 lib/screens/send/cubits/send_recipient/send_recipient_state.dart create mode 100644 lib/screens/send/send_amount_page.dart create mode 100644 lib/screens/send/send_confirm_page.dart create mode 100644 lib/screens/send/send_process_page.dart create mode 100644 lib/screens/send/send_recipient_page.dart create mode 100644 lib/widgets/scanner/qr_scanner_view.dart create mode 100644 test/goldens/screens/send/goldens/macos/send_amount_page_empty.png create mode 100644 test/goldens/screens/send/goldens/macos/send_amount_page_insufficient.png create mode 100644 test/goldens/screens/send/goldens/macos/send_confirm_page.png create mode 100644 test/goldens/screens/send/goldens/macos/send_process_page_signing.png create mode 100644 test/goldens/screens/send/goldens/macos/send_recipient_page_empty.png create mode 100644 test/goldens/screens/send/send_golden_test.dart create mode 100644 test/packages/service/dfx/models/payment/transfer/transfer_dtos_test.dart create mode 100644 test/packages/service/dfx/real_unit_transfer_service_test.dart create mode 100644 test/screens/send/cubits/send_amount_cubit_test.dart create mode 100644 test/screens/send/cubits/send_process_cubit_test.dart create mode 100644 test/screens/send/cubits/send_recipient_cubit_test.dart create mode 100644 test/screens/send/send_amount_page_test.dart create mode 100644 test/screens/send/send_confirm_page_test.dart create mode 100644 test/screens/send/send_process_page_test.dart create mode 100644 test/screens/send/send_recipient_page_test.dart diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index bc0b3530..8304bbb3 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -294,7 +294,35 @@ "sellReviewAndConfirm": "Verkauf prüfen & bestätigen", "sellSuccess": "Verkauf erfolgreich", "sellSuccessDescription": "Der Betrag wird Ihnen auf das angegebene Bankkonto ausgezahlt.", + "send": "Senden", + "sendAmountAvailable": "Verfügbar: ${shares} REALU", + "sendAmountInsufficient": "Nicht genügend REALU in Ihrer Wallet.", + "sendAmountInvalid": "Geben Sie eine ganze Anzahl REALU-Anteile ein.", + "sendAmountLabel": "Betrag in REALU-Anteilen", + "sendAmountTitle": "Zu sendender Betrag", + "sendConfirmAmount": "Betrag", + "sendConfirmButton": "REALU senden", + "sendConfirmRecipient": "Empfänger", + "sendConfirmSummary": "Sie senden ${shares} REALU", + "sendConfirmTitle": "Überweisung prüfen", + "sendFailureGasUnavailable": "Überweisungen sind vorübergehend nicht verfügbar. Ihre REALU bleiben unangetastet – bitte versuchen Sie es später erneut.", + "sendFailureGeneric": "Bei der Überweisung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "sendFailureInvalidRequest": "Die Überweisung konnte nicht vorbereitet werden. Bitte prüfen Sie Empfänger und Betrag und versuchen Sie es erneut.", + "sendFailureSignatureCancelled": "Die Signatur wurde abgebrochen. Ihre REALU bleiben unangetastet.", + "sendFailureSignatureUnsupported": "Diese Wallet kann keine gaslosen Überweisungen signieren. Wechseln Sie zu einer Software-Wallet, um REALU zu senden.", + "sendFailureTitle": "Überweisung fehlgeschlagen", "sending": "Wird gesendet", + "sendPaste": "Einfügen", + "sendPreparing": "Überweisung wird vorbereitet", + "sendProcessTitle": "REALU wird gesendet", + "sendRecipientInvalid": "Dies ist keine gültige Wallet-Adresse.", + "sendRecipientLabel": "Empfängeradresse", + "sendRecipientManualHint": "Scannen Sie einen Wallet-QR-Code oder fügen Sie die Empfängeradresse unten ein.", + "sendRecipientTitle": "Empfänger scannen", + "sendShares": "${shares} REALU", + "sendSigning": "Bestätigen Sie die Überweisung in Ihrer Wallet", + "sendSuccess": "Überweisung gesendet", + "sendSuccessDescription": "Ihre REALU sind auf dem Weg zum Empfänger.", "setNationalityFailed": "Ihre Staatsangehörigkeit konnte nicht gesetzt werden:\n${message}", "settings": "Einstellungen", "settingsAppVersion": "Version ${tag}", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 19b0a59a..21c09c40 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -294,7 +294,35 @@ "sellReviewAndConfirm": "Review & confirm sale", "sellSuccess": "Sell successful", "sellSuccessDescription": "The amount will be paid into the bank account you have specified.", + "send": "Send", + "sendAmountAvailable": "Available: ${shares} REALU", + "sendAmountInsufficient": "Not enough REALU in your wallet.", + "sendAmountInvalid": "Enter a whole number of REALU shares.", + "sendAmountLabel": "Amount in REALU shares", + "sendAmountTitle": "Amount to send", + "sendConfirmAmount": "Amount", + "sendConfirmButton": "Send REALU", + "sendConfirmRecipient": "Recipient", + "sendConfirmSummary": "You send ${shares} REALU", + "sendConfirmTitle": "Review transfer", + "sendFailureGasUnavailable": "Transfers are temporarily unavailable. Your REALU is untouched — please try again later.", + "sendFailureGeneric": "Something went wrong with the transfer. Please try again.", + "sendFailureInvalidRequest": "The transfer could not be prepared. Please check the recipient and amount and try again.", + "sendFailureSignatureCancelled": "The signature was cancelled. Your REALU is untouched.", + "sendFailureSignatureUnsupported": "This wallet cannot sign gasless transfers. Switch to a software wallet to send REALU.", + "sendFailureTitle": "Transfer failed", "sending": "Sending", + "sendPaste": "Paste", + "sendPreparing": "Preparing transfer", + "sendProcessTitle": "Sending REALU", + "sendRecipientInvalid": "This is not a valid wallet address.", + "sendRecipientLabel": "Recipient address", + "sendRecipientManualHint": "Scan a wallet QR code, or paste the recipient address below.", + "sendRecipientTitle": "Scan recipient", + "sendShares": "${shares} REALU", + "sendSigning": "Confirm the transfer in your wallet", + "sendSuccess": "Transfer sent", + "sendSuccessDescription": "Your REALU is on its way to the recipient.", "setNationalityFailed": "Could not set your nationality:\n${message}", "settings": "Settings", "settingsAppVersion": "Version ${tag}", diff --git a/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart b/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart new file mode 100644 index 00000000..362409d0 --- /dev/null +++ b/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart @@ -0,0 +1,51 @@ +// Typed failures for the wallet-to-wallet (W2W) RealUnit transfer flow +// (enter/scan recipient → amount → confirm → sign → transfer → confirm). Each +// one renders a human-readable string (enumerated in `exception_surface_test.dart`) +// so it surfaces cleanly in logs and user-facing error states instead of the +// Dart default `Instance of '...'`. Typed failures drive control flow — no +// error-string parsing. + +/// The recipient string the user scanned or pasted is not a syntactically valid +/// EVM address. This is a client-side UX guard only; the API remains the final +/// authority on the address. +class InvalidRecipientAddressException implements Exception { + /// The rejected raw input, for diagnostics. + final String input; + + const InvalidRecipientAddressException(this.input); + + @override + String toString() => 'InvalidRecipientAddressException: $input is not a valid wallet address'; +} + +/// The active wallet mode cannot produce the EIP-712 delegation + EIP-7702 +/// authorization the gasless transfer requires (today: the debug wallet, and +/// hardware wallets whose firmware exposes no raw EIP-7702 signing API). The +/// flow is not branched on wallet type beyond this capability gate. +class TransferSignatureUnsupportedException implements Exception { + /// Diagnostic detail (e.g. the underlying signer message). + final String detail; + + const TransferSignatureUnsupportedException([ + this.detail = 'this wallet mode cannot sign gasless transfers', + ]); + + @override + String toString() => 'TransferSignatureUnsupportedException: $detail'; +} + +/// DFX cannot currently fund gas for the gasless transfer (the backend's +/// dedicated W2W gas wallet is unconfigured or below its low-balance +/// threshold). Surfaced from the API's `ServiceUnavailable` (503) as a friendly +/// "temporarily unavailable" state — the user's REALU is untouched. +class TransferGasFundingUnavailableException implements Exception { + /// Diagnostic detail (e.g. the API message), for logs. + final String detail; + + const TransferGasFundingUnavailableException([ + this.detail = 'gas funding for transfers is temporarily unavailable', + ]); + + @override + String toString() => 'TransferGasFundingUnavailableException: $detail'; +} diff --git a/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart new file mode 100644 index 00000000..29cb4af4 --- /dev/null +++ b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart @@ -0,0 +1,19 @@ +/// Request body for `PUT /v1/realunit/transfer` — the wallet-to-wallet +/// transfer intent. REALU has `decimals = 0`, so [amount] is a whole number of +/// shares. +class RealUnitTransferDto { + final String toAddress; + final int amount; + + const RealUnitTransferDto({ + required this.toAddress, + required this.amount, + }); + + Map toJson() { + return { + 'toAddress': toAddress, + 'amount': amount, + }; + } +} diff --git a/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart new file mode 100644 index 00000000..8c4f659c --- /dev/null +++ b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart @@ -0,0 +1,67 @@ +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart'; + +/// EIP-7702 delegation data for a gasless wallet-to-wallet REALU transfer. +/// +/// Mirrors the sell flow's [Eip7702Data] (`domain`/`types`/`message` are the +/// exact same shape the EIP-712 delegation + EIP-7702 authorization signers +/// consume), but the transfer endpoint returns the on-chain destination as +/// `recipient` rather than the sell flow's `depositAddress`. +class RealUnitTransferEip7702Data { + final String relayerAddress; + final String delegationManagerAddress; + final String delegatorAddress; + final int userNonce; + final Eip7702Domain domain; + final Eip7702Types types; + final Eip7702Message message; + final String tokenAddress; + final String amountWei; + final String recipient; + + const RealUnitTransferEip7702Data({ + required this.relayerAddress, + required this.delegationManagerAddress, + required this.delegatorAddress, + required this.userNonce, + required this.domain, + required this.types, + required this.message, + required this.tokenAddress, + required this.amountWei, + required this.recipient, + }); + + factory RealUnitTransferEip7702Data.fromJson(Map json) { + return RealUnitTransferEip7702Data( + relayerAddress: json['relayerAddress'] as String, + delegationManagerAddress: json['delegationManagerAddress'] as String, + delegatorAddress: json['delegatorAddress'] as String, + userNonce: json['userNonce'] as int, + domain: Eip7702Domain.fromJson(json['domain'] as Map), + types: Eip7702Types.fromJson(json['types'] as Map), + message: Eip7702Message.fromJson(json['message'] as Map), + tokenAddress: json['tokenAddress'] as String, + amountWei: json['amountWei'] as String, + recipient: json['recipient'] as String, + ); + } + + /// Adapts to the sell flow's [Eip7702Data] so the shared + /// `Eip712Signer.signDelegation` / `Eip7702Signer.signAuthorization` can sign + /// it without a transfer-specific signer. The signers never read + /// `depositAddress`, so the `recipient` is mapped through it verbatim. + Eip7702Data toEip7702Data() { + return Eip7702Data( + relayerAddress: relayerAddress, + delegationManagerAddress: delegationManagerAddress, + delegatorAddress: delegatorAddress, + userNonce: userNonce, + domain: domain, + types: types, + message: message, + tokenAddress: tokenAddress, + amountWei: amountWei, + depositAddress: recipient, + ); + } +} diff --git a/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart new file mode 100644 index 00000000..8c7658e8 --- /dev/null +++ b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart @@ -0,0 +1,38 @@ +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart'; + +/// Response of `PUT /v1/realunit/transfer` — the persisted transfer intent plus +/// the EIP-7702 delegation data the app must sign for the gasless REALU +/// transfer. +class RealUnitTransferPaymentInfoDto { + final int id; + final String uid; + final String toAddress; + final int amount; + final String tokenAddress; + final int chainId; + final RealUnitTransferEip7702Data eip7702; + + const RealUnitTransferPaymentInfoDto({ + required this.id, + required this.uid, + required this.toAddress, + required this.amount, + required this.tokenAddress, + required this.chainId, + required this.eip7702, + }); + + factory RealUnitTransferPaymentInfoDto.fromJson(Map json) { + return RealUnitTransferPaymentInfoDto( + id: json['id'] as int, + uid: json['uid'] as String, + toAddress: json['toAddress'] as String, + amount: (json['amount'] as num).toInt(), + tokenAddress: json['tokenAddress'] as String, + chainId: json['chainId'] as int, + eip7702: RealUnitTransferEip7702Data.fromJson( + json['eip7702'] as Map, + ), + ); + } +} diff --git a/lib/packages/service/dfx/real_unit_transfer_service.dart b/lib/packages/service/dfx/real_unit_transfer_service.dart new file mode 100644 index 00000000..50df0726 --- /dev/null +++ b/lib/packages/service/dfx/real_unit_transfer_service.dart @@ -0,0 +1,194 @@ +import 'dart:convert'; + +import 'package:eip7702/eip7702.dart' as eip7702; +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/transfer_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_confirm_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/wallet/eip712_signer.dart'; +import 'package:realunit_wallet/packages/wallet/eip7702_signer.dart'; + +/// Consumes the gasless wallet-to-wallet RealUnit transfer endpoints +/// (`PUT /v1/realunit/transfer`, `PUT /v1/realunit/transfer/:id/confirm` — +/// DFXswiss/api #3820). DFX pays gas via EIP-7702 from a dedicated W2W gas +/// wallet, so the app signs an EIP-712 delegation + an EIP-7702 authorization — +/// the exact pattern the SOFTWARE gasless sell confirm uses +/// ([RealUnitSellPaymentInfoService.confirmPayment]). +class RealUnitTransferService extends DFXAuthService { + static const _transferPath = '/v1/realunit/transfer'; + static String _confirmPath(int id) => '/v1/realunit/transfer/$id/confirm'; + + // MetaMask Delegation Framework v1.3.0, CREATE2 — identical on all EVM chains. + // Pinned exactly as the sell software-confirm path pins them, so a tampered + // delegation payload is rejected before it is signed. + static const _metaMaskDelegatorAddress = '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b'; + static const _delegationManagerAddress = '0xdb9b1e94b5b69df7e401ddbede43491141047db3'; + + RealUnitTransferService(super.appStore, super.walletService); + + /// `PUT /transfer` — persists the transfer intent and returns the EIP-7702 + /// delegation data to sign. A 503 means DFX cannot currently fund gas; it is + /// surfaced as a typed [TransferGasFundingUnavailableException] so the flow can + /// render a friendly "temporarily unavailable" state. Every other non-2xx is + /// an [ApiException] (KYC30 / registration / invalid recipient are signaled by + /// the API and rendered from its message). + Future prepareTransfer(RealUnitTransferDto dto) async { + final uri = buildUri(host, _transferPath); + final response = await authenticatedPut( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(dto.toJson()), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return RealUnitTransferPaymentInfoDto.fromJson( + jsonDecode(response.body) as Map, + ); + } + + final errorJson = jsonDecode(response.body) as Map; + if (response.statusCode == 503) { + throw TransferGasFundingUnavailableException( + (errorJson['message'] ?? 'gas funding for transfers is temporarily unavailable').toString(), + ); + } + throw ApiException.fromJson(errorJson, httpStatusCode: response.statusCode); + } + + /// `PUT /transfer/:id/confirm` — signs the EIP-712 delegation + EIP-7702 + /// authorization and relays them; DFX broadcasts the gasless transfer and + /// returns the tx hash. + /// + /// Reuses the wallet unlock/lock boundary and the shared + /// `Eip712Signer.signDelegation` / `Eip7702Signer.signAuthorization` exactly + /// like the sell software-confirm. A wallet that cannot produce these + /// signatures (debug wallet, or hardware firmware without raw EIP-7702 + /// support) raises [TransferSignatureUnsupportedException] — the capability + /// gate, not a wallet-type branch. + Future confirmTransfer(RealUnitTransferPaymentInfoDto info) async { + // EIP-712 + EIP-7702 typed-data signing needs the private key; promote the + // view-wallet to a fully unlocked SoftwareWallet before reading credentials. + await walletService.ensureCurrentWalletUnlocked(); + try { + final credentials = appStore.wallet.currentAccount.primaryAddress; + final transferData = info.eip7702; + _validateEip7702Data(transferData, credentials.address.hexEip55, info.amount); + + final eip7702Data = transferData.toEip7702Data(); + final String delegationSignature; + final eip7702.EIP7702MsgSignature authorizationSignature; + try { + delegationSignature = await Eip712Signer.signDelegation( + credentials: credentials, + eip7702Data: eip7702Data, + ); + authorizationSignature = Eip7702Signer.signAuthorization( + credentials: credentials, + eip7702Data: eip7702Data, + ); + } on UnsupportedError catch (e) { + // Debug-wallet credentials reject typed-data signing with UnsupportedError. + throw TransferSignatureUnsupportedException(e.message ?? e.toString()); + } + + return _sendConfirm( + info.id, + Eip7702ConfirmDto( + delegation: Eip7702DelegationDto( + delegate: transferData.relayerAddress, + delegator: transferData.message.delegator, + authority: transferData.message.authority, + salt: '${transferData.message.salt}', + signature: delegationSignature, + ), + authorization: Eip7702AuthorizationDto( + chainId: transferData.domain.chainId, + address: transferData.delegatorAddress, + nonce: transferData.userNonce, + r: '0x${authorizationSignature.r.toRadixString(16).padLeft(64, '0')}', + s: '0x${authorizationSignature.s.toRadixString(16).padLeft(64, '0')}', + yParity: authorizationSignature.yParity, + ), + ), + ); + } finally { + // Drop the mnemonic from memory as soon as signing is done — runs on the + // throw path too so a validation/sign failure mid-sequence does not leave + // the key resident. Mirrors [RealUnitSellPaymentInfoService.confirmPayment]. + await walletService.lockCurrentWallet(); + } + } + + Future _sendConfirm(int id, Eip7702ConfirmDto dto) async { + final uri = buildUri(host, _confirmPath(id)); + final response = await authenticatedPut( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(dto.toJson()), + ); + + if (response.statusCode != 200 && response.statusCode != 201) { + final errorJson = jsonDecode(response.body) as Map; + if (response.statusCode == 503) { + throw TransferGasFundingUnavailableException( + (errorJson['message'] ?? 'gas funding for transfers is temporarily unavailable') + .toString(), + ); + } + throw ApiException.fromJson(errorJson, httpStatusCode: response.statusCode); + } + + return (jsonDecode(response.body) as Map)['txHash'] as String; + } + + /// Pins the signed contract addresses + cross-checks the signed/unsigned + /// fields against known values before signing, mirroring the sell flow. The + /// recipient is server-bound (the backend supplies the ERC20 transfer call at + /// execute time), so it is validated for amount/token/chain consistency here. + void _validateEip7702Data( + RealUnitTransferEip7702Data data, + String walletAddress, + int userAmount, + ) { + final expectedChainId = appStore.apiConfig.asset.chainId; + + if (data.delegatorAddress.toLowerCase() != _metaMaskDelegatorAddress) { + throw Exception( + 'EIP-7702 delegator address does not match expected MetaMask Delegator contract', + ); + } + if (data.delegationManagerAddress.toLowerCase() != _delegationManagerAddress) { + throw Exception('EIP-7702 delegation manager address does not match expected contract'); + } + if (data.domain.verifyingContract.toLowerCase() != _delegationManagerAddress) { + throw Exception('EIP-7702 verifying contract does not match expected DelegationManager'); + } + if (data.message.delegator.toLowerCase() != walletAddress.toLowerCase()) { + throw Exception('EIP-7702 message delegator does not match wallet address'); + } + if (data.domain.chainId != expectedChainId) { + throw Exception( + 'EIP-7702 chain ID mismatch: expected $expectedChainId, got ${data.domain.chainId}', + ); + } + if (data.message.delegate.toLowerCase() != data.relayerAddress.toLowerCase()) { + throw Exception('EIP-7702 message delegate does not match relayer address'); + } + if (data.tokenAddress.toLowerCase() != appStore.apiConfig.asset.address.toLowerCase()) { + throw Exception('EIP-7702 token address does not match RealUnit token'); + } + // REALU has decimals = 0, so the wei amount equals the share count; compute + // generically against the asset decimals so a non-zero-decimals asset would + // still be validated correctly. + final expectedWei = + BigInt.from(userAmount) * BigInt.from(10).pow(appStore.apiConfig.asset.decimals); + final actualWei = BigInt.tryParse(data.amountWei); + if (actualWei == null || actualWei != expectedWei) { + throw Exception('EIP-7702 amount mismatch: expected $expectedWei, got ${data.amountWei}'); + } + } +} diff --git a/lib/screens/dashboard/widgets/sections/dashboard_actions.dart b/lib/screens/dashboard/widgets/sections/dashboard_actions.dart index 2fb38a1f..0c64a6a6 100644 --- a/lib/screens/dashboard/widgets/sections/dashboard_actions.dart +++ b/lib/screens/dashboard/widgets/sections/dashboard_actions.dart @@ -46,6 +46,17 @@ class DashboardActions extends StatelessWidget { onPressed: () => context.pushNamed(AppRoutes.pay), ), ), + Expanded( + child: ActionButton( + icon: Icon( + Icons.send_rounded, + color: RealUnitColors.basic.white, + size: 20, + ), + label: S.of(context).send, + onPressed: () => context.pushNamed(AppRoutes.send), + ), + ), ], ); } diff --git a/lib/screens/pay/pay_scan_page.dart b/lib/screens/pay/pay_scan_page.dart index 302568c3..7cc13dd5 100644 --- a/lib/screens/pay/pay_scan_page.dart +++ b/lib/screens/pay/pay_scan_page.dart @@ -5,11 +5,11 @@ // 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'; +import 'package:realunit_wallet/widgets/scanner/qr_scanner_view.dart'; class PayScanPage extends StatelessWidget { const PayScanPage({super.key}); @@ -52,11 +52,8 @@ class PayScanView extends StatelessWidget { 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); - }, + body: QrScannerView( + onDetect: (raw) => context.read().onCodeDetected(raw), ), ); }, diff --git a/lib/screens/send/cubits/send_amount/send_amount_cubit.dart b/lib/screens/send/cubits/send_amount/send_amount_cubit.dart new file mode 100644 index 00000000..7a373374 --- /dev/null +++ b/lib/screens/send/cubits/send_amount/send_amount_cubit.dart @@ -0,0 +1,60 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +part 'send_amount_state.dart'; + +/// Validates the REALU amount to send. REALU has `decimals = 0`, so the amount +/// is a whole number of shares. The available balance (also whole shares) is +/// tracked here so the field can be validated against it locally for UX; the +/// API remains the authority and re-checks the balance on `PUT /transfer`. +class SendAmountCubit extends Cubit { + /// Available REALU shares in the wallet, or null when the balance is unknown + /// (the available-balance hint and the over-balance guard are then skipped — + /// the API still validates). Mutable because the balance arrives over a + /// stream and may update after the cubit is built. + BigInt? availableShares; + + SendAmountCubit({this.availableShares}) : super(const SendAmountState()); + + /// Updates the tracked balance when a fresh value arrives from the balance + /// stream, then re-evaluates the current input against it so an amount that + /// was provisionally valid (unknown balance) is re-checked. + void availableSharesChanged(BigInt shares) { + if (availableShares == shares) return; + availableShares = shares; + if (state.text.isNotEmpty) amountChanged(state.text); + } + + /// Re-evaluates [raw] on every keystroke and emits the parsed amount + a + /// validity flag the confirm button binds to. + void amountChanged(String raw) { + final text = raw.trim(); + if (text.isEmpty) { + emit(SendAmountState(text: text, amount: null, status: SendAmountStatus.empty)); + return; + } + + final parsed = int.tryParse(text); + if (parsed == null || parsed < 1) { + emit(SendAmountState(text: text, amount: parsed, status: SendAmountStatus.invalid)); + return; + } + + final balance = availableShares; + if (balance != null && BigInt.from(parsed) > balance) { + emit( + SendAmountState(text: text, amount: parsed, status: SendAmountStatus.insufficientBalance), + ); + return; + } + + emit(SendAmountState(text: text, amount: parsed, status: SendAmountStatus.valid)); + } + + /// Fills the field with the full available balance. + void useMax() { + final balance = availableShares; + if (balance == null || balance < BigInt.one) return; + amountChanged(balance.toString()); + } +} diff --git a/lib/screens/send/cubits/send_amount/send_amount_state.dart b/lib/screens/send/cubits/send_amount/send_amount_state.dart new file mode 100644 index 00000000..f666804f --- /dev/null +++ b/lib/screens/send/cubits/send_amount/send_amount_state.dart @@ -0,0 +1,37 @@ +part of 'send_amount_cubit.dart'; + +/// Validity of the entered amount. Each value maps to a localized hint/error in +/// the view — the cubit carries the status, not the copy. +enum SendAmountStatus { + /// Nothing entered yet. + empty, + + /// Not a whole number >= 1. + invalid, + + /// A valid whole number, but more than the available REALU balance. + insufficientBalance, + + /// A valid, sendable amount. + valid, +} + +class SendAmountState extends Equatable { + final String text; + + /// The parsed amount, or null when [text] is empty / not an integer. + final int? amount; + final SendAmountStatus status; + + const SendAmountState({ + this.text = '', + this.amount, + this.status = SendAmountStatus.empty, + }); + + /// The confirm button binds to this — only a fully valid amount may advance. + bool get isValid => status == SendAmountStatus.valid && amount != null; + + @override + List get props => [text, amount, status]; +} diff --git a/lib/screens/send/cubits/send_process/send_process_cubit.dart b/lib/screens/send/cubits/send_process/send_process_cubit.dart new file mode 100644 index 00000000..e7f5c2e5 --- /dev/null +++ b/lib/screens/send/cubits/send_process/send_process_cubit.dart @@ -0,0 +1,90 @@ +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/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; + +part 'send_process_state.dart'; + +/// Orchestrates the gasless wallet-to-wallet transfer once the user confirms: +/// gate the wallet's signing capability → `PUT /transfer` (prepare + EIP-7702 +/// delegation data) → sign the EIP-712 delegation + EIP-7702 authorization and +/// `PUT /transfer/:id/confirm` → success (txHash) / a typed failure. +/// +/// The gasless EIP-7702 path can only be signed by a software wallet today; +/// debug and BitBox wallets are gated up-front (the contract's capability gate — +/// the flow is NOT otherwise branched on wallet type). Insufficient REALU and +/// invalid-address are decided by the API and rendered from its signal; a 503 +/// (gas-funding-unavailable) surfaces as a dedicated "temporarily unavailable" +/// state. +class SendProcessCubit extends Cubit { + final RealUnitTransferService _transferService; + final AppStore _appStore; + final String _recipient; + final int _amount; + + SendProcessCubit({ + required RealUnitTransferService transferService, + required AppStore appStore, + required String recipient, + required int amount, + }) : _transferService = transferService, + _appStore = appStore, + _recipient = recipient, + _amount = amount, + super(const SendProcessInitial()); + + /// Entry point — called by the view once the user confirms the summary. + Future start() async { + // Capability gate BEFORE any network/sign action: only a software wallet can + // produce the EIP-712 delegation + EIP-7702 authorization the gasless + // transfer requires. Surface the dedicated unsupported state otherwise. + if (_appStore.wallet.walletType != WalletType.software) { + emit(const SendProcessFailure(SendProcessFailureReason.signatureUnsupported)); + return; + } + + try { + emit(const SendProcessPreparing()); + final info = await _transferService.prepareTransfer( + RealUnitTransferDto(toAddress: _recipient, amount: _amount), + ); + + emit(const SendProcessSigning()); + final txHash = await _transferService.confirmTransfer(info); + + emit(SendProcessSuccess(txHash)); + } on TransferSignatureUnsupportedException { + emit(const SendProcessFailure(SendProcessFailureReason.signatureUnsupported)); + } on TransferGasFundingUnavailableException { + emit(const SendProcessFailure(SendProcessFailureReason.gasFundingUnavailable)); + } on SigningCancelledException { + emit(const SendProcessFailure(SendProcessFailureReason.signatureCancelled)); + } on BitboxNotConnectedException { + emit(const SendProcessFailure(SendProcessFailureReason.signatureUnsupported)); + } on ApiException catch (e) { + // The API is the authority on recipient/amount/eligibility. Render its + // signaled reason rather than re-deriving limits locally. + emit(SendProcessFailure(_reasonForApi(e), message: e.message)); + } catch (e) { + emit(SendProcessFailure(SendProcessFailureReason.generic, message: e.toString())); + } + } + + /// Maps an API error to a typed failure reason. A 400 from `PUT /transfer` + /// covers both an invalid recipient and insufficient REALU; both render a + /// generic "could not prepare the transfer" message keyed off the API text, + /// so they share [SendProcessFailureReason.invalidRequest]. + static SendProcessFailureReason _reasonForApi(ApiException e) { + if (e.statusCode == 503) return SendProcessFailureReason.gasFundingUnavailable; + if (e.statusCode == 400 || e.statusCode == 404) { + return SendProcessFailureReason.invalidRequest; + } + return SendProcessFailureReason.generic; + } +} diff --git a/lib/screens/send/cubits/send_process/send_process_state.dart b/lib/screens/send/cubits/send_process/send_process_state.dart new file mode 100644 index 00000000..897345b1 --- /dev/null +++ b/lib/screens/send/cubits/send_process/send_process_state.dart @@ -0,0 +1,66 @@ +part of 'send_process_cubit.dart'; + +/// Why the transfer failed. Each reason maps to a localized, user-facing message +/// in the view — the cubit carries the reason, not the copy. +enum SendProcessFailureReason { + /// The active wallet mode cannot sign the gasless EIP-7702 transfer (debug or + /// BitBox wallet, or a debug credential detected at sign time). + signatureUnsupported, + + /// The user cancelled the signature (or the signing device dropped the link). + signatureCancelled, + + /// DFX cannot currently fund gas for the transfer (the API's 503). The user's + /// REALU is untouched — this is a transient "temporarily unavailable" state. + gasFundingUnavailable, + + /// The API rejected the prepare request (invalid recipient, self-transfer, + /// token-contract recipient, non-integer amount, or insufficient REALU). The + /// API message carries the specific detail. + invalidRequest, + + /// Any other unexpected error. + generic, +} + +sealed class SendProcessState extends Equatable { + const SendProcessState(); + + @override + List get props => []; +} + +class SendProcessInitial extends SendProcessState { + const SendProcessInitial(); +} + +/// `PUT /transfer` in flight — preparing the intent + EIP-7702 delegation data. +class SendProcessPreparing extends SendProcessState { + const SendProcessPreparing(); +} + +/// Signing the EIP-712 delegation + EIP-7702 authorization and confirming. +class SendProcessSigning extends SendProcessState { + const SendProcessSigning(); +} + +class SendProcessSuccess extends SendProcessState { + final String txHash; + + const SendProcessSuccess(this.txHash); + + @override + List get props => [txHash]; +} + +class SendProcessFailure extends SendProcessState { + final SendProcessFailureReason reason; + + /// Diagnostic detail for logs — not the user-facing copy. + final String? message; + + const SendProcessFailure(this.reason, {this.message}); + + @override + List get props => [reason, message]; +} diff --git a/lib/screens/send/cubits/send_recipient/send_recipient_cubit.dart b/lib/screens/send/cubits/send_recipient/send_recipient_cubit.dart new file mode 100644 index 00000000..993411f4 --- /dev/null +++ b/lib/screens/send/cubits/send_recipient/send_recipient_cubit.dart @@ -0,0 +1,55 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart'; +import 'package:web3dart/web3dart.dart' show EthereumAddress; + +part 'send_recipient_state.dart'; + +/// Captures the transfer recipient — either scanned from a QR code or pasted / +/// typed manually. Validation here is a client-side UX guard only (a malformed +/// address is rejected before the user can advance); the API remains the final +/// authority on the address. A scanned `ethereum:0x…` URI is normalized to the +/// bare address so a wallet QR that encodes an EIP-681 URI is accepted. +class SendRecipientCubit extends Cubit { + SendRecipientCubit() : super(const SendRecipientEmpty()); + + /// Called once per detected barcode while the scanner is open. Guards against + /// re-entry after a successful decode so a continuously-detecting scanner does + /// not re-emit. + void onCodeDetected(String raw) { + if (state is SendRecipientValid) return; + submit(raw); + } + + /// Validates a manually entered / pasted / scanned [input]. Emits + /// [SendRecipientValid] with the checksummed address on success, or + /// [SendRecipientInvalid] otherwise. + void submit(String input) { + final address = _extractAddress(input); + try { + final checksummed = EthereumAddress.fromHex(address).hexEip55; + emit(SendRecipientValid(checksummed)); + } catch (_) { + emit(SendRecipientInvalid(InvalidRecipientAddressException(input.trim()))); + } + } + + /// Clears the current selection so the field/scanner is ready for new input. + void reset() => emit(const SendRecipientEmpty()); + + /// Strips an optional `ethereum:` EIP-681 scheme and any `@chainId` / query + /// suffix, returning the bare hex address candidate. + static String _extractAddress(String input) { + var value = input.trim(); + if (value.toLowerCase().startsWith('ethereum:')) { + value = value.substring('ethereum:'.length); + } + final at = value.indexOf('@'); + if (at != -1) value = value.substring(0, at); + final query = value.indexOf('?'); + if (query != -1) value = value.substring(0, query); + final slash = value.indexOf('/'); + if (slash != -1) value = value.substring(0, slash); + return value.trim(); + } +} diff --git a/lib/screens/send/cubits/send_recipient/send_recipient_state.dart b/lib/screens/send/cubits/send_recipient/send_recipient_state.dart new file mode 100644 index 00000000..549d754e --- /dev/null +++ b/lib/screens/send/cubits/send_recipient/send_recipient_state.dart @@ -0,0 +1,33 @@ +part of 'send_recipient_cubit.dart'; + +sealed class SendRecipientState extends Equatable { + const SendRecipientState(); + + @override + List get props => []; +} + +/// No recipient entered yet. +class SendRecipientEmpty extends SendRecipientState { + const SendRecipientEmpty(); +} + +/// A syntactically valid EVM address, normalized to its EIP-55 checksum form. +class SendRecipientValid extends SendRecipientState { + final String address; + + const SendRecipientValid(this.address); + + @override + List get props => [address]; +} + +/// The entered/scanned value is not a valid EVM address (client-side UX guard). +class SendRecipientInvalid extends SendRecipientState { + final InvalidRecipientAddressException error; + + const SendRecipientInvalid(this.error); + + @override + List get props => [error.input]; +} diff --git a/lib/screens/send/send_amount_page.dart b/lib/screens/send/send_amount_page.dart new file mode 100644 index 00000000..bbb95ea0 --- /dev/null +++ b/lib/screens/send/send_amount_page.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/screens/sell/cubits/sell_balance/sell_balance_cubit.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_amount/send_amount_cubit.dart'; +import 'package:realunit_wallet/screens/send/send_confirm_page.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/styles/colors.dart'; + +/// Second step: choose the whole-share REALU amount. The available balance is +/// read via the shared [SellBalanceCubit] (a generic REALU balance watcher) and +/// fed into [SendAmountCubit] for the local over-balance UX guard. REALU has +/// `decimals = 0`, so the raw balance equals whole shares. +class SendAmountPage extends StatelessWidget { + final String recipient; + + const SendAmountPage({super.key, required this.recipient}); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => SellBalanceCubit(getIt(), getIt()), + ), + BlocProvider(create: (_) => SendAmountCubit()), + ], + child: SendAmountView(recipient: recipient), + ); + } +} + +class SendAmountView extends StatelessWidget { + final String recipient; + + const SendAmountView({super.key, required this.recipient}); + + @override + Widget build(BuildContext context) { + // The balance arrives over a stream; push every update into the amount + // cubit so the available hint + over-balance guard track the live balance + // (BlocProvider.create runs once, so the balance can't be captured there). + return BlocListener( + listener: (context, balance) => + context.read().availableSharesChanged(balance.balance), + child: _SendAmountBody(recipient: recipient), + ); + } +} + +class _SendAmountBody extends StatefulWidget { + final String recipient; + + const _SendAmountBody({required this.recipient}); + + @override + State<_SendAmountBody> createState() => _SendAmountBodyState(); +} + +class _SendAmountBodyState extends State<_SendAmountBody> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _syncController(String text) { + if (_controller.text == text) return; + _controller.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).sendAmountTitle)), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: BlocConsumer( + listenWhen: (previous, current) => previous.text != current.text, + listener: (context, state) => _syncController(state.text), + builder: (context, state) { + // The available hint tracks the live balance directly so it stays + // in sync with the stream. + final available = context.watch().state.balance; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + Text( + S.of(context).sendAmountAvailable(available.toString()), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + TextField( + controller: _controller, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + labelText: S.of(context).sendAmountLabel, + errorText: _errorText(context, state.status), + suffixIcon: TextButton( + onPressed: () => context.read().useMax(), + child: Text(S.of(context).max.toUpperCase()), + ), + ), + onChanged: (value) => context.read().amountChanged(value), + ), + const Spacer(), + FilledButton( + onPressed: state.isValid + ? () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SendConfirmPage( + recipient: widget.recipient, + amount: state.amount!, + ), + ), + ) + : null, + child: Text(S.of(context).next), + ), + ], + ); + }, + ), + ), + ), + ); + } + + String? _errorText(BuildContext context, SendAmountStatus status) => switch (status) { + SendAmountStatus.empty || SendAmountStatus.valid => null, + SendAmountStatus.invalid => S.of(context).sendAmountInvalid, + SendAmountStatus.insufficientBalance => S.of(context).sendAmountInsufficient, + }; +} diff --git a/lib/screens/send/send_confirm_page.dart b/lib/screens/send/send_confirm_page.dart new file mode 100644 index 00000000..f2ee52b6 --- /dev/null +++ b/lib/screens/send/send_confirm_page.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/send/send_process_page.dart'; +import 'package:realunit_wallet/styles/colors.dart'; + +/// Third step: review the recipient + amount before signing. Confirming starts +/// the on-chain process step. +class SendConfirmPage extends StatelessWidget { + final String recipient; + final int amount; + + const SendConfirmPage({super.key, required this.recipient, required this.amount}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).sendConfirmTitle)), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 24, + children: [ + const Spacer(), + Text( + S.of(context).sendConfirmSummary(amount.toString()), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium, + ), + _SummaryRow( + label: S.of(context).sendConfirmAmount, + value: S.of(context).sendShares(amount.toString()), + ), + _SummaryRow( + label: S.of(context).sendConfirmRecipient, + value: recipient, + ), + const Spacer(), + FilledButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SendProcessPage(recipient: recipient, amount: amount), + ), + ), + child: Text(S.of(context).sendConfirmButton), + ), + ], + ), + ), + ), + ); + } +} + +class _SummaryRow extends StatelessWidget { + final String label; + final String value; + + const _SummaryRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + value, + textAlign: TextAlign.end, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ); + } +} diff --git a/lib/screens/send/send_process_page.dart b/lib/screens/send/send_process_page.dart new file mode 100644 index 00000000..dc79c47a --- /dev/null +++ b/lib/screens/send/send_process_page.dart @@ -0,0 +1,134 @@ +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/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/styles/colors.dart'; + +/// Final step: prepare → sign (EIP-712 delegation + EIP-7702 authorization) → +/// confirm, then render the txHash success or a typed failure. The cubit drives +/// every outcome as a state — no error-string parsing in the view. +class SendProcessPage extends StatelessWidget { + final String recipient; + final int amount; + + const SendProcessPage({super.key, required this.recipient, required this.amount}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => SendProcessCubit( + transferService: getIt(), + appStore: getIt(), + recipient: recipient, + amount: amount, + )..start(), + child: const SendProcessView(), + ); + } +} + +class SendProcessView extends StatelessWidget { + const SendProcessView({super.key}); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (previous, current) => + current is SendProcessSuccess || current is SendProcessFailure, + listener: (context, state) async { + if (state is SendProcessSuccess) { + await _showResultSheet( + context, + icon: Icons.check_circle_rounded, + title: S.of(context).sendSuccess, + description: S.of(context).sendSuccessDescription, + ); + } else if (state is SendProcessFailure) { + await _showResultSheet( + context, + icon: Icons.error_rounded, + title: S.of(context).sendFailureTitle, + description: _failureMessage(context, state.reason), + ); + } + }, + builder: (context, state) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).sendProcessTitle)), + 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, SendProcessState state) => switch (state) { + SendProcessInitial() || SendProcessPreparing() => S.of(context).sendPreparing, + SendProcessSigning() => S.of(context).sendSigning, + SendProcessSuccess() => S.of(context).sendSuccess, + SendProcessFailure() => S.of(context).sendFailureTitle, + }; + + String _failureMessage(BuildContext context, SendProcessFailureReason reason) => switch (reason) { + SendProcessFailureReason.signatureUnsupported => S.of(context).sendFailureSignatureUnsupported, + SendProcessFailureReason.signatureCancelled => S.of(context).sendFailureSignatureCancelled, + SendProcessFailureReason.gasFundingUnavailable => S.of(context).sendFailureGasUnavailable, + SendProcessFailureReason.invalidRequest => S.of(context).sendFailureInvalidRequest, + SendProcessFailureReason.generic => S.of(context).sendFailureGeneric, + }; + + 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/send/send_recipient_page.dart b/lib/screens/send/send_recipient_page.dart new file mode 100644 index 00000000..989e424e --- /dev/null +++ b/lib/screens/send/send_recipient_page.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_recipient/send_recipient_cubit.dart'; +import 'package:realunit_wallet/screens/send/send_amount_page.dart'; +import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/scanner/qr_scanner_view.dart'; + +/// First step of the wallet-to-wallet send flow: pick the recipient by scanning +/// a wallet QR or pasting/typing the address. Reuses the shared +/// [QrScannerView] (same camera wrapper the OCP pay flow uses) so the scanner is +/// not duplicated; the EVM-address decode lives in [SendRecipientCubit]. +class SendRecipientPage extends StatelessWidget { + const SendRecipientPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => SendRecipientCubit(), + child: const SendRecipientView(), + ); + } +} + +class SendRecipientView extends StatefulWidget { + const SendRecipientView({super.key}); + + @override + State createState() => _SendRecipientViewState(); +} + +class _SendRecipientViewState extends State { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state is SendRecipientValid) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SendAmountPage(recipient: state.address), + ), + ); + context.read().reset(); + } + if (state is SendRecipientInvalid) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context).sendRecipientInvalid), + backgroundColor: RealUnitColors.status.red600, + ), + ); + } + }, + child: Scaffold( + appBar: AppBar(title: Text(S.of(context).sendRecipientTitle)), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: QrScannerView( + onDetect: (raw) => context.read().onCodeDetected(raw), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 12, + children: [ + Text( + S.of(context).sendRecipientManualHint, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + TextField( + controller: _controller, + autocorrect: false, + decoration: InputDecoration( + labelText: S.of(context).sendRecipientLabel, + suffixIcon: IconButton( + icon: const Icon(Icons.paste_rounded), + tooltip: S.of(context).sendPaste, + onPressed: () async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text; + if (text != null) _controller.text = text.trim(); + }, + ), + ), + ), + FilledButton( + onPressed: () => context.read().submit(_controller.text), + child: Text(S.of(context).next), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/setup/di.dart b/lib/setup/di.dart index 60fca67a..e553209c 100644 --- a/lib/setup/di.dart +++ b/lib/setup/di.dart @@ -33,6 +33,7 @@ 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'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart'; import 'package:realunit_wallet/packages/service/session_cache.dart'; import 'package:realunit_wallet/packages/service/settings_service.dart'; import 'package:realunit_wallet/packages/service/transaction_history_service.dart'; @@ -187,6 +188,9 @@ void setupServices() { getIt.registerFactory( () => RealUnitSellPaymentInfoService(getIt(), getIt()), ); + getIt.registerFactory( + () => RealUnitTransferService(getIt(), getIt()), + ); getIt.registerFactory(() => SettingsService(getIt())); getIt.registerFactory( () => DebugAuthService(getIt(), getIt()), diff --git a/lib/setup/routing/router_config.dart b/lib/setup/routing/router_config.dart index 610318c2..54538ab1 100644 --- a/lib/setup/routing/router_config.dart +++ b/lib/setup/routing/router_config.dart @@ -12,6 +12,7 @@ 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/send/send_recipient_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'; @@ -157,6 +158,12 @@ final GoRouter routerConfig = GoRouter( builder: (_, _) => const PayScanPage(), ), + GoRoute( + name: AppRoutes.send, + path: '/send', + builder: (_, _) => const SendRecipientPage(), + ), + 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 721e9e91..bb7491a4 100644 --- a/lib/setup/routing/routes/app_routes.dart +++ b/lib/setup/routing/routes/app_routes.dart @@ -6,6 +6,7 @@ abstract final class AppRoutes { static const sell = 'sell'; static const sellBitbox = 'sellBitbox'; static const pay = 'pay'; + static const send = 'send'; static const kyc = 'kyc'; static const receive = 'receive'; diff --git a/lib/widgets/scanner/qr_scanner_view.dart b/lib/widgets/scanner/qr_scanner_view.dart new file mode 100644 index 00000000..9bf6020b --- /dev/null +++ b/lib/widgets/scanner/qr_scanner_view.dart @@ -0,0 +1,30 @@ +// @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. This widget is the shared camera-preview wrapper reused by the OCP +// pay flow (LNURL decode) and the wallet-to-wallet send flow (EVM address +// decode); the per-flow decode logic it feeds is unit-tested in the respective +// cubit tests. +import 'package:flutter/widgets.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +/// Thin wrapper around [MobileScanner] that surfaces the first raw barcode +/// value of each capture via [onDetect]. Keeping the camera/MethodChannel +/// wiring in one widget lets every flow reuse the scanner without duplicating +/// it — each flow decides what the scanned payload means in its own cubit. +class QrScannerView extends StatelessWidget { + /// Invoked with the raw string value of the first detected barcode in a + /// capture. Null raw values are filtered out before this is called. + final ValueChanged onDetect; + + const QrScannerView({super.key, required this.onDetect}); + + @override + Widget build(BuildContext context) { + return MobileScanner( + onDetect: (capture) { + final raw = capture.barcodes.firstOrNull?.rawValue; + if (raw != null) onDetect(raw); + }, + ); + } +} diff --git a/test/goldens/screens/send/goldens/macos/send_amount_page_empty.png b/test/goldens/screens/send/goldens/macos/send_amount_page_empty.png new file mode 100644 index 0000000000000000000000000000000000000000..caeb2d35bddb31450a6f551a422f25765e2fdd51 GIT binary patch literal 13451 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 literal 0 HcmV?d00001 diff --git a/test/goldens/screens/send/goldens/macos/send_confirm_page.png b/test/goldens/screens/send/goldens/macos/send_confirm_page.png new file mode 100644 index 0000000000000000000000000000000000000000..25a4df850d073c4f34374e12026b28ee888db464 GIT binary patch literal 24324 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 uw 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 literal 0 HcmV?d00001 diff --git a/test/goldens/screens/send/send_golden_test.dart b/test/goldens/screens/send/send_golden_test.dart new file mode 100644 index 00000000..71c63cbf --- /dev/null +++ b/test/goldens/screens/send/send_golden_test.dart @@ -0,0 +1,161 @@ +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/models/balance.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/screens/sell/cubits/sell_balance/sell_balance_cubit.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_amount/send_amount_cubit.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_recipient/send_recipient_cubit.dart'; +import 'package:realunit_wallet/screens/send/send_amount_page.dart'; +import 'package:realunit_wallet/screens/send/send_confirm_page.dart'; +import 'package:realunit_wallet/screens/send/send_process_page.dart'; +import 'package:realunit_wallet/screens/send/send_recipient_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockSendRecipientCubit extends MockCubit implements SendRecipientCubit {} + +class _MockSellBalanceCubit extends MockCubit implements SellBalanceCubit {} + +class _MockSendAmountCubit extends MockCubit implements SendAmountCubit {} + +class _MockSendProcessCubit extends MockCubit implements SendProcessCubit {} + +Balance _balance(int shares) => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: '0xwallet', + balance: BigInt.from(shares), + asset: realUnitAsset, +); + +void main() { + setUpAll(() { + registerFallbackValue(BigInt.zero); + stubMobileScannerChannel(); + }); + + group('$SendRecipientView', () { + late _MockSendRecipientCubit recipientCubit; + + setUp(() { + recipientCubit = _MockSendRecipientCubit(); + when(() => recipientCubit.state).thenReturn(const SendRecipientEmpty()); + }); + + goldenTest( + 'scan + manual-entry state', + fileName: 'send_recipient_page_empty', + constraints: phoneConstraints, + // The camera preview never reaches an isInitialized frame headlessly, so + // pumpAndSettle would await a settle that never comes. pumpOnce captures + // the deterministic placeholder frame. + pumpBeforeTest: pumpOnce, + builder: () => wrapForGolden( + BlocProvider.value( + value: recipientCubit, + child: const SendRecipientView(), + ), + ), + ); + }); + + group('$SendAmountView', () { + late _MockSellBalanceCubit balanceCubit; + late _MockSendAmountCubit amountCubit; + + setUp(() { + balanceCubit = _MockSellBalanceCubit(); + amountCubit = _MockSendAmountCubit(); + when(() => balanceCubit.state).thenReturn(_balance(42)); + when(() => amountCubit.availableShares).thenReturn(BigInt.from(42)); + when(() => amountCubit.availableSharesChanged(any())).thenReturn(null); + when(() => amountCubit.state).thenReturn(const SendAmountState()); + }); + + goldenTest( + 'amount entry with available balance', + fileName: 'send_amount_page_empty', + constraints: phoneConstraints, + builder: () => wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: balanceCubit), + BlocProvider.value(value: amountCubit), + ], + child: const SendAmountView(recipient: '0xRecipient'), + ), + ), + ); + + goldenTest( + 'over-balance amount shows the insufficient error', + fileName: 'send_amount_page_insufficient', + constraints: phoneConstraints, + builder: () { + when(() => amountCubit.state).thenReturn( + const SendAmountState( + text: '99', + amount: 99, + status: SendAmountStatus.insufficientBalance, + ), + ); + return wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: balanceCubit), + BlocProvider.value(value: amountCubit), + ], + child: const SendAmountView(recipient: '0xRecipient'), + ), + ); + }, + ); + }); + + group('$SendConfirmPage', () { + goldenTest( + 'transfer summary', + fileName: 'send_confirm_page', + constraints: phoneConstraints, + builder: () => wrapForGolden( + const SendConfirmPage( + recipient: '0x9F5713DEacB8e9CAB6c2d3FaE1AFc2715F8D2D71', + amount: 5, + ), + ), + ); + }); + + group('$SendProcessView', () { + late _MockSendProcessCubit processCubit; + + setUp(() { + processCubit = _MockSendProcessCubit(); + when(() => processCubit.state).thenReturn(const SendProcessInitial()); + }); + + // Terminal states (success/failure) are surfaced via a modal sheet 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. + goldenTest( + 'in-progress signing state', + fileName: 'send_process_page_signing', + constraints: phoneConstraints, + // The CupertinoActivityIndicator animates forever; pumpOnce captures the + // first frame. + pumpBeforeTest: pumpOnce, + builder: () { + when(() => processCubit.state).thenReturn(const SendProcessSigning()); + return wrapForGolden( + BlocProvider.value( + value: processCubit, + child: const SendProcessView(), + ), + ); + }, + ); + }); +} diff --git a/test/packages/service/dfx/exceptions/exception_surface_test.dart b/test/packages/service/dfx/exceptions/exception_surface_test.dart index 65625b44..2c6a5cc8 100644 --- a/test/packages/service/dfx/exceptions/exception_surface_test.dart +++ b/test/packages/service/dfx/exceptions/exception_surface_test.dart @@ -3,6 +3,7 @@ import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.da 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/service/dfx/exceptions/payment/transfer_exceptions.dart'; import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; // Guard against a recurring failure mode: an Exception subclass without a @@ -28,6 +29,9 @@ void main() { const InvalidPaymentLinkException('test'), const PayUnsupportedEnvironmentException(), const PaySignatureUnsupportedException(), + const InvalidRecipientAddressException('test'), + const TransferSignatureUnsupportedException(), + const TransferGasFundingUnavailableException(), ]; for (final ex in exceptions) { diff --git a/test/packages/service/dfx/models/payment/transfer/transfer_dtos_test.dart b/test/packages/service/dfx/models/payment/transfer/transfer_dtos_test.dart new file mode 100644 index 00000000..444f6bd3 --- /dev/null +++ b/test/packages/service/dfx/models/payment/transfer/transfer_dtos_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart'; + +Map _eip7702Json() => { + 'relayerAddress': '0xRelayer', + 'delegationManagerAddress': '0xManager', + 'delegatorAddress': '0xDelegator', + 'userNonce': 7, + 'domain': { + 'name': 'DelegationManager', + 'version': '1', + 'chainId': 11155111, + 'verifyingContract': '0xManager', + }, + 'types': { + 'Delegation': [ + {'name': 'delegate', 'type': 'address'}, + ], + 'Caveat': [ + {'name': 'enforcer', 'type': 'address'}, + ], + }, + 'message': { + 'delegate': '0xRelayer', + 'delegator': '0xSender', + 'authority': '0xRoot', + 'caveats': [], + 'salt': 3, + }, + 'tokenAddress': '0xRealu', + 'amountWei': '5', + 'recipient': '0xRecipient', +}; + +void main() { + group('RealUnitTransferDto', () { + test('toJson carries toAddress + amount', () { + const dto = RealUnitTransferDto(toAddress: '0xRecipient', amount: 5); + + expect(dto.toJson(), {'toAddress': '0xRecipient', 'amount': 5}); + }); + }); + + group('RealUnitTransferEip7702Data', () { + test('fromJson parses the recipient (transfer) shape', () { + final data = RealUnitTransferEip7702Data.fromJson(_eip7702Json()); + + expect(data.relayerAddress, '0xRelayer'); + expect(data.delegatorAddress, '0xDelegator'); + expect(data.userNonce, 7); + expect(data.domain.chainId, 11155111); + expect(data.types.delegation.first.name, 'delegate'); + expect(data.types.caveat.first.type, 'address'); + expect(data.message.delegator, '0xSender'); + expect(data.message.salt, 3); + expect(data.tokenAddress, '0xRealu'); + expect(data.amountWei, '5'); + expect(data.recipient, '0xRecipient'); + }); + + test('toEip7702Data maps recipient into the shared signer DTO depositAddress', () { + final data = RealUnitTransferEip7702Data.fromJson(_eip7702Json()); + + final shared = data.toEip7702Data(); + + // The recipient flows through depositAddress (the signers never read it), + // while every signed field is preserved verbatim. + expect(shared.depositAddress, '0xRecipient'); + expect(shared.relayerAddress, '0xRelayer'); + expect(shared.delegatorAddress, '0xDelegator'); + expect(shared.userNonce, 7); + expect(shared.domain.chainId, 11155111); + expect(shared.message.delegator, '0xSender'); + expect(shared.message.salt, 3); + expect(shared.tokenAddress, '0xRealu'); + expect(shared.amountWei, '5'); + }); + }); + + group('RealUnitTransferPaymentInfoDto', () { + test('fromJson parses the full prepare response', () { + final dto = RealUnitTransferPaymentInfoDto.fromJson({ + 'id': 99, + 'uid': 'RTabc', + 'toAddress': '0xRecipient', + 'amount': 5, + 'tokenAddress': '0xRealu', + 'chainId': 11155111, + 'eip7702': _eip7702Json(), + }); + + expect(dto.id, 99); + expect(dto.uid, 'RTabc'); + expect(dto.toAddress, '0xRecipient'); + expect(dto.amount, 5); + expect(dto.tokenAddress, '0xRealu'); + expect(dto.chainId, 11155111); + expect(dto.eip7702.recipient, '0xRecipient'); + expect(dto.eip7702.amountWei, '5'); + }); + + test('fromJson tolerates a numeric (double) amount from the API', () { + final dto = RealUnitTransferPaymentInfoDto.fromJson({ + 'id': 1, + 'uid': 'RTx', + 'toAddress': '0xRecipient', + 'amount': 5.0, + 'tokenAddress': '0xRealu', + 'chainId': 1, + 'eip7702': _eip7702Json(), + }); + + expect(dto.amount, 5); + }); + }); +} diff --git a/test/packages/service/dfx/real_unit_transfer_service_test.dart b/test/packages/service/dfx/real_unit_transfer_service_test.dart new file mode 100644 index 00000000..8c9975d1 --- /dev/null +++ b/test/packages/service/dfx/real_unit_transfer_service_test.dart @@ -0,0 +1,342 @@ +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/transfer_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_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 {} + +// Deterministic test private key — a real EthPrivateKey credential the +// EIP-712 / EIP-7702 signers accept directly (they reject anything that isn't +// BitboxCredentials or EthPrivateKey). +const _testPrivateKeyHex = 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612'; + +final _privKey = EthPrivateKey.fromHex(_testPrivateKeyHex); +final _walletAddress = _privKey.address.hexEip55; + +const _metaMaskDelegator = '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b'; +const _delegationManager = '0xdb9b1e94b5b69df7e401ddbede43491141047db3'; + +/// Debug-wallet style credential: it is neither BitboxCredentials nor +/// EthPrivateKey, so `Eip712Signer.signDelegation` hits its `_ => throw +/// UnsupportedError(...)` branch — exactly the debug-wallet capability gap. +class _UnsupportedCreds extends Fake implements CredentialsWithKnownAddress { + @override + EthereumAddress get address => _privKey.address; +} + +Map _eip7702Json({int chainId = 1}) => { + 'relayerAddress': '0xrelay', + 'delegationManagerAddress': _delegationManager, + 'delegatorAddress': _metaMaskDelegator, + 'userNonce': 7, + 'domain': { + 'name': 'RealUnit', + 'version': '1', + 'chainId': chainId, + 'verifyingContract': _delegationManager, + }, + 'types': { + 'Delegation': >[], + 'Caveat': >[], + }, + 'message': { + 'delegate': '0xrelay', + 'delegator': _walletAddress, + 'authority': '0xauth', + 'caveats': >[], + 'salt': 0, + }, + // The asset address the mainnet RealUnit token resolves to in this fixture. + 'tokenAddress': '0x553C7f9C780316FC1D34b8e14ac2465Ab22a090B', + 'amountWei': '5', + 'recipient': '0xRecipient', +}; + +RealUnitTransferPaymentInfoDto _info() => RealUnitTransferPaymentInfoDto.fromJson({ + 'id': 42, + 'uid': 'RTabc', + 'toAddress': '0xRecipient', + 'amount': 5, + 'tokenAddress': '0xRealu', + 'chainId': 1, + 'eip7702': _eip7702Json(), +}); + +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(_privKey); + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); + when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); + }); + + RealUnitTransferService build(http.Client client) { + when(() => appStore.httpClient).thenReturn(client); + return RealUnitTransferService(appStore, walletService); + } + + group('prepareTransfer', () { + test('200 → parses the payment-info DTO and PUTs toAddress + amount', () async { + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode({ + 'id': 42, + 'uid': 'RTabc', + 'toAddress': '0xRecipient', + 'amount': 5, + 'tokenAddress': '0xRealu', + 'chainId': 1, + 'eip7702': _eip7702Json(), + }), + 200, + ); + }); + + final info = await build(client).prepareTransfer( + const RealUnitTransferDto(toAddress: '0xRecipient', amount: 5), + ); + + expect(sentUri!.path, '/v1/realunit/transfer'); + expect(body, {'toAddress': '0xRecipient', 'amount': 5}); + expect(info.id, 42); + expect(info.eip7702.recipient, '0xRecipient'); + }); + + test('503 → TransferGasFundingUnavailableException', () async { + final client = MockClient( + (_) async => http.Response( + jsonEncode({'statusCode': 503, 'message': 'W2W gas funding temporarily unavailable'}), + 503, + ), + ); + + expect( + () => build(client).prepareTransfer( + const RealUnitTransferDto(toAddress: '0xRecipient', amount: 5), + ), + throwsA(isA()), + ); + }); + + test('400 (invalid recipient / insufficient REALU) → ApiException', () async { + final client = MockClient( + (_) async => http.Response( + jsonEncode({'statusCode': 400, 'code': 'X', 'message': 'Invalid recipient address'}), + 400, + ), + ); + + expect( + () => build(client).prepareTransfer( + const RealUnitTransferDto(toAddress: 'bad', amount: 5), + ), + throwsA(isA()), + ); + }); + }); + + group('confirmTransfer (software wallet happy path)', () { + test('signs delegation + authorization, PUTs the envelope, 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': '0xdeadbeef'}), 200); + }); + + final txHash = await build(client).confirmTransfer(_info()); + + expect(txHash, '0xdeadbeef'); + expect(sentUri!.path, '/v1/realunit/transfer/42/confirm'); + + final envelope = body!; + expect(envelope.containsKey('delegation'), isTrue); + expect(envelope.containsKey('authorization'), isTrue); + + final delegation = envelope['delegation'] as Map; + expect(delegation['delegate'], '0xrelay'); + expect(delegation['delegator'], _walletAddress); + expect(delegation['authority'], '0xauth'); + expect(delegation['salt'], '0'); + expect((delegation['signature'] as String).length, 132); + + final authorization = envelope['authorization'] as Map; + expect(authorization['chainId'], 1); + expect(authorization['address'], _metaMaskDelegator); + expect(authorization['nonce'], 7); + // r/s are always full 32-byte (64 hex char) big-endian values. + expect((authorization['r'] as String).substring(2).length, 64); + expect((authorization['s'] as String).substring(2).length, 64); + expect(authorization['yParity'], anyOf(0, 1)); + }); + + test('locks the wallet after signing (key never left resident)', () async { + final client = MockClient((_) async => http.Response(jsonEncode({'txHash': '0x1'}), 200)); + + await build(client).confirmTransfer(_info()); + + verify(() => walletService.ensureCurrentWalletUnlocked()).called(1); + verify(() => walletService.lockCurrentWallet()).called(1); + }); + + test('debug-wallet credentials → TransferSignatureUnsupportedException', () async { + when(() => account.primaryAddress).thenReturn(_UnsupportedCreds()); + final client = MockClient((_) async => http.Response('{}', 200)); + + expect( + () => build(client).confirmTransfer(_info()), + throwsA(isA()), + ); + }); + + test('503 on confirm → TransferGasFundingUnavailableException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'message': 'unavailable'}), 503), + ); + + expect( + () => build(client).confirmTransfer(_info()), + throwsA(isA()), + ); + }); + + test('4xx on confirm → ApiException', () async { + final client = MockClient( + (_) async => + http.Response(jsonEncode({'statusCode': 409, 'code': 'X', 'message': 'no'}), 409), + ); + + expect( + () => build(client).confirmTransfer(_info()), + throwsA(isA()), + ); + }); + + // Every pinned contract/field is rejected before signing, mirroring the + // sell software-confirm guard. Each case mutates one field of the otherwise + // valid eip7702 payload and asserts validation throws WITHOUT any PUT. + group('eip7702 validation pins (throw before any PUT)', () { + RealUnitTransferPaymentInfoDto infoWith(Map Function() mutate) => + RealUnitTransferPaymentInfoDto.fromJson({ + 'id': 42, + 'uid': 'RTabc', + 'toAddress': '0xRecipient', + 'amount': 5, + 'tokenAddress': '0xRealu', + 'chainId': 1, + 'eip7702': mutate(), + }); + + Future expectRejected(RealUnitTransferPaymentInfoDto info) async { + var called = false; + final client = MockClient((_) async { + called = true; + return http.Response('{}', 200); + }); + await expectLater(() => build(client).confirmTransfer(info), throwsException); + expect(called, isFalse, reason: 'no PUT must happen when validation rejects the payload'); + } + + test('wrong delegator (MetaMask delegator) contract', () async { + await expectRejected(infoWith(() => _eip7702Json()..['delegatorAddress'] = '0xWrong')); + }); + + test('wrong delegation manager contract', () async { + await expectRejected( + infoWith(() => _eip7702Json()..['delegationManagerAddress'] = '0xWrong'), + ); + }); + + test('verifying contract != delegation manager', () async { + await expectRejected( + infoWith(() { + final json = _eip7702Json(); + (json['domain'] as Map)['verifyingContract'] = '0xWrong'; + return json; + }), + ); + }); + + test('message delegator != wallet address', () async { + await expectRejected( + infoWith(() { + final json = _eip7702Json(); + (json['message'] as Map)['delegator'] = '0xSomeoneElse'; + return json; + }), + ); + }); + + test('chain id mismatch', () async { + await expectRejected(infoWith(() => _eip7702Json(chainId: 999))); + }); + + test('message delegate != relayer address', () async { + await expectRejected( + infoWith(() { + final json = _eip7702Json(); + (json['message'] as Map)['delegate'] = '0xOtherRelayer'; + return json; + }), + ); + }); + + test('token address != RealUnit token', () async { + await expectRejected(infoWith(() => _eip7702Json()..['tokenAddress'] = '0xNotRealu')); + }); + + test('amount-wei mismatch', () async { + await expectRejected(infoWith(() => _eip7702Json()..['amountWei'] = '6')); + }); + + test('unparseable amount-wei', () async { + await expectRejected(infoWith(() => _eip7702Json()..['amountWei'] = 'not-a-number')); + }); + }); + }); +} diff --git a/test/screens/send/cubits/send_amount_cubit_test.dart b/test/screens/send/cubits/send_amount_cubit_test.dart new file mode 100644 index 00000000..f9b52825 --- /dev/null +++ b/test/screens/send/cubits/send_amount_cubit_test.dart @@ -0,0 +1,87 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_amount/send_amount_cubit.dart'; + +void main() { + group('SendAmountCubit', () { + test('starts empty and not valid', () { + final cubit = SendAmountCubit(availableShares: BigInt.from(10)); + expect(cubit.state.status, SendAmountStatus.empty); + expect(cubit.state.isValid, isFalse); + }); + + blocTest( + 'empty text → empty status', + build: () => SendAmountCubit(availableShares: BigInt.from(10)), + act: (cubit) => cubit.amountChanged(' '), + verify: (cubit) => expect(cubit.state.status, SendAmountStatus.empty), + ); + + blocTest( + 'a whole number within balance is valid', + build: () => SendAmountCubit(availableShares: BigInt.from(10)), + act: (cubit) => cubit.amountChanged('5'), + verify: (cubit) { + expect(cubit.state.status, SendAmountStatus.valid); + expect(cubit.state.amount, 5); + expect(cubit.state.isValid, isTrue); + }, + ); + + blocTest( + 'zero is invalid', + build: () => SendAmountCubit(availableShares: BigInt.from(10)), + act: (cubit) => cubit.amountChanged('0'), + verify: (cubit) => expect(cubit.state.status, SendAmountStatus.invalid), + ); + + blocTest( + 'a non-integer is invalid', + build: () => SendAmountCubit(availableShares: BigInt.from(10)), + act: (cubit) => cubit.amountChanged('1.5'), + verify: (cubit) => expect(cubit.state.status, SendAmountStatus.invalid), + ); + + blocTest( + 'more than the available balance → insufficientBalance', + build: () => SendAmountCubit(availableShares: BigInt.from(10)), + act: (cubit) => cubit.amountChanged('11'), + verify: (cubit) { + expect(cubit.state.status, SendAmountStatus.insufficientBalance); + expect(cubit.state.isValid, isFalse); + }, + ); + + blocTest( + 'with unknown balance the over-balance guard is skipped (API validates)', + build: SendAmountCubit.new, + act: (cubit) => cubit.amountChanged('999999'), + verify: (cubit) => expect(cubit.state.status, SendAmountStatus.valid), + ); + + blocTest( + 'useMax fills the full available balance', + build: () => SendAmountCubit(availableShares: BigInt.from(42)), + act: (cubit) => cubit.useMax(), + verify: (cubit) { + expect(cubit.state.text, '42'); + expect(cubit.state.amount, 42); + expect(cubit.state.status, SendAmountStatus.valid); + }, + ); + + blocTest( + 'useMax is a no-op when the balance is zero', + build: () => SendAmountCubit(availableShares: BigInt.zero), + act: (cubit) => cubit.useMax(), + expect: () => [], + ); + + blocTest( + 'useMax is a no-op when the balance is unknown', + build: SendAmountCubit.new, + act: (cubit) => cubit.useMax(), + expect: () => [], + ); + }); +} diff --git a/test/screens/send/cubits/send_process_cubit_test.dart b/test/screens/send/cubits/send_process_cubit_test.dart new file mode 100644 index 00000000..142e85d4 --- /dev/null +++ b/test/screens/send/cubits/send_process_cubit_test.dart @@ -0,0 +1,262 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.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/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart'; + +class _MockTransferService extends Mock implements RealUnitTransferService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockWallet extends Mock implements AWallet {} + +RealUnitTransferPaymentInfoDto _info() => RealUnitTransferPaymentInfoDto.fromJson({ + 'id': 42, + 'uid': 'RTabc', + 'toAddress': '0xRecipient', + 'amount': 5, + 'tokenAddress': '0xRealu', + 'chainId': 1, + 'eip7702': { + 'relayerAddress': '0xrelay', + 'delegationManagerAddress': '0xmanager', + 'delegatorAddress': '0xdelegator', + 'userNonce': 0, + 'domain': {'name': 'd', 'version': '1', 'chainId': 1, 'verifyingContract': '0xmanager'}, + 'types': { + 'Delegation': >[], + 'Caveat': >[], + }, + 'message': { + 'delegate': '0xrelay', + 'delegator': '0xsender', + 'authority': '0xroot', + 'caveats': >[], + 'salt': 0, + }, + 'tokenAddress': '0xRealu', + 'amountWei': '5', + 'recipient': '0xRecipient', + }, +}); + +void main() { + late _MockTransferService service; + late _MockAppStore appStore; + late _MockWallet wallet; + + setUpAll(() { + registerFallbackValue(const RealUnitTransferDto(toAddress: '0x', amount: 1)); + registerFallbackValue(_info()); + }); + + setUp(() { + service = _MockTransferService(); + appStore = _MockAppStore(); + wallet = _MockWallet(); + when(() => appStore.wallet).thenReturn(wallet); + when(() => wallet.walletType).thenReturn(WalletType.software); + }); + + SendProcessCubit build() => SendProcessCubit( + transferService: service, + appStore: appStore, + recipient: '0xRecipient', + amount: 5, + ); + + void wireHappyPath() { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + when(() => service.confirmTransfer(any())).thenAnswer((_) async => '0xdeadbeef'); + } + + 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 SendProcessFailure; + expect(state.reason, SendProcessFailureReason.signatureUnsupported); + verifyNever(() => service.prepareTransfer(any())); + await cubit.close(); + }); + + test('bitbox wallet → signatureUnsupported before any network call', () async { + when(() => wallet.walletType).thenReturn(WalletType.bitbox); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.signatureUnsupported); + verifyNever(() => service.prepareTransfer(any())); + await cubit.close(); + }); + + test('happy path: prepare → confirm → success with txHash', () async { + wireHappyPath(); + RealUnitTransferDto? sentDto; + when(() => service.prepareTransfer(any())).thenAnswer((invocation) async { + sentDto = invocation.positionalArguments.first as RealUnitTransferDto; + return _info(); + }); + + final cubit = build(); + await cubit.start(); + + expect(sentDto!.toAddress, '0xRecipient'); + expect(sentDto!.amount, 5); + final state = cubit.state as SendProcessSuccess; + expect(state.txHash, '0xdeadbeef'); + await cubit.close(); + }); + + test('emits Preparing then Signing then Success', () async { + wireHappyPath(); + + final cubit = build(); + final emitted = []; + final sub = cubit.stream.listen(emitted.add); + await cubit.start(); + // Let the final Success microtask flush before cancelling the subscription. + await Future.delayed(Duration.zero); + await sub.cancel(); + + expect(emitted.map((s) => s.runtimeType).toList(), [ + SendProcessPreparing, + SendProcessSigning, + SendProcessSuccess, + ]); + await cubit.close(); + }); + + test('service-reported unsupported signature → signatureUnsupported', () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + when( + () => service.confirmTransfer(any()), + ).thenThrow(const TransferSignatureUnsupportedException()); + + final cubit = build(); + await cubit.start(); + + expect( + (cubit.state as SendProcessFailure).reason, + SendProcessFailureReason.signatureUnsupported, + ); + await cubit.close(); + }); + + test('gas funding unavailable exception → gasFundingUnavailable', () async { + when( + () => service.prepareTransfer(any()), + ).thenThrow(const TransferGasFundingUnavailableException()); + + final cubit = build(); + await cubit.start(); + + expect( + (cubit.state as SendProcessFailure).reason, + SendProcessFailureReason.gasFundingUnavailable, + ); + await cubit.close(); + }); + + test('signing cancelled → signatureCancelled', () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + when(() => service.confirmTransfer(any())).thenThrow(const SigningCancelledException()); + + final cubit = build(); + await cubit.start(); + + expect( + (cubit.state as SendProcessFailure).reason, + SendProcessFailureReason.signatureCancelled, + ); + await cubit.close(); + }); + + test('bitbox not connected (defensive) → signatureUnsupported', () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + when(() => service.confirmTransfer(any())).thenThrow(const BitboxNotConnectedException()); + + final cubit = build(); + await cubit.start(); + + expect( + (cubit.state as SendProcessFailure).reason, + SendProcessFailureReason.signatureUnsupported, + ); + await cubit.close(); + }); + + test('API 400 (invalid recipient / insufficient REALU) → invalidRequest', () async { + when(() => service.prepareTransfer(any())).thenThrow( + const ApiException(statusCode: 400, code: 'X', message: 'Invalid recipient address'), + ); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.invalidRequest); + expect(state.message, 'Invalid recipient address'); + await cubit.close(); + }); + + test('API 404 → invalidRequest', () async { + when( + () => service.prepareTransfer(any()), + ).thenThrow(const ApiException(statusCode: 404, code: 'X', message: 'not found')); + + final cubit = build(); + await cubit.start(); + + expect((cubit.state as SendProcessFailure).reason, SendProcessFailureReason.invalidRequest); + await cubit.close(); + }); + + test('API 503 → gasFundingUnavailable', () async { + when( + () => service.prepareTransfer(any()), + ).thenThrow(const ApiException(statusCode: 503, code: 'X', message: 'unavailable')); + + final cubit = build(); + await cubit.start(); + + expect( + (cubit.state as SendProcessFailure).reason, + SendProcessFailureReason.gasFundingUnavailable, + ); + await cubit.close(); + }); + + test('API 500 → generic', () async { + when( + () => service.prepareTransfer(any()), + ).thenThrow(const ApiException(statusCode: 500, code: 'X', message: 'boom')); + + final cubit = build(); + await cubit.start(); + + expect((cubit.state as SendProcessFailure).reason, SendProcessFailureReason.generic); + await cubit.close(); + }); + + test('unexpected error → generic', () async { + when(() => service.prepareTransfer(any())).thenThrow(Exception('weird')); + + final cubit = build(); + await cubit.start(); + + expect((cubit.state as SendProcessFailure).reason, SendProcessFailureReason.generic); + await cubit.close(); + }); +} diff --git a/test/screens/send/cubits/send_recipient_cubit_test.dart b/test/screens/send/cubits/send_recipient_cubit_test.dart new file mode 100644 index 00000000..e247595e --- /dev/null +++ b/test/screens/send/cubits/send_recipient_cubit_test.dart @@ -0,0 +1,73 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_recipient/send_recipient_cubit.dart'; + +void main() { + // A valid EVM address and its EIP-55 checksum form. + const lowercase = '0x9f5713deacb8e9cab6c2d3fae1afc2715f8d2d71'; + const checksummed = '0x9F5713DEacB8e9CAB6c2d3FaE1AFc2715F8D2D71'; + + group('SendRecipientCubit', () { + test('starts empty', () { + expect(SendRecipientCubit().state, isA()); + }); + + blocTest( + 'a valid address is normalized to its EIP-55 checksum', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit(lowercase), + verify: (cubit) { + final state = cubit.state as SendRecipientValid; + expect(state.address, checksummed); + }, + ); + + blocTest( + 'trims surrounding whitespace before validating', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit(' $checksummed '), + expect: () => [const SendRecipientValid(checksummed)], + ); + + blocTest( + 'strips an ethereum: EIP-681 scheme and @chainId / query suffix', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit('ethereum:$lowercase@1?value=0'), + expect: () => [const SendRecipientValid(checksummed)], + ); + + blocTest( + 'an invalid address emits SendRecipientInvalid', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit('not-an-address'), + expect: () => [isA()], + ); + + blocTest( + 'onCodeDetected decodes a scanned address like submit', + build: SendRecipientCubit.new, + act: (cubit) => cubit.onCodeDetected(checksummed), + expect: () => [const SendRecipientValid(checksummed)], + ); + + blocTest( + 'onCodeDetected ignores further detections once valid (no re-emit)', + build: SendRecipientCubit.new, + act: (cubit) { + cubit.onCodeDetected(checksummed); + cubit.onCodeDetected(checksummed); + }, + expect: () => [const SendRecipientValid(checksummed)], + ); + + blocTest( + 'reset returns to empty', + build: SendRecipientCubit.new, + act: (cubit) { + cubit.submit(checksummed); + cubit.reset(); + }, + expect: () => [const SendRecipientValid(checksummed), const SendRecipientEmpty()], + ); + }); +} diff --git a/test/screens/send/send_amount_page_test.dart b/test/screens/send/send_amount_page_test.dart new file mode 100644 index 00000000..360eeb3a --- /dev/null +++ b/test/screens/send/send_amount_page_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.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/models/balance.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/screens/send/send_amount_page.dart'; +import 'package:realunit_wallet/screens/send/send_confirm_page.dart'; + +import '../../helper/helper.dart'; + +class _MockBalanceRepository extends Mock implements BalanceRepository {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +void main() { + Balance balanceOf(BigInt value) => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: '0xwallet', + balance: value, + asset: realUnitAsset, + ); + + late _MockBalanceRepository balanceRepo; + + setUpAll(() { + registerFallbackValue( + Balance( + chainId: 1, + contractAddress: '0x', + walletAddress: '0x', + balance: BigInt.zero, + asset: realUnitAsset, + ), + ); + }); + + void registerGraph(BigInt available) { + final getIt = GetIt.instance; + balanceRepo = _MockBalanceRepository(); + when(() => balanceRepo.watchBalance(any())).thenAnswer( + (_) => Stream.value(balanceOf(available)), + ); + getIt.registerFactory(() => balanceRepo); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + when(() => appStore.apiConfig).thenReturn(apiConfig); + when(() => appStore.primaryAddress).thenReturn('0xwallet'); + getIt.registerSingleton(appStore); + } + + tearDown(() async => GetIt.instance.reset()); + + group('$SendAmountPage', () { + testWidgets('shows the available balance and gates the continue button', (tester) async { + registerGraph(BigInt.from(42)); + await tester.pumpApp(const SendAmountPage(recipient: '0xRecipient')); + await tester.pumpAndSettle(); + + expect(find.text(S.current.sendAmountAvailable('42')), findsOne); + + // Continue is disabled until a valid amount is entered. + final disabled = tester.widget(find.byType(FilledButton)); + expect(disabled.onPressed, isNull); + }); + + testWidgets('an over-balance amount surfaces the insufficient error', (tester) async { + registerGraph(BigInt.from(5)); + await tester.pumpApp(const SendAmountPage(recipient: '0xRecipient')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '6'); + await tester.pump(); + + expect(find.text(S.current.sendAmountInsufficient), findsOne); + final stillDisabled = tester.widget(find.byType(FilledButton)); + expect(stillDisabled.onPressed, isNull); + }); + + testWidgets('a valid amount enables continue and navigates to confirm', (tester) async { + registerGraph(BigInt.from(42)); + await tester.pumpApp(const SendAmountPage(recipient: '0xRecipient')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '5'); + await tester.pumpAndSettle(); + + // The continue button is now enabled. + final enabled = tester.widget(find.byType(FilledButton)); + expect(enabled.onPressed, isNotNull); + + await tester.tap(find.widgetWithText(FilledButton, S.current.next)); + await tester.pumpAndSettle(); + + expect(find.byType(SendConfirmPage), findsOne); + }); + + testWidgets('MAX fills the available balance', (tester) async { + registerGraph(BigInt.from(42)); + await tester.pumpApp(const SendAmountPage(recipient: '0xRecipient')); + await tester.pumpAndSettle(); + + await tester.tap(find.text(S.current.max.toUpperCase())); + await tester.pumpAndSettle(); + + // MAX fills the field with the available balance and the amount validates. + expect(find.text('42'), findsOneWidget); + final enabled = tester.widget(find.byType(FilledButton)); + expect(enabled.onPressed, isNotNull); + }); + }); +} diff --git a/test/screens/send/send_confirm_page_test.dart b/test/screens/send/send_confirm_page_test.dart new file mode 100644 index 00000000..3637df05 --- /dev/null +++ b/test/screens/send/send_confirm_page_test.dart @@ -0,0 +1,71 @@ +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/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/send/send_confirm_page.dart'; +import 'package:realunit_wallet/screens/send/send_process_page.dart'; + +import '../../helper/helper.dart'; + +class _MockTransferService extends Mock implements RealUnitTransferService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +class _MockWallet extends Mock implements SoftwareWallet {} + +void main() { + setUpAll(() { + // The confirm button pushes SendProcessPage, which builds a cubit off getIt + // and calls start(). A debug wallet makes start() settle immediately without + // any network call. + final getIt = GetIt.instance; + getIt.registerSingleton(_MockTransferService()); + 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()); + + group('$SendConfirmPage', () { + testWidgets('renders the recipient + amount summary', (tester) async { + await tester.pumpApp( + const SendConfirmPage( + recipient: '0x9F5713dEAcb8e9CaB6c2D3FaE1aFc2715F8D2D71', + amount: 5, + ), + ); + + expect(find.text(S.current.sendConfirmTitle), findsOne); + expect(find.text(S.current.sendConfirmSummary('5')), findsOne); + expect(find.text('0x9F5713dEAcb8e9CaB6c2D3FaE1aFc2715F8D2D71'), findsOne); + expect(find.text(S.current.sendShares('5')), findsOne); + }); + + testWidgets('confirming starts the process step', (tester) async { + await tester.pumpApp( + const SendConfirmPage(recipient: '0xRecipient', amount: 5), + ); + + await tester.tap(find.text(S.current.sendConfirmButton)); + // Let the page-route transition advance (the process page never settles — + // it keeps a CupertinoActivityIndicator animating — so pump fixed frames). + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byType(SendProcessView), findsOne); + }); + }); +} diff --git a/test/screens/send/send_process_page_test.dart b/test/screens/send/send_process_page_test.dart new file mode 100644 index 00000000..d18de666 --- /dev/null +++ b/test/screens/send/send_process_page_test.dart @@ -0,0 +1,183 @@ +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/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart'; +import 'package:realunit_wallet/screens/send/send_process_page.dart'; + +import '../../helper/helper.dart'; + +class _MockSendProcessCubit extends MockCubit implements SendProcessCubit {} + +class _MockTransferService extends Mock implements RealUnitTransferService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +class _MockWallet extends Mock implements SoftwareWallet {} + +void main() { + late _MockSendProcessCubit processCubit; + + setUpAll(() { + final getIt = GetIt.instance; + // SendProcessPage resolves the service + AppStore from getIt and calls + // start(). A debug wallet makes start() settle immediately + // (signatureUnsupported) without touching the network. + getIt.registerSingleton(_MockTransferService()); + 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 = _MockSendProcessCubit(); + when(() => processCubit.state).thenReturn(const SendProcessInitial()); + }); + + Widget buildSubject() => BlocProvider.value( + value: processCubit, + child: const SendProcessView(), + ); + + group('$SendProcessPage', () { + testWidgets('builds its own cubit and renders $SendProcessView', (tester) async { + await tester.pumpApp(const SendProcessPage(recipient: '0xRecipient', amount: 5)); + await tester.pump(); + + expect(find.byType(SendProcessView), findsOne); + }); + }); + + group('$SendProcessView progress labels', () { + Future expectLabel(WidgetTester tester, SendProcessState 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', (tester) async { + await expectLabel(tester, const SendProcessInitial(), S.current.sendPreparing); + }); + + testWidgets('preparing label', (tester) async { + await expectLabel(tester, const SendProcessPreparing(), S.current.sendPreparing); + }); + + testWidgets('signing label', (tester) async { + await expectLabel(tester, const SendProcessSigning(), S.current.sendSigning); + }); + + testWidgets('success label', (tester) async { + await expectLabel(tester, const SendProcessSuccess('0xtx'), S.current.sendSuccess); + }); + + testWidgets('failure label', (tester) async { + await expectLabel( + tester, + const SendProcessFailure(SendProcessFailureReason.generic), + S.current.sendFailureTitle, + ); + }); + }); + + // The result sheet is a modal bottom sheet shown from the listener. The view + // keeps a CupertinoActivityIndicator animating behind it, so pumpAndSettle + // never settles; pump fixed frames to open the sheet. + Future pumpWithState(WidgetTester tester, SendProcessState 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 SendProcessSigning(), + ); + await tester.pumpApp(buildSubject()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + } + + group('$SendProcessView result sheet', () { + testWidgets('success emits a success sheet with title + description', (tester) async { + await pumpWithState(tester, const SendProcessSuccess('0xtx')); + + expect(find.text(S.current.sendSuccessDescription), findsOne); + expect(find.byIcon(Icons.check_circle_rounded), findsOne); + expect(find.text(S.current.close), findsOne); + + 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('signature-unsupported failure message', (tester) async { + await pumpWithState( + tester, + const SendProcessFailure(SendProcessFailureReason.signatureUnsupported), + ); + + expect(find.text(S.current.sendFailureSignatureUnsupported), findsOne); + expect(find.byIcon(Icons.error_rounded), findsOne); + }); + + testWidgets('signature-cancelled failure message', (tester) async { + await pumpWithState( + tester, + const SendProcessFailure(SendProcessFailureReason.signatureCancelled), + ); + + expect(find.text(S.current.sendFailureSignatureCancelled), findsOne); + }); + + testWidgets('gas-unavailable failure message', (tester) async { + await pumpWithState( + tester, + const SendProcessFailure(SendProcessFailureReason.gasFundingUnavailable), + ); + + expect(find.text(S.current.sendFailureGasUnavailable), findsOne); + }); + + testWidgets('invalid-request failure message', (tester) async { + await pumpWithState( + tester, + const SendProcessFailure(SendProcessFailureReason.invalidRequest), + ); + + expect(find.text(S.current.sendFailureInvalidRequest), findsOne); + }); + + testWidgets('generic failure message', (tester) async { + await pumpWithState( + tester, + const SendProcessFailure(SendProcessFailureReason.generic), + ); + + expect(find.text(S.current.sendFailureGeneric), findsOne); + }); + }); +} diff --git a/test/screens/send/send_recipient_page_test.dart b/test/screens/send/send_recipient_page_test.dart new file mode 100644 index 00000000..4d793d44 --- /dev/null +++ b/test/screens/send/send_recipient_page_test.dart @@ -0,0 +1,181 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/models/balance.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_recipient/send_recipient_cubit.dart'; +import 'package:realunit_wallet/screens/send/send_amount_page.dart'; +import 'package:realunit_wallet/screens/send/send_recipient_page.dart'; + +import '../../helper/helper.dart'; + +class _MockSendRecipientCubit extends MockCubit implements SendRecipientCubit {} + +class _MockBalanceRepository extends Mock implements BalanceRepository {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +void main() { + late _MockSendRecipientCubit recipientCubit; + + Balance balanceOf(BigInt value) => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: '0xwallet', + balance: value, + asset: realUnitAsset, + ); + + setUpAll(() { + registerFallbackValue( + Balance( + chainId: 1, + contractAddress: '0x', + walletAddress: '0x', + balance: BigInt.zero, + asset: realUnitAsset, + ), + ); + stubMobileScannerChannel(); + + // A valid recipient pushes SendAmountPage, which builds a SellBalanceCubit + // off getIt; register a balance repository + app store so the pushed route + // resolves and renders. + final getIt = GetIt.instance; + final balanceRepo = _MockBalanceRepository(); + when(() => balanceRepo.watchBalance(any())).thenAnswer( + (_) => Stream.value(balanceOf(BigInt.from(100))), + ); + getIt.registerFactory(() => balanceRepo); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + when(() => appStore.apiConfig).thenReturn(apiConfig); + when(() => appStore.primaryAddress).thenReturn('0xwallet'); + getIt.registerSingleton(appStore); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + recipientCubit = _MockSendRecipientCubit(); + when(() => recipientCubit.state).thenReturn(const SendRecipientEmpty()); + when(() => recipientCubit.reset()).thenReturn(null); + when(() => recipientCubit.submit(any())).thenReturn(null); + when(() => recipientCubit.onCodeDetected(any())).thenReturn(null); + }); + + Widget buildSubject() => BlocProvider.value( + value: recipientCubit, + child: const SendRecipientView(), + ); + + group('$SendRecipientPage', () { + testWidgets('builds its own cubit and renders $SendRecipientView', (tester) async { + await tester.pumpApp(const SendRecipientPage()); + + expect(find.byType(SendRecipientView), findsOne); + }); + }); + + group('$SendRecipientView', () { + testWidgets('renders the title, scanner preview and the manual field', (tester) async { + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.sendRecipientTitle), findsOne); + expect(find.byType(MobileScanner), findsOne); + expect(find.text(S.current.sendRecipientManualHint), findsOne); + }); + + testWidgets('onDetect forwards a scanned raw value to the cubit', (tester) async { + await tester.pumpApp(buildSubject()); + + final scanner = tester.widget(find.byType(MobileScanner)); + scanner.onDetect!(const BarcodeCapture()); + scanner.onDetect!(const BarcodeCapture(barcodes: [Barcode(rawValue: '0xabc')])); + + verify(() => recipientCubit.onCodeDetected('0xabc')).called(1); + }); + + testWidgets('the continue button submits the typed address', (tester) async { + await tester.pumpApp(buildSubject()); + + await tester.enterText(find.byType(TextField), '0xRecipient'); + await tester.tap(find.text(S.current.next)); + await tester.pump(); + + verify(() => recipientCubit.submit('0xRecipient')).called(1); + }); + + testWidgets('the paste button fills the field from the clipboard', (tester) async { + // Stub the clipboard platform channel so getData returns a known address. + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.getData') { + return {'text': ' 0xPasted '}; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + + await tester.pumpApp(buildSubject()); + + await tester.tap(find.byIcon(Icons.paste_rounded)); + await tester.pump(); + + final field = tester.widget(find.byType(TextField)); + expect(field.controller!.text, '0xPasted'); + }); + + testWidgets('an invalid recipient shows a snackbar', (tester) async { + whenListen( + recipientCubit, + Stream.fromIterable([ + const SendRecipientInvalid(InvalidRecipientAddressException('bad')), + ]), + initialState: const SendRecipientEmpty(), + ); + + await tester.pumpApp(buildSubject()); + await tester.pump(); + + expect(find.byType(SnackBar), findsOne); + expect(find.text(S.current.sendRecipientInvalid), findsOne); + }); + + testWidgets('a valid recipient navigates to the amount step and resets', (tester) async { + whenListen( + recipientCubit, + Stream.fromIterable([ + const SendRecipientValid('0x9F5713dEAcb8e9CaB6c2D3FaE1aFc2715F8D2D71'), + ]), + initialState: const SendRecipientEmpty(), + ); + + await tester.pumpApp(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.byType(SendAmountView), findsOne); + verify(() => recipientCubit.reset()).called(1); + }); + }); +} From 39ad778ecbecafb9174e6c2c8911b1b1e3d8851c 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:50:08 +0200 Subject: [PATCH 5/8] test(goldens): regenerate baselines on dfx01 --- .../goldens/macos/dashboard_with_balance.png | Bin 27898 -> 28891 bytes .../home/goldens/macos/home_page_loaded.png | Bin 27435 -> 27456 bytes 2 files changed, 0 insertions(+), 0 deletions(-) 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 ff69acbf1a1c79b0461cc35602ee7deff946f718..61341c27bd58cf6616e24497510cce21a4d1edba 100644 GIT binary patch literal 28891 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 WfK8Vl_d(Q#KoAP*k4xm9zWg79ie#t& literal 27898 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

r5*#J$(?omb4Qwc{-a1BRx*nj*^l5I7+xco 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

OyO(p9 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 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 10:56:22 +0200 Subject: [PATCH 6/8] fix(send): order send page import alphabetically in router config --- lib/setup/routing/router_config.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/setup/routing/router_config.dart b/lib/setup/routing/router_config.dart index 54538ab1..d56aef7e 100644 --- a/lib/setup/routing/router_config.dart +++ b/lib/setup/routing/router_config.dart @@ -12,7 +12,6 @@ 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/send/send_recipient_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'; @@ -20,6 +19,7 @@ import 'package:realunit_wallet/screens/restore_wallet/restore_wallet_page.dart' import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/sell_payment_info.dart'; import 'package:realunit_wallet/screens/sell/sell_page.dart'; import 'package:realunit_wallet/screens/sell_bitbox/sell_bitbox_page.dart'; +import 'package:realunit_wallet/screens/send/send_recipient_page.dart'; import 'package:realunit_wallet/screens/settings/settings_page.dart'; import 'package:realunit_wallet/screens/settings_contact/settings_contact_page.dart'; import 'package:realunit_wallet/screens/settings_currencies/settings_currencies_page.dart'; From 7ec8c83202b7801e1a672aa26c92c3025a629b64 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:16:29 +0200 Subject: [PATCH 7/8] test(send): cover SendProcessState props via equality tests --- .../send_process/send_process_state_test.dart | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 test/screens/send/cubits/send_process/send_process_state_test.dart diff --git a/test/screens/send/cubits/send_process/send_process_state_test.dart b/test/screens/send/cubits/send_process/send_process_state_test.dart new file mode 100644 index 00000000..4282915e --- /dev/null +++ b/test/screens/send/cubits/send_process/send_process_state_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart'; + +/// Equatable-`props` surface tests for `SendProcessState`. +void main() { + group('SendProcessState equality (Equatable props)', () { + test('payload-less states expose empty props and compare by type', () { + // Reading `.props` directly evaluates the inherited base getter; const + // canonicalization would otherwise let `==` short-circuit via identical + // and skip the getter line in the coverage report. + expect(const SendProcessInitial().props, isEmpty); + expect(const SendProcessPreparing().props, isEmpty); + expect(const SendProcessSigning().props, isEmpty); + + // Same type compares equal (base `props => []`). + expect(const SendProcessInitial(), const SendProcessInitial()); + expect(const SendProcessPreparing(), const SendProcessPreparing()); + expect(const SendProcessSigning(), const SendProcessSigning()); + + // Different payload-less subclasses are unequal. + expect( + const SendProcessInitial(), + isNot(equals(const SendProcessPreparing())), + ); + expect( + const SendProcessPreparing(), + isNot(equals(const SendProcessSigning())), + ); + expect( + const SendProcessSigning(), + isNot(equals(const SendProcessInitial())), + ); + }); + + test('SendProcessSuccess is keyed on txHash', () { + expect(const SendProcessSuccess('h'), const SendProcessSuccess('h')); + expect( + const SendProcessSuccess('h'), + isNot(equals(const SendProcessSuccess('g'))), + ); + expect(const SendProcessSuccess('h').props, ['h']); + }); + + test('SendProcessFailure is keyed on reason + message', () { + // Equal when reason and message match. + expect( + const SendProcessFailure( + SendProcessFailureReason.invalidRequest, + message: 'm', + ), + const SendProcessFailure( + SendProcessFailureReason.invalidRequest, + message: 'm', + ), + ); + + // Unequal when the reason differs. + expect( + const SendProcessFailure( + SendProcessFailureReason.invalidRequest, + message: 'm', + ), + isNot( + equals( + const SendProcessFailure( + SendProcessFailureReason.generic, + message: 'm', + ), + ), + ), + ); + + // Unequal when the message differs. + expect( + const SendProcessFailure( + SendProcessFailureReason.invalidRequest, + message: 'm', + ), + isNot( + equals( + const SendProcessFailure( + SendProcessFailureReason.invalidRequest, + message: 'n', + ), + ), + ), + ); + + // The default (no `message`) variant is equal to itself and unequal to + // the same reason carrying a message — both evaluate `props`. + expect( + const SendProcessFailure(SendProcessFailureReason.signatureCancelled), + const SendProcessFailure(SendProcessFailureReason.signatureCancelled), + ); + expect( + const SendProcessFailure(SendProcessFailureReason.signatureCancelled), + isNot( + equals( + const SendProcessFailure( + SendProcessFailureReason.signatureCancelled, + message: 'm', + ), + ), + ), + ); + + expect( + const SendProcessFailure( + SendProcessFailureReason.invalidRequest, + message: 'm', + ).props, + [SendProcessFailureReason.invalidRequest, 'm'], + ); + expect( + const SendProcessFailure(SendProcessFailureReason.signatureCancelled).props, + [SendProcessFailureReason.signatureCancelled, null], + ); + }); + }); +} From e4925f9d3c7c5e341ec926d61155a1ad80fe4d60 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:04:10 +0200 Subject: [PATCH 8/8] test(send): cover changed lib lines to 100% Add widget/unit tests for the changed lib lines that the existing pay + send suites did not yet exercise: - DashboardActions: render + tap-routes the buy/sell/pay/send action buttons, covering the four Expanded(ActionButton) subtrees and their onPressed push closures (incl. the new send button). - setupServices: resolve the newly registered RealUnitPayService and RealUnitTransferService factories, covering their registration and construction closures in di.dart. - routerConfig /pay and /send routes: drive the real router to each route so the GoRoute builder closures returning PayScanPage / SendRecipientPage are executed. AppRoutes.pay and AppRoutes.send are compile-time const fields (no instrumentable line); they are exercised at runtime by the above tests. --- .../sections/dashboard_actions_test.dart | 123 ++++++++++++++++++ test/setup/di_pay_transfer_service_test.dart | 62 +++++++++ test/setup/routing/pay_send_routes_test.dart | 75 +++++++++++ 3 files changed, 260 insertions(+) create mode 100644 test/screens/dashboard/widgets/sections/dashboard_actions_test.dart create mode 100644 test/setup/di_pay_transfer_service_test.dart create mode 100644 test/setup/routing/pay_send_routes_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..0192a2e3 --- /dev/null +++ b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart @@ -0,0 +1,123 @@ +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 four 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'), + target(AppRoutes.send, '/send'), + ], + ); + } + + 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, pay and send 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); + expect(actionButtonByLabel(S.current.send), findsOneWidget); + // Each button is laid out inside an Expanded so the row divides the + // available width into four equal slots. + expect(find.byType(Expanded), findsNWidgets(4)); + }); + + 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); + expect(find.byIcon(Icons.send_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]); + }); + + testWidgets('send button pushes the send route', (tester) async { + await pumpActions(tester); + + await tester.tap(actionButtonByLabel(S.current.send)); + await tester.pumpAndSettle(); + + expect(pushedRoutes, [AppRoutes.send]); + }); + }); +} diff --git a/test/setup/di_pay_transfer_service_test.dart b/test/setup/di_pay_transfer_service_test.dart new file mode 100644 index 00000000..239f1b51 --- /dev/null +++ b/test/setup/di_pay_transfer_service_test.dart @@ -0,0 +1,62 @@ +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/packages/service/dfx/real_unit_transfer_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 and wallet-to-wallet flows wire their backend clients through + // `setupServices()` as factories: + // () => RealUnitPayService(getIt(), getIt()) + // () => RealUnitTransferService(getIt(), getIt()) + // This test exercises both registrations and then resolves each factory. + // + // `setupServices()` constructs `BalanceService` (eager singleton) up front, + // so `AppStore` + `BalanceRepository` must already be registered. Resolving + // either service then pulls the lazy `WalletService`, whose 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()); + expect(identical(service, getIt()), isFalse); + }); + + test('setupServices registers a resolvable RealUnitTransferService factory', () { + setupServices(); + + expect(getIt.isRegistered(), isTrue); + + final service = getIt(); + expect(service, isA()); + expect(identical(service, getIt()), isFalse); + }); +} diff --git a/test/setup/routing/pay_send_routes_test.dart b/test/setup/routing/pay_send_routes_test.dart new file mode 100644 index 00000000..27667f56 --- /dev/null +++ b/test/setup/routing/pay_send_routes_test.dart @@ -0,0 +1,75 @@ +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/screens/send/send_recipient_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(() { + // Both routed pages embed a QrScannerView (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 and /send GoRoutes 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(); + } + + // Restore the global router singleton to its initial location after each + // test so the /pay or /send location does not leak into any later test. + tearDown(() => routerConfig.goNamed(AppRoutes.home)); + + testWidgets('the pay route builds PayScanPage', (tester) async { + await pumpRouter(tester); + + routerConfig.goNamed(AppRoutes.pay); + await tester.pumpAndSettle(); + + expect(find.byType(PayScanPage), findsOneWidget); + }); + + testWidgets('the send route builds SendRecipientPage', (tester) async { + await pumpRouter(tester); + + routerConfig.goNamed(AppRoutes.send); + await tester.pumpAndSettle(); + + expect(find.byType(SendRecipientPage), findsOneWidget); + }); +}