diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index e3f21595..7784bbea 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,38 @@ "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.", + "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", "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", + "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", + "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 +225,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 +274,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 +310,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 +357,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 +384,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..d6152ae7 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,38 @@ "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.", + "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", "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", + "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", + "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 +225,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 +274,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 +310,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 +357,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 +384,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..13dd475a --- /dev/null +++ b/lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart @@ -0,0 +1,27 @@ +// 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 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..4f923a57 --- /dev/null +++ b/lib/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart @@ -0,0 +1,102 @@ +/// 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. +/// +/// 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; + + /// 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, + }); + + 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), + 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; + + /// 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: amount?.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..e16c9578 --- /dev/null +++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart @@ -0,0 +1,21 @@ +/// 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; + + // 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() => { + 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..535d4f47 --- /dev/null +++ b/lib/packages/service/dfx/real_unit_pay_service.dart @@ -0,0 +1,152 @@ +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/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'; +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'; + +/// 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 { + 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 { + 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, + ); + } + + 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..d1d59e81 --- /dev/null +++ b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart @@ -0,0 +1,342 @@ +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/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'; +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; + + /// 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; + + /// 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); + + 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 { + // Capability gate — checked BEFORE any on-chain action: the debug wallet + // cannot produce EIP-1559 signatures, so the irreversible REALU→ZCHF swap + // must never run on it. The backend settles OCP on every environment + // (Sepolia off-PRD, mainnet+L2 on PRD), so there is no environment gate + // here — the flow requests the real quote and surfaces a typed backend + // error if one ever comes back. + if (_appStore.wallet.walletType == WalletType.debug) { + emit(const PayProcessFailure(PayProcessFailureReason.signatureUnsupported)); + return; + } + 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); + // 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)); + } on BitboxNotConnectedException { + emit(const PayProcessFailure(PayProcessFailureReason.bitboxRequired)); + } catch (e) { + emit(PayProcessFailure(PayProcessFailureReason.generic, message: e.toString())); + } + } + + /// 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. 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()); + details = await _payService.getPaymentDetails(_paymentLinkId); + } catch (e) { + // 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 { + 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(); + } catch (e) { + // 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() { + _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 { + // 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 + } + }); + } + + /// 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..fe836311 --- /dev/null +++ b/lib/screens/pay/cubits/pay_process/pay_process_state.dart @@ -0,0 +1,113 @@ +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 active wallet mode cannot sign transactions (debug wallet). + signatureUnsupported, + + /// A BitBox is required but not connected. + bitboxRequired, + + /// Any other unexpected error. + 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(); + + @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(); +} + +/// 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; + + /// 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..fb75ef48 --- /dev/null +++ b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart @@ -0,0 +1,61 @@ +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..ace5bdc4 --- /dev/null +++ b/lib/screens/pay/pay_process_page.dart @@ -0,0 +1,212 @@ +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( + listenWhen: (previous, current) => + current is PayProcessSuccess || + current is PayProcessFailure || + current is PayProcessPayRetry, + 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 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, + 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, + 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.signatureUnsupported => S.of(context).payFailureSignatureUnsupported, + PayProcessFailureReason.bitboxRequired => S.of(context).payFailureBitboxRequired, + 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, + 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(); + } + + /// 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 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 02631d18..ff69acbf 100644 Binary files a/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png and b/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png differ 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 84de9124..309a5508 100644 Binary files a/test/goldens/screens/home/goldens/macos/home_page_loaded.png and b/test/goldens/screens/home/goldens/macos/home_page_loaded.png differ 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 00000000..f180456f Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_process_page_awaiting_settlement.png differ 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 00000000..9c782538 Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_process_page_pay_retry.png differ diff --git a/test/goldens/screens/pay/goldens/macos/pay_process_page_swapping.png b/test/goldens/screens/pay/goldens/macos/pay_process_page_swapping.png new file mode 100644 index 00000000..18c71c3a Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_process_page_swapping.png differ diff --git a/test/goldens/screens/pay/goldens/macos/pay_quote_page_expired.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_expired.png new file mode 100644 index 00000000..0ce937c0 Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_quote_page_expired.png differ 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 00000000..66fa82dc Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_quote_page_loading.png differ diff --git a/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png new file mode 100644 index 00000000..d58c5b28 Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png differ diff --git a/test/goldens/screens/pay/goldens/macos/pay_scan_page_scanning.png b/test/goldens/screens/pay/goldens/macos/pay_scan_page_scanning.png new file mode 100644 index 00000000..5f249d68 Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_scan_page_scanning.png differ 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..8fce767b --- /dev/null +++ b/test/goldens/screens/pay/pay_quote_golden_test.dart @@ -0,0 +1,80 @@ +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(), + ), + ), + ); + + // Real Sepolia OCP capture (DFXswiss/api #3819): a CHF 2.00 payment link + // whose Ethereum method settles 2.0 ZCHF. The screen renders the amounts + // straight from the quote — the app never computes them. + 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_realunit_ocp_sepolia', + quoteId: 'plq_realunit_ocp_sepolia', + fiatAsset: 'CHF', + fiatAmount: 2, + zchfAmount: 2.0, + ), + ); + 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/packages/service/dfx/exceptions/exception_surface_test.dart b/test/packages/service/dfx/exceptions/exception_surface_test.dart index d187e5a6..48c7994d 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,8 @@ void main() { requiredLevel: 1, currentLevel: 0, ), + const InvalidPaymentLinkException('test'), + 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..eab27d41 --- /dev/null +++ b/test/packages/service/dfx/models/payment/pay/pay_dtos_test.dart @@ -0,0 +1,305 @@ +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('fromTargetAmount serialises only targetAmount (no amount key)', () { + 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({ + '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.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); + }); + + 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 new file mode 100644 index 00000000..2717993f --- /dev/null +++ b/test/packages/service/dfx/real_unit_pay_service_test.dart @@ -0,0 +1,341 @@ +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/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.fromTargetAmount(95.5)), + 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', () { + // Values from the real Sepolia OCP testnet run (DFXswiss/api #3819): + // a CHF 2.00 link settling 2.0 ZCHF (2000000000000000000 wei) to the DFX + // deposit recipient on chain 11155111. + test('PUTs /pay/unsigned-transaction with the payment refs', () async { + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode({ + 'unsignedTx': + '0x02f87483aa36a782019b8502284a84ae8502284a84ae830186a094d3117681ca462268048f57d106d312ba0b1215ea80b844a9059cbb000000000000000000000000fb2a9731cda8b3fca015723ef40f310c1e48366b0000000000000000000000000000000000000000000000001bc16d674ec80000c0', + 'tokenAddress': '0xD3117681cA462268048f57D106d312Ba0b1215eA', + 'recipient': '0xfB2a9731cdA8b3FCa015723EF40f310C1E48366b', + 'amountWei': '2000000000000000000', + 'chainId': 11155111, + }), + 200, + ); + }); + + final dto = await build(client).createPayUnsignedTransaction( + const RealUnitOcpPayDto(paymentLinkId: 'pl_realunit_ocp_sepolia', quoteId: 'q1'), + ); + + expect(sentUri!.path, '/v1/realunit/pay/unsigned-transaction'); + expect(body!['paymentLinkId'], 'pl_realunit_ocp_sepolia'); + expect(body!['quoteId'], 'q1'); + expect(dto.recipient, '0xfB2a9731cdA8b3FCa015723EF40f310C1E48366b'); + expect(dto.tokenAddress, '0xD3117681cA462268048f57D106d312Ba0b1215eA'); + expect(dto.amountWei, '2000000000000000000'); + expect(dto.chainId, 11155111); + }); + + test('non-200 → 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('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/dashboard/widgets/sections/dashboard_actions_test.dart b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart new file mode 100644 index 00000000..43eb3975 --- /dev/null +++ b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/dashboard/widgets/sections/dashboard_actions.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; +import 'package:realunit_wallet/widgets/action_button.dart'; + +void main() { + late List pushedRoutes; + + setUp(() { + pushedRoutes = []; + }); + + // Routes the three action buttons can push. Each target records the + // pushed route name so the `onPressed` closures are both executed and + // asserted, instead of only painted. + GoRouter buildRouter() { + GoRoute target(String name, String path) => GoRoute( + name: name, + path: path, + builder: (_, _) { + pushedRoutes.add(name); + return Scaffold(body: Text('ROUTE:$name')); + }, + ); + + return GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const Scaffold(body: DashboardActions()), + ), + target(AppRoutes.buy, '/buy'), + target(AppRoutes.sell, '/sell'), + target(AppRoutes.pay, '/pay'), + ], + ); + } + + Future pumpActions(WidgetTester tester) async { + final router = buildRouter(); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ); + await tester.pumpAndSettle(); + } + + Finder actionButtonByLabel(String label) => find.byWidgetPredicate( + (w) => w is ActionButton && w.label == label, + ); + + group('$DashboardActions', () { + testWidgets('renders the buy, sell and pay action buttons', (tester) async { + await pumpActions(tester); + + expect(actionButtonByLabel(S.current.buy), findsOneWidget); + expect(actionButtonByLabel(S.current.sell), findsOneWidget); + expect(actionButtonByLabel(S.current.pay), findsOneWidget); + // Each button is laid out inside an Expanded so the row divides + // the available width into three equal slots. + expect(find.byType(Expanded), findsNWidgets(3)); + }); + + testWidgets('renders the expected icons for each action', (tester) async { + await pumpActions(tester); + + expect(find.byIcon(Icons.add_circle_rounded), findsOneWidget); + expect(find.byIcon(Icons.do_not_disturb_on_rounded), findsOneWidget); + expect(find.byIcon(Icons.qr_code_scanner_rounded), findsOneWidget); + }); + + testWidgets('buy button pushes the buy route', (tester) async { + await pumpActions(tester); + + await tester.tap(actionButtonByLabel(S.current.buy)); + await tester.pumpAndSettle(); + + expect(pushedRoutes, [AppRoutes.buy]); + }); + + testWidgets('sell button pushes the sell route', (tester) async { + await pumpActions(tester); + + await tester.tap(actionButtonByLabel(S.current.sell)); + await tester.pumpAndSettle(); + + expect(pushedRoutes, [AppRoutes.sell]); + }); + + testWidgets('pay button pushes the pay route', (tester) async { + await pumpActions(tester); + + await tester.tap(actionButtonByLabel(S.current.pay)); + await tester.pumpAndSettle(); + + expect(pushedRoutes, [AppRoutes.pay]); + }); + }); +} diff --git a/test/screens/pay/pay_process_cubit_test.dart b/test/screens/pay/pay_process_cubit_test.dart new file mode 100644 index 00000000..ad632b97 --- /dev/null +++ b/test/screens/pay/pay_process_cubit_test.dart @@ -0,0 +1,633 @@ +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/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', + double zchf = 42.7, +}) { + return LnurlpPaymentDto( + requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 42.5), + quote: LnurlpQuoteDto(id: quoteId, expiration: expiration), + transferAmounts: [ + LnurlpTransferAmountDto( + method: 'Ethereum', + assets: [LnurlpTransferAssetDto(asset: 'ZCHF', amount: zchf)], + ), + ], + ); +} + +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.fromTargetAmount(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.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(); + }); + + 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 → 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 retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); + await cubit.start(); + final state = await retry as PayProcessPayRetry; + + // 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 after swap → retry (transient), not terminal', () async { + wireHappyPath(); + when(() => payService.submitPay(any())).thenThrow(Exception('settlement rejected')); + + final cubit = build(); + final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); + await cubit.start(); + final state = await retry as PayProcessPayRetry; + + // 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) → pay-only retry', () 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); + // 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(); + }); + }); + + 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('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 retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); + await cubit.start(); + final state = await retry as PayProcessPayRetry; + + // 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('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. 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(), + ); + when(() => account.primaryAddress).thenReturn(creds); + + final cubit = build(); + final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); + await cubit.start(); + final state = await retry as PayProcessPayRetry; + + expect(creds.calls, 2); + 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('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). Post-swap, this is a retryable pay-leg failure. + final creds = _CountingSignCreds( + throwOnCall: 2, + error: UnsupportedError('cannot sign'), + ); + when(() => account.primaryAddress).thenReturn(creds); + + final cubit = build(); + final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); + await cubit.start(); + final state = await retry as PayProcessPayRetry; + + expect(creds.calls, 2); + expect(state.reason, PayRetryReason.transient); + 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_page_test.dart b/test/screens/pay/pay_process_page_test.dart new file mode 100644 index 00000000..bff92a33 --- /dev/null +++ b/test/screens/pay/pay_process_page_test.dart @@ -0,0 +1,268 @@ +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(); + 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('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_process_state_test.dart b/test/screens/pay/pay_process_state_test.dart new file mode 100644 index 00000000..354720b6 --- /dev/null +++ b/test/screens/pay/pay_process_state_test.dart @@ -0,0 +1,64 @@ +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.generic), + const PayProcessFailure(PayProcessFailureReason.generic), + ); + expect( + 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 new file mode 100644 index 00000000..91d8b34b --- /dev/null +++ b/test/screens/pay/pay_quote_cubit_test.dart @@ -0,0 +1,103 @@ +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 {} + +// Real Sepolia OCP capture (DFXswiss/api #3819): a CHF 2.00 payment link whose +// Ethereum method settles 2.0 ZCHF. The cubit reads these amounts verbatim from +// the public lnurlp quote — it never computes them. +LnurlpPaymentDto _details({ + required DateTime expiration, + bool withEthZchf = true, + double zchf = 2.0, +}) { + return LnurlpPaymentDto( + requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 2), + quote: LnurlpQuoteDto(id: 'plq_realunit_ocp_sepolia', expiration: expiration), + 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_realunit_ocp_sepolia'); + + blocTest( + 'a fresh quote with an Ethereum/ZCHF method emits PayQuoteReady', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).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, 'plq_realunit_ocp_sepolia'); + expect(state.fiatAsset, 'CHF'); + expect(state.fiatAmount, 2); + expect(state.zchfAmount, 2.0); + }, + ); + + blocTest( + 'an expired quote emits PayQuoteExpired', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).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_realunit_ocp_sepolia')).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_realunit_ocp_sepolia')).thenThrow( + const ApiException(code: 'X', message: 'boom'), + ); + }, + act: (cubit) => cubit.load(), + expect: () => [isA(), isA()], + ); +} 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..6e1952bc --- /dev/null +++ b/test/screens/pay/pay_quote_page_test.dart @@ -0,0 +1,153 @@ +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/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +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; + + // Real Sepolia OCP capture (DFXswiss/api #3819): CHF 2.00 → 2.0 ZCHF on the + // Ethereum method. + const ready = PayQuoteReady( + paymentLinkId: 'pl_realunit_ocp_sepolia', + quoteId: 'plq_realunit_ocp_sepolia', + fiatAsset: 'CHF', + fiatAmount: 2, + zchfAmount: 2.0, + ); + + setUpAll(() { + final getIt = GetIt.instance; + + // PayQuotePage resolves the pay service from getIt and calls load(); the + // load throws a typed error here so the pushed route builds deterministically + // (rendering PayQuoteError) without a live backend. + final payService = _MockPayService(); + when(() => payService.getPaymentDetails(any())).thenThrow( + const ApiException(code: 'TEST', message: 'no backend in widget test'), + ); + getIt.registerSingleton(payService); + + // The confirm button pushes PayProcessPage, which resolves a full service + // 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('2.00', 'CHF')), findsOne); + expect(find.text('2.00 CHF'), findsOne); + expect(find.text('2.00 ZCHF'), findsOne); + expect(find.text(S.current.payConfirmButton), findsOne); + }); + + 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('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_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()], + ); + }); +} 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..b8c486d4 --- /dev/null +++ b/test/screens/pay/pay_scan_page_test.dart @@ -0,0 +1,122 @@ +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/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/lnurl_decoder.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart'; +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 whose quote + // fetch throws so the pushed route builds deterministically (rendering the + // PayQuoteError message) without touching a live backend. + final payService = _MockPayService(); + when(() => payService.getPaymentDetails(any())).thenThrow( + const ApiException(code: 'TEST', message: 'no backend in widget test'), + ); + 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); + }); + }); +} diff --git a/test/setup/di_pay_service_test.dart b/test/setup/di_pay_service_test.dart new file mode 100644 index 00000000..f8d88e2a --- /dev/null +++ b/test/setup/di_pay_service_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/repository/settings_repository.dart'; +import 'package:realunit_wallet/packages/repository/wallet_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/setup/di.dart'; + +class _MockAppStore extends Mock implements AppStore {} + +class _MockBalanceRepository extends Mock implements BalanceRepository {} + +class _MockSettingsRepository extends Mock implements SettingsRepository {} + +class _MockWalletRepository extends Mock implements WalletRepository {} + +void main() { + // The pay flow's backend client is wired through `setupServices()` as a + // factory: `() => RealUnitPayService(getIt(), getIt())`. + // This test exercises that exact registration and then resolves the factory. + // + // `setupServices()` constructs `BalanceService` (eager singleton) up front, + // so `AppStore` + `BalanceRepository` must already be registered. Resolving + // `RealUnitPayService` then pulls the lazy `WalletService`, whose own + // dependencies bottom out at `WalletRepository` + `SettingsRepository` + + // `AppStore` (`BitboxService` is registered by `setupServices()` itself). + // Registering those leaf mocks keeps the whole chain construct-only — none + // of the mocked collaborators perform I/O on construction. + setUp(() { + getIt.reset(); + getIt.registerSingleton(_MockAppStore()); + getIt.registerSingleton(_MockBalanceRepository()); + getIt.registerSingleton(_MockSettingsRepository()); + getIt.registerSingleton(_MockWalletRepository()); + }); + + tearDown(() => getIt.reset()); + + test('setupServices registers a resolvable RealUnitPayService factory', () { + setupServices(); + + expect(getIt.isRegistered(), isTrue); + + final service = getIt(); + expect(service, isA()); + + // registerFactory hands back a fresh instance on every resolution. + expect(identical(service, getIt()), isFalse); + }); +} diff --git a/test/setup/routing/pay_route_test.dart b/test/setup/routing/pay_route_test.dart new file mode 100644 index 00000000..4cdb9a70 --- /dev/null +++ b/test/setup/routing/pay_route_test.dart @@ -0,0 +1,65 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; +import 'package:realunit_wallet/setup/routing/router_config.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; + +import '../../helper/helper.dart'; + +class _MockHomeBloc extends MockBloc implements HomeBloc {} + +void main() { + late _MockHomeBloc homeBloc; + + setUpAll(() { + // PayScanPage embeds a MobileScanner; the stub keeps the headless camera + // preview deterministic and free of MissingPluginException. + stubMobileScannerChannel(); + }); + + setUp(() { + homeBloc = _MockHomeBloc(); + when(() => homeBloc.state).thenReturn(const HomeState()); + }); + + // Mirrors the production wiring in main.dart: the routed pages read their + // blocs from above MaterialApp.router, so HomeBloc (used by the initial + // /home route) is provided here. Navigation then drives the real + // `routerConfig` to the /pay GoRoute under test. + Future pumpRouter(WidgetTester tester) async { + await tester.pumpWidget( + BlocProvider.value( + value: homeBloc, + child: MaterialApp.router( + routerConfig: routerConfig, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('the pay route builds PayScanPage', (tester) async { + await pumpRouter(tester); + + routerConfig.goNamed(AppRoutes.pay); + await tester.pumpAndSettle(); + + expect(find.byType(PayScanPage), findsOneWidget); + expect(find.byType(PayScanView), findsOneWidget); + + // Restore the router to its initial location so the global singleton + // does not leak the /pay location into any later test. + addTearDown(() => routerConfig.goNamed(AppRoutes.home)); + }); +}