diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index e3f215950..8304bbb3d 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,39 @@ "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", + "payFailureUnsupportedEnvironment": "Open CryptoPay ist nur im Mainnet verfügbar.", "paymentInformationFailed": "Beim Abrufen der Zahlungsinformationen ist ein Fehler aufgetreten.", "paymentInformationFailedDescription": "Bitte versuchen Sie es später erneut. Wenn der Fehler weiterhin besteht, wenden Sie sich an unseren Support.", "payoutAccountAdd": "Auszahlungskonto hinzufügen", "payoutAccountSelect": "Auszahlungskonto auswählen", + "payPaying": "Zahlung wird gesendet", + "payPreparingSwap": "Tausch wird vorbereitet", + "payQuoteRequested": "Geforderter Betrag", + "payQuoteSummary": "Sie bezahlen ${amount} ${asset}", + "payQuoteTitle": "Zahlung bestätigen", + "payQuoteUnavailable": "Für diesen Zahlungscode ist keine ZCHF-Zahlung verfügbar.", + "payQuoteZchfNeeded": "Benötigte ZCHF", + "payRefreshingQuote": "Angebot wird aktualisiert", + "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 +226,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 +275,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}", @@ -265,7 +294,35 @@ "sellReviewAndConfirm": "Verkauf prüfen & bestätigen", "sellSuccess": "Verkauf erfolgreich", "sellSuccessDescription": "Der Betrag wird Ihnen auf das angegebene Bankkonto ausgezahlt.", + "send": "Senden", + "sendAmountAvailable": "Verfügbar: ${shares} REALU", + "sendAmountInsufficient": "Nicht genügend REALU in Ihrer Wallet.", + "sendAmountInvalid": "Geben Sie eine ganze Anzahl REALU-Anteile ein.", + "sendAmountLabel": "Betrag in REALU-Anteilen", + "sendAmountTitle": "Zu sendender Betrag", + "sendConfirmAmount": "Betrag", + "sendConfirmButton": "REALU senden", + "sendConfirmRecipient": "Empfänger", + "sendConfirmSummary": "Sie senden ${shares} REALU", + "sendConfirmTitle": "Überweisung prüfen", + "sendFailureGasUnavailable": "Überweisungen sind vorübergehend nicht verfügbar. Ihre REALU bleiben unangetastet – bitte versuchen Sie es später erneut.", + "sendFailureGeneric": "Bei der Überweisung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "sendFailureInvalidRequest": "Die Überweisung konnte nicht vorbereitet werden. Bitte prüfen Sie Empfänger und Betrag und versuchen Sie es erneut.", + "sendFailureSignatureCancelled": "Die Signatur wurde abgebrochen. Ihre REALU bleiben unangetastet.", + "sendFailureSignatureUnsupported": "Diese Wallet kann keine gaslosen Überweisungen signieren. Wechseln Sie zu einer Software-Wallet, um REALU zu senden.", + "sendFailureTitle": "Überweisung fehlgeschlagen", "sending": "Wird gesendet", + "sendPaste": "Einfügen", + "sendPreparing": "Überweisung wird vorbereitet", + "sendProcessTitle": "REALU wird gesendet", + "sendRecipientInvalid": "Dies ist keine gültige Wallet-Adresse.", + "sendRecipientLabel": "Empfängeradresse", + "sendRecipientManualHint": "Scannen Sie einen Wallet-QR-Code oder fügen Sie die Empfängeradresse unten ein.", + "sendRecipientTitle": "Empfänger scannen", + "sendShares": "${shares} REALU", + "sendSigning": "Bestätigen Sie die Überweisung in Ihrer Wallet", + "sendSuccess": "Überweisung gesendet", + "sendSuccessDescription": "Ihre REALU sind auf dem Weg zum Empfänger.", "setNationalityFailed": "Ihre Staatsangehörigkeit konnte nicht gesetzt werden:\n${message}", "settings": "Einstellungen", "settingsAppVersion": "Version ${tag}", @@ -282,10 +339,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 +386,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 +413,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 56e4db7f0..21c09c401 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,39 @@ "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", + "payFailureUnsupportedEnvironment": "Open CryptoPay is only available on mainnet.", "paymentInformationFailed": "An error occurred while getting the payment information.", "paymentInformationFailedDescription": "Please try again later. If the error persists, contact our support team.", "payoutAccountAdd": "Add payout account", "payoutAccountSelect": "Select payout account", + "payPaying": "Sending payment", + "payPreparingSwap": "Preparing swap", + "payQuoteRequested": "Requested amount", + "payQuoteSummary": "You pay ${amount} ${asset}", + "payQuoteTitle": "Confirm payment", + "payQuoteUnavailable": "No ZCHF payment is available for this payment code.", + "payQuoteZchfNeeded": "ZCHF needed", + "payRefreshingQuote": "Refreshing quote", + "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 +226,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 +275,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}", @@ -265,7 +294,35 @@ "sellReviewAndConfirm": "Review & confirm sale", "sellSuccess": "Sell successful", "sellSuccessDescription": "The amount will be paid into the bank account you have specified.", + "send": "Send", + "sendAmountAvailable": "Available: ${shares} REALU", + "sendAmountInsufficient": "Not enough REALU in your wallet.", + "sendAmountInvalid": "Enter a whole number of REALU shares.", + "sendAmountLabel": "Amount in REALU shares", + "sendAmountTitle": "Amount to send", + "sendConfirmAmount": "Amount", + "sendConfirmButton": "Send REALU", + "sendConfirmRecipient": "Recipient", + "sendConfirmSummary": "You send ${shares} REALU", + "sendConfirmTitle": "Review transfer", + "sendFailureGasUnavailable": "Transfers are temporarily unavailable. Your REALU is untouched — please try again later.", + "sendFailureGeneric": "Something went wrong with the transfer. Please try again.", + "sendFailureInvalidRequest": "The transfer could not be prepared. Please check the recipient and amount and try again.", + "sendFailureSignatureCancelled": "The signature was cancelled. Your REALU is untouched.", + "sendFailureSignatureUnsupported": "This wallet cannot sign gasless transfers. Switch to a software wallet to send REALU.", + "sendFailureTitle": "Transfer failed", "sending": "Sending", + "sendPaste": "Paste", + "sendPreparing": "Preparing transfer", + "sendProcessTitle": "Sending REALU", + "sendRecipientInvalid": "This is not a valid wallet address.", + "sendRecipientLabel": "Recipient address", + "sendRecipientManualHint": "Scan a wallet QR code, or paste the recipient address below.", + "sendRecipientTitle": "Scan recipient", + "sendShares": "${shares} REALU", + "sendSigning": "Confirm the transfer in your wallet", + "sendSuccess": "Transfer sent", + "sendSuccessDescription": "Your REALU is on its way to the recipient.", "setNationalityFailed": "Could not set your nationality:\n${message}", "settings": "Settings", "settingsAppVersion": "Version ${tag}", @@ -282,10 +339,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 +386,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 +413,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 421d96889..60b32b0c3 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 000000000..4872f4926 --- /dev/null +++ b/lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart @@ -0,0 +1,39 @@ +// Typed failures for the OCP pay flow (scan → swap → pay). Each one renders a +// human-readable string (see `exception_surface_test.dart`) so it can surface +// cleanly in logs, Sentry, and user-facing error states instead of the Dart +// default `Instance of '...'`. + +/// The scanned QR / pasted code is not a DFX Open CryptoPay payment link. +class InvalidPaymentLinkException implements Exception { + final String reason; + + const InvalidPaymentLinkException(this.reason); + + @override + String toString() => 'InvalidPaymentLinkException: $reason'; +} + +/// The Open CryptoPay settlement is not available on the current backend +/// environment. The payment-link engine is mainnet-only, so `pay/submit` and +/// `pay/unsigned-transaction` fail fast on dev.api.dfx.swiss (Sepolia). +class PayUnsupportedEnvironmentException implements Exception { + const PayUnsupportedEnvironmentException(); + + @override + String toString() => + 'PayUnsupportedEnvironmentException: Open CryptoPay settlement is only ' + 'available on mainnet'; +} + +/// The loaded wallet cannot produce EIP-1559 signatures (today: the debug +/// wallet). The pay flow needs to sign the swap and pay transactions locally, +/// so it cannot proceed in this wallet mode. +class PaySignatureUnsupportedException implements Exception { + // Only ever thrown / constructed as a const expression, so the zero-arg + // body never registers a runtime line hit; toString() below is exercised. + const PaySignatureUnsupportedException(); // coverage:ignore-line + + @override + String toString() => + 'PaySignatureUnsupportedException: this wallet mode cannot sign transactions'; +} diff --git a/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart b/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart new file mode 100644 index 000000000..362409d0c --- /dev/null +++ b/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart @@ -0,0 +1,51 @@ +// Typed failures for the wallet-to-wallet (W2W) RealUnit transfer flow +// (enter/scan recipient → amount → confirm → sign → transfer → confirm). Each +// one renders a human-readable string (enumerated in `exception_surface_test.dart`) +// so it surfaces cleanly in logs and user-facing error states instead of the +// Dart default `Instance of '...'`. Typed failures drive control flow — no +// error-string parsing. + +/// The recipient string the user scanned or pasted is not a syntactically valid +/// EVM address. This is a client-side UX guard only; the API remains the final +/// authority on the address. +class InvalidRecipientAddressException implements Exception { + /// The rejected raw input, for diagnostics. + final String input; + + const InvalidRecipientAddressException(this.input); + + @override + String toString() => 'InvalidRecipientAddressException: $input is not a valid wallet address'; +} + +/// The active wallet mode cannot produce the EIP-712 delegation + EIP-7702 +/// authorization the gasless transfer requires (today: the debug wallet, and +/// hardware wallets whose firmware exposes no raw EIP-7702 signing API). The +/// flow is not branched on wallet type beyond this capability gate. +class TransferSignatureUnsupportedException implements Exception { + /// Diagnostic detail (e.g. the underlying signer message). + final String detail; + + const TransferSignatureUnsupportedException([ + this.detail = 'this wallet mode cannot sign gasless transfers', + ]); + + @override + String toString() => 'TransferSignatureUnsupportedException: $detail'; +} + +/// DFX cannot currently fund gas for the gasless transfer (the backend's +/// dedicated W2W gas wallet is unconfigured or below its low-balance +/// threshold). Surfaced from the API's `ServiceUnavailable` (503) as a friendly +/// "temporarily unavailable" state — the user's REALU is untouched. +class TransferGasFundingUnavailableException implements Exception { + /// Diagnostic detail (e.g. the API message), for logs. + final String detail; + + const TransferGasFundingUnavailableException([ + this.detail = 'gas funding for transfers is temporarily unavailable', + ]); + + @override + String toString() => 'TransferGasFundingUnavailableException: $detail'; +} diff --git a/lib/packages/service/dfx/lnurl_decoder.dart b/lib/packages/service/dfx/lnurl_decoder.dart new file mode 100644 index 000000000..6c7781104 --- /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 000000000..4f923a57e --- /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 000000000..7393a5628 --- /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 000000000..1a90a2b46 --- /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 000000000..b03f4e0ab --- /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 000000000..acd5740bb --- /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 000000000..e887938fb --- /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 000000000..e16c95782 --- /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 000000000..54dee50d9 --- /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 000000000..5e0fe5b69 --- /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 000000000..b1704adc5 --- /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/models/payment/transfer/dto/real_unit_transfer_dto.dart b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart new file mode 100644 index 000000000..29cb4af4a --- /dev/null +++ b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart @@ -0,0 +1,19 @@ +/// Request body for `PUT /v1/realunit/transfer` — the wallet-to-wallet +/// transfer intent. REALU has `decimals = 0`, so [amount] is a whole number of +/// shares. +class RealUnitTransferDto { + final String toAddress; + final int amount; + + const RealUnitTransferDto({ + required this.toAddress, + required this.amount, + }); + + Map toJson() { + return { + 'toAddress': toAddress, + 'amount': amount, + }; + } +} diff --git a/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart new file mode 100644 index 000000000..8c4f659ca --- /dev/null +++ b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart @@ -0,0 +1,67 @@ +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart'; + +/// EIP-7702 delegation data for a gasless wallet-to-wallet REALU transfer. +/// +/// Mirrors the sell flow's [Eip7702Data] (`domain`/`types`/`message` are the +/// exact same shape the EIP-712 delegation + EIP-7702 authorization signers +/// consume), but the transfer endpoint returns the on-chain destination as +/// `recipient` rather than the sell flow's `depositAddress`. +class RealUnitTransferEip7702Data { + final String relayerAddress; + final String delegationManagerAddress; + final String delegatorAddress; + final int userNonce; + final Eip7702Domain domain; + final Eip7702Types types; + final Eip7702Message message; + final String tokenAddress; + final String amountWei; + final String recipient; + + const RealUnitTransferEip7702Data({ + required this.relayerAddress, + required this.delegationManagerAddress, + required this.delegatorAddress, + required this.userNonce, + required this.domain, + required this.types, + required this.message, + required this.tokenAddress, + required this.amountWei, + required this.recipient, + }); + + factory RealUnitTransferEip7702Data.fromJson(Map json) { + return RealUnitTransferEip7702Data( + relayerAddress: json['relayerAddress'] as String, + delegationManagerAddress: json['delegationManagerAddress'] as String, + delegatorAddress: json['delegatorAddress'] as String, + userNonce: json['userNonce'] as int, + domain: Eip7702Domain.fromJson(json['domain'] as Map), + types: Eip7702Types.fromJson(json['types'] as Map), + message: Eip7702Message.fromJson(json['message'] as Map), + tokenAddress: json['tokenAddress'] as String, + amountWei: json['amountWei'] as String, + recipient: json['recipient'] as String, + ); + } + + /// Adapts to the sell flow's [Eip7702Data] so the shared + /// `Eip712Signer.signDelegation` / `Eip7702Signer.signAuthorization` can sign + /// it without a transfer-specific signer. The signers never read + /// `depositAddress`, so the `recipient` is mapped through it verbatim. + Eip7702Data toEip7702Data() { + return Eip7702Data( + relayerAddress: relayerAddress, + delegationManagerAddress: delegationManagerAddress, + delegatorAddress: delegatorAddress, + userNonce: userNonce, + domain: domain, + types: types, + message: message, + tokenAddress: tokenAddress, + amountWei: amountWei, + depositAddress: recipient, + ); + } +} diff --git a/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart new file mode 100644 index 000000000..8c7658e89 --- /dev/null +++ b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart @@ -0,0 +1,38 @@ +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart'; + +/// Response of `PUT /v1/realunit/transfer` — the persisted transfer intent plus +/// the EIP-7702 delegation data the app must sign for the gasless REALU +/// transfer. +class RealUnitTransferPaymentInfoDto { + final int id; + final String uid; + final String toAddress; + final int amount; + final String tokenAddress; + final int chainId; + final RealUnitTransferEip7702Data eip7702; + + const RealUnitTransferPaymentInfoDto({ + required this.id, + required this.uid, + required this.toAddress, + required this.amount, + required this.tokenAddress, + required this.chainId, + required this.eip7702, + }); + + factory RealUnitTransferPaymentInfoDto.fromJson(Map json) { + return RealUnitTransferPaymentInfoDto( + id: json['id'] as int, + uid: json['uid'] as String, + toAddress: json['toAddress'] as String, + amount: (json['amount'] as num).toInt(), + tokenAddress: json['tokenAddress'] as String, + chainId: json['chainId'] as int, + eip7702: RealUnitTransferEip7702Data.fromJson( + json['eip7702'] as Map, + ), + ); + } +} diff --git a/lib/packages/service/dfx/real_unit_pay_service.dart b/lib/packages/service/dfx/real_unit_pay_service.dart new file mode 100644 index 000000000..6b83e874d --- /dev/null +++ b/lib/packages/service/dfx/real_unit_pay_service.dart @@ -0,0 +1,172 @@ +import 'dart:convert'; + +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/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 { + assertPaySupported(); + final uri = buildUri(host, _payUnsignedTxPath); + final response = await authenticatedPut( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(dto.toJson()), + ); + + if (response.statusCode != 200 && response.statusCode != 201) { + _throwApi(response.body, response.statusCode); + } + return RealUnitOcpPayUnsignedTransactionDto.fromJson( + jsonDecode(response.body) as Map, + ); + } + + Future submitPay(RealUnitOcpPaySubmitDto dto) async { + assertPaySupported(); + final uri = buildUri(host, _paySubmitPath); + final response = await authenticatedPut( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(dto.toJson()), + ); + + if (response.statusCode != 200 && response.statusCode != 201) { + _throwApi(response.body, response.statusCode); + } + return RealUnitOcpPayResultDto.fromJson( + jsonDecode(response.body) as Map, + ).txId; + } + + Future getPayStatus(String id) async { + final uri = buildUri(host, _payStatusPath(id)); + final response = await authenticatedGet(uri); + + if (response.statusCode != 200) { + _throwApi(response.body, response.statusCode); + } + return RealUnitOcpPayStatusDto.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// Whether the current backend environment can settle an OCP payment. The + /// payment-link engine is mainnet-only; on testnet the pay/* endpoints fail + /// fast server-side with a 400. This is environment-static (keyed off + /// [ApiConfig]), so the flow can read it BEFORE the irreversible REALU→ZCHF + /// swap and refuse to swap on an environment where the pay leg can never + /// settle. Not local business logic — purely a capability gate. + bool get isPaySupportedEnvironment => !appStore.apiConfig.networkMode.isTestnet; + + /// Defense-in-depth mirror of [isPaySupportedEnvironment] on the pay/* calls: + /// even though the flow gates up-front, surface the typed failure before the + /// round-trip rather than parsing the backend error body. + void assertPaySupported() { + if (!isPaySupportedEnvironment) { + throw const PayUnsupportedEnvironmentException(); + } + } + + Never _throwApi(String body, int statusCode) { + final errorJson = jsonDecode(body) as Map; + throw ApiException.fromJson(errorJson, httpStatusCode: statusCode); + } +} diff --git a/lib/packages/service/dfx/real_unit_transfer_service.dart b/lib/packages/service/dfx/real_unit_transfer_service.dart new file mode 100644 index 000000000..50df07268 --- /dev/null +++ b/lib/packages/service/dfx/real_unit_transfer_service.dart @@ -0,0 +1,194 @@ +import 'dart:convert'; + +import 'package:eip7702/eip7702.dart' as eip7702; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_confirm_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/wallet/eip712_signer.dart'; +import 'package:realunit_wallet/packages/wallet/eip7702_signer.dart'; + +/// Consumes the gasless wallet-to-wallet RealUnit transfer endpoints +/// (`PUT /v1/realunit/transfer`, `PUT /v1/realunit/transfer/:id/confirm` — +/// DFXswiss/api #3820). DFX pays gas via EIP-7702 from a dedicated W2W gas +/// wallet, so the app signs an EIP-712 delegation + an EIP-7702 authorization — +/// the exact pattern the SOFTWARE gasless sell confirm uses +/// ([RealUnitSellPaymentInfoService.confirmPayment]). +class RealUnitTransferService extends DFXAuthService { + static const _transferPath = '/v1/realunit/transfer'; + static String _confirmPath(int id) => '/v1/realunit/transfer/$id/confirm'; + + // MetaMask Delegation Framework v1.3.0, CREATE2 — identical on all EVM chains. + // Pinned exactly as the sell software-confirm path pins them, so a tampered + // delegation payload is rejected before it is signed. + static const _metaMaskDelegatorAddress = '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b'; + static const _delegationManagerAddress = '0xdb9b1e94b5b69df7e401ddbede43491141047db3'; + + RealUnitTransferService(super.appStore, super.walletService); + + /// `PUT /transfer` — persists the transfer intent and returns the EIP-7702 + /// delegation data to sign. A 503 means DFX cannot currently fund gas; it is + /// surfaced as a typed [TransferGasFundingUnavailableException] so the flow can + /// render a friendly "temporarily unavailable" state. Every other non-2xx is + /// an [ApiException] (KYC30 / registration / invalid recipient are signaled by + /// the API and rendered from its message). + Future prepareTransfer(RealUnitTransferDto dto) async { + final uri = buildUri(host, _transferPath); + final response = await authenticatedPut( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(dto.toJson()), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return RealUnitTransferPaymentInfoDto.fromJson( + jsonDecode(response.body) as Map, + ); + } + + final errorJson = jsonDecode(response.body) as Map; + if (response.statusCode == 503) { + throw TransferGasFundingUnavailableException( + (errorJson['message'] ?? 'gas funding for transfers is temporarily unavailable').toString(), + ); + } + throw ApiException.fromJson(errorJson, httpStatusCode: response.statusCode); + } + + /// `PUT /transfer/:id/confirm` — signs the EIP-712 delegation + EIP-7702 + /// authorization and relays them; DFX broadcasts the gasless transfer and + /// returns the tx hash. + /// + /// Reuses the wallet unlock/lock boundary and the shared + /// `Eip712Signer.signDelegation` / `Eip7702Signer.signAuthorization` exactly + /// like the sell software-confirm. A wallet that cannot produce these + /// signatures (debug wallet, or hardware firmware without raw EIP-7702 + /// support) raises [TransferSignatureUnsupportedException] — the capability + /// gate, not a wallet-type branch. + Future confirmTransfer(RealUnitTransferPaymentInfoDto info) async { + // EIP-712 + EIP-7702 typed-data signing needs the private key; promote the + // view-wallet to a fully unlocked SoftwareWallet before reading credentials. + await walletService.ensureCurrentWalletUnlocked(); + try { + final credentials = appStore.wallet.currentAccount.primaryAddress; + final transferData = info.eip7702; + _validateEip7702Data(transferData, credentials.address.hexEip55, info.amount); + + final eip7702Data = transferData.toEip7702Data(); + final String delegationSignature; + final eip7702.EIP7702MsgSignature authorizationSignature; + try { + delegationSignature = await Eip712Signer.signDelegation( + credentials: credentials, + eip7702Data: eip7702Data, + ); + authorizationSignature = Eip7702Signer.signAuthorization( + credentials: credentials, + eip7702Data: eip7702Data, + ); + } on UnsupportedError catch (e) { + // Debug-wallet credentials reject typed-data signing with UnsupportedError. + throw TransferSignatureUnsupportedException(e.message ?? e.toString()); + } + + return _sendConfirm( + info.id, + Eip7702ConfirmDto( + delegation: Eip7702DelegationDto( + delegate: transferData.relayerAddress, + delegator: transferData.message.delegator, + authority: transferData.message.authority, + salt: '${transferData.message.salt}', + signature: delegationSignature, + ), + authorization: Eip7702AuthorizationDto( + chainId: transferData.domain.chainId, + address: transferData.delegatorAddress, + nonce: transferData.userNonce, + r: '0x${authorizationSignature.r.toRadixString(16).padLeft(64, '0')}', + s: '0x${authorizationSignature.s.toRadixString(16).padLeft(64, '0')}', + yParity: authorizationSignature.yParity, + ), + ), + ); + } finally { + // Drop the mnemonic from memory as soon as signing is done — runs on the + // throw path too so a validation/sign failure mid-sequence does not leave + // the key resident. Mirrors [RealUnitSellPaymentInfoService.confirmPayment]. + await walletService.lockCurrentWallet(); + } + } + + Future _sendConfirm(int id, Eip7702ConfirmDto dto) async { + final uri = buildUri(host, _confirmPath(id)); + final response = await authenticatedPut( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(dto.toJson()), + ); + + if (response.statusCode != 200 && response.statusCode != 201) { + final errorJson = jsonDecode(response.body) as Map; + if (response.statusCode == 503) { + throw TransferGasFundingUnavailableException( + (errorJson['message'] ?? 'gas funding for transfers is temporarily unavailable') + .toString(), + ); + } + throw ApiException.fromJson(errorJson, httpStatusCode: response.statusCode); + } + + return (jsonDecode(response.body) as Map)['txHash'] as String; + } + + /// Pins the signed contract addresses + cross-checks the signed/unsigned + /// fields against known values before signing, mirroring the sell flow. The + /// recipient is server-bound (the backend supplies the ERC20 transfer call at + /// execute time), so it is validated for amount/token/chain consistency here. + void _validateEip7702Data( + RealUnitTransferEip7702Data data, + String walletAddress, + int userAmount, + ) { + final expectedChainId = appStore.apiConfig.asset.chainId; + + if (data.delegatorAddress.toLowerCase() != _metaMaskDelegatorAddress) { + throw Exception( + 'EIP-7702 delegator address does not match expected MetaMask Delegator contract', + ); + } + if (data.delegationManagerAddress.toLowerCase() != _delegationManagerAddress) { + throw Exception('EIP-7702 delegation manager address does not match expected contract'); + } + if (data.domain.verifyingContract.toLowerCase() != _delegationManagerAddress) { + throw Exception('EIP-7702 verifying contract does not match expected DelegationManager'); + } + if (data.message.delegator.toLowerCase() != walletAddress.toLowerCase()) { + throw Exception('EIP-7702 message delegator does not match wallet address'); + } + if (data.domain.chainId != expectedChainId) { + throw Exception( + 'EIP-7702 chain ID mismatch: expected $expectedChainId, got ${data.domain.chainId}', + ); + } + if (data.message.delegate.toLowerCase() != data.relayerAddress.toLowerCase()) { + throw Exception('EIP-7702 message delegate does not match relayer address'); + } + if (data.tokenAddress.toLowerCase() != appStore.apiConfig.asset.address.toLowerCase()) { + throw Exception('EIP-7702 token address does not match RealUnit token'); + } + // REALU has decimals = 0, so the wei amount equals the share count; compute + // generically against the asset decimals so a non-zero-decimals asset would + // still be validated correctly. + final expectedWei = + BigInt.from(userAmount) * BigInt.from(10).pow(appStore.apiConfig.asset.decimals); + final actualWei = BigInt.tryParse(data.amountWei); + if (actualWei == null || actualWei != expectedWei) { + throw Exception('EIP-7702 amount mismatch: expected $expectedWei, got ${data.amountWei}'); + } + } +} diff --git a/lib/screens/dashboard/widgets/sections/dashboard_actions.dart b/lib/screens/dashboard/widgets/sections/dashboard_actions.dart index 9b68eed66..0c64a6a60 100644 --- a/lib/screens/dashboard/widgets/sections/dashboard_actions.dart +++ b/lib/screens/dashboard/widgets/sections/dashboard_actions.dart @@ -13,23 +13,49 @@ 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), + ), + ), + Expanded( + child: ActionButton( + icon: Icon( + Icons.send_rounded, + color: RealUnitColors.basic.white, + size: 20, + ), + label: S.of(context).send, + onPressed: () => context.pushNamed(AppRoutes.send), ), - 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 000000000..9870e468c --- /dev/null +++ b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart @@ -0,0 +1,345 @@ +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 { + // Environment capability gate — checked BEFORE any on-chain action. The + // REALU→ZCHF swap is irreversible; if OCP settlement can never succeed on + // this environment (mainnet-only), refuse here so the user is never swapped + // into ZCHF and then told "mainnet only". This is environment-static, so it + // is safe (and required) to evaluate before the swap is signed/broadcast. + if (!_payService.isPaySupportedEnvironment) { + emit(const PayProcessFailure(PayProcessFailureReason.payUnsupportedEnvironment)); + return; + } + if (_appStore.wallet.walletType == WalletType.debug) { + emit(const PayProcessFailure(PayProcessFailureReason.signatureUnsupported)); + return; + } + 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 000000000..f7894c83a --- /dev/null +++ b/lib/screens/pay/cubits/pay_process/pay_process_state.dart @@ -0,0 +1,118 @@ +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, + + /// Open CryptoPay settlement is unavailable on the current backend + /// environment (mainnet-only; checked BEFORE the swap so it never strands the + /// user in ZCHF). + payUnsupportedEnvironment, + + /// The active wallet mode cannot sign transactions (debug wallet). + signatureUnsupported, + + /// 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 000000000..6180400c2 --- /dev/null +++ b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart @@ -0,0 +1,70 @@ +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()); + + // Gate the irreversible flow up-front: if OCP settlement can never succeed + // on this environment, surface it now — before the user can confirm a quote + // and trigger the REALU→ZCHF swap. The swap must never run where the pay + // leg cannot settle. + if (!_payService.isPaySupportedEnvironment) { + emit(const PayQuoteUnsupportedEnvironment()); + return; + } + + try { + final details = await _payService.getPaymentDetails(_paymentLinkId); + + 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 000000000..9125644e5 --- /dev/null +++ b/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart @@ -0,0 +1,56 @@ +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(); +} + +/// OCP settlement is unavailable on the current backend environment +/// (mainnet-only). Surfaced before the swap so it can never run on testnet. +class PayQuoteUnsupportedEnvironment extends PayQuoteState { + const PayQuoteUnsupportedEnvironment(); +} + +class PayQuoteError extends PayQuoteState { + final String message; + + 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 000000000..d40c3cf68 --- /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 000000000..f3552e5f4 --- /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 000000000..0d7f9fb9c --- /dev/null +++ b/lib/screens/pay/pay_process_page.dart @@ -0,0 +1,214 @@ +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.payUnsupportedEnvironment => + S.of(context).payFailureUnsupportedEnvironment, + 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 000000000..7708b29ad --- /dev/null +++ b/lib/screens/pay/pay_quote_page.dart @@ -0,0 +1,140 @@ +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), + PayQuoteUnsupportedEnvironment() => _PayQuoteMessage( + message: S.of(context).payFailureUnsupportedEnvironment, + ), + 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 000000000..7cc13dd58 --- /dev/null +++ b/lib/screens/pay/pay_scan_page.dart @@ -0,0 +1,62 @@ +// @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:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_quote_page.dart'; +import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/scanner/qr_scanner_view.dart'; + +class PayScanPage extends StatelessWidget { + const PayScanPage({super.key}); + + @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: QrScannerView( + onDetect: (raw) => context.read().onCodeDetected(raw), + ), + ); + }, + ); + } +} diff --git a/lib/screens/send/cubits/send_amount/send_amount_cubit.dart b/lib/screens/send/cubits/send_amount/send_amount_cubit.dart new file mode 100644 index 000000000..7a373374e --- /dev/null +++ b/lib/screens/send/cubits/send_amount/send_amount_cubit.dart @@ -0,0 +1,60 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +part 'send_amount_state.dart'; + +/// Validates the REALU amount to send. REALU has `decimals = 0`, so the amount +/// is a whole number of shares. The available balance (also whole shares) is +/// tracked here so the field can be validated against it locally for UX; the +/// API remains the authority and re-checks the balance on `PUT /transfer`. +class SendAmountCubit extends Cubit { + /// Available REALU shares in the wallet, or null when the balance is unknown + /// (the available-balance hint and the over-balance guard are then skipped — + /// the API still validates). Mutable because the balance arrives over a + /// stream and may update after the cubit is built. + BigInt? availableShares; + + SendAmountCubit({this.availableShares}) : super(const SendAmountState()); + + /// Updates the tracked balance when a fresh value arrives from the balance + /// stream, then re-evaluates the current input against it so an amount that + /// was provisionally valid (unknown balance) is re-checked. + void availableSharesChanged(BigInt shares) { + if (availableShares == shares) return; + availableShares = shares; + if (state.text.isNotEmpty) amountChanged(state.text); + } + + /// Re-evaluates [raw] on every keystroke and emits the parsed amount + a + /// validity flag the confirm button binds to. + void amountChanged(String raw) { + final text = raw.trim(); + if (text.isEmpty) { + emit(SendAmountState(text: text, amount: null, status: SendAmountStatus.empty)); + return; + } + + final parsed = int.tryParse(text); + if (parsed == null || parsed < 1) { + emit(SendAmountState(text: text, amount: parsed, status: SendAmountStatus.invalid)); + return; + } + + final balance = availableShares; + if (balance != null && BigInt.from(parsed) > balance) { + emit( + SendAmountState(text: text, amount: parsed, status: SendAmountStatus.insufficientBalance), + ); + return; + } + + emit(SendAmountState(text: text, amount: parsed, status: SendAmountStatus.valid)); + } + + /// Fills the field with the full available balance. + void useMax() { + final balance = availableShares; + if (balance == null || balance < BigInt.one) return; + amountChanged(balance.toString()); + } +} diff --git a/lib/screens/send/cubits/send_amount/send_amount_state.dart b/lib/screens/send/cubits/send_amount/send_amount_state.dart new file mode 100644 index 000000000..f666804f6 --- /dev/null +++ b/lib/screens/send/cubits/send_amount/send_amount_state.dart @@ -0,0 +1,37 @@ +part of 'send_amount_cubit.dart'; + +/// Validity of the entered amount. Each value maps to a localized hint/error in +/// the view — the cubit carries the status, not the copy. +enum SendAmountStatus { + /// Nothing entered yet. + empty, + + /// Not a whole number >= 1. + invalid, + + /// A valid whole number, but more than the available REALU balance. + insufficientBalance, + + /// A valid, sendable amount. + valid, +} + +class SendAmountState extends Equatable { + final String text; + + /// The parsed amount, or null when [text] is empty / not an integer. + final int? amount; + final SendAmountStatus status; + + const SendAmountState({ + this.text = '', + this.amount, + this.status = SendAmountStatus.empty, + }); + + /// The confirm button binds to this — only a fully valid amount may advance. + bool get isValid => status == SendAmountStatus.valid && amount != null; + + @override + List get props => [text, amount, status]; +} diff --git a/lib/screens/send/cubits/send_process/send_process_cubit.dart b/lib/screens/send/cubits/send_process/send_process_cubit.dart new file mode 100644 index 000000000..e7f5c2e5c --- /dev/null +++ b/lib/screens/send/cubits/send_process/send_process_cubit.dart @@ -0,0 +1,90 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; + +part 'send_process_state.dart'; + +/// Orchestrates the gasless wallet-to-wallet transfer once the user confirms: +/// gate the wallet's signing capability → `PUT /transfer` (prepare + EIP-7702 +/// delegation data) → sign the EIP-712 delegation + EIP-7702 authorization and +/// `PUT /transfer/:id/confirm` → success (txHash) / a typed failure. +/// +/// The gasless EIP-7702 path can only be signed by a software wallet today; +/// debug and BitBox wallets are gated up-front (the contract's capability gate — +/// the flow is NOT otherwise branched on wallet type). Insufficient REALU and +/// invalid-address are decided by the API and rendered from its signal; a 503 +/// (gas-funding-unavailable) surfaces as a dedicated "temporarily unavailable" +/// state. +class SendProcessCubit extends Cubit { + final RealUnitTransferService _transferService; + final AppStore _appStore; + final String _recipient; + final int _amount; + + SendProcessCubit({ + required RealUnitTransferService transferService, + required AppStore appStore, + required String recipient, + required int amount, + }) : _transferService = transferService, + _appStore = appStore, + _recipient = recipient, + _amount = amount, + super(const SendProcessInitial()); + + /// Entry point — called by the view once the user confirms the summary. + Future start() async { + // Capability gate BEFORE any network/sign action: only a software wallet can + // produce the EIP-712 delegation + EIP-7702 authorization the gasless + // transfer requires. Surface the dedicated unsupported state otherwise. + if (_appStore.wallet.walletType != WalletType.software) { + emit(const SendProcessFailure(SendProcessFailureReason.signatureUnsupported)); + return; + } + + try { + emit(const SendProcessPreparing()); + final info = await _transferService.prepareTransfer( + RealUnitTransferDto(toAddress: _recipient, amount: _amount), + ); + + emit(const SendProcessSigning()); + final txHash = await _transferService.confirmTransfer(info); + + emit(SendProcessSuccess(txHash)); + } on TransferSignatureUnsupportedException { + emit(const SendProcessFailure(SendProcessFailureReason.signatureUnsupported)); + } on TransferGasFundingUnavailableException { + emit(const SendProcessFailure(SendProcessFailureReason.gasFundingUnavailable)); + } on SigningCancelledException { + emit(const SendProcessFailure(SendProcessFailureReason.signatureCancelled)); + } on BitboxNotConnectedException { + emit(const SendProcessFailure(SendProcessFailureReason.signatureUnsupported)); + } on ApiException catch (e) { + // The API is the authority on recipient/amount/eligibility. Render its + // signaled reason rather than re-deriving limits locally. + emit(SendProcessFailure(_reasonForApi(e), message: e.message)); + } catch (e) { + emit(SendProcessFailure(SendProcessFailureReason.generic, message: e.toString())); + } + } + + /// Maps an API error to a typed failure reason. A 400 from `PUT /transfer` + /// covers both an invalid recipient and insufficient REALU; both render a + /// generic "could not prepare the transfer" message keyed off the API text, + /// so they share [SendProcessFailureReason.invalidRequest]. + static SendProcessFailureReason _reasonForApi(ApiException e) { + if (e.statusCode == 503) return SendProcessFailureReason.gasFundingUnavailable; + if (e.statusCode == 400 || e.statusCode == 404) { + return SendProcessFailureReason.invalidRequest; + } + return SendProcessFailureReason.generic; + } +} diff --git a/lib/screens/send/cubits/send_process/send_process_state.dart b/lib/screens/send/cubits/send_process/send_process_state.dart new file mode 100644 index 000000000..897345b1c --- /dev/null +++ b/lib/screens/send/cubits/send_process/send_process_state.dart @@ -0,0 +1,66 @@ +part of 'send_process_cubit.dart'; + +/// Why the transfer failed. Each reason maps to a localized, user-facing message +/// in the view — the cubit carries the reason, not the copy. +enum SendProcessFailureReason { + /// The active wallet mode cannot sign the gasless EIP-7702 transfer (debug or + /// BitBox wallet, or a debug credential detected at sign time). + signatureUnsupported, + + /// The user cancelled the signature (or the signing device dropped the link). + signatureCancelled, + + /// DFX cannot currently fund gas for the transfer (the API's 503). The user's + /// REALU is untouched — this is a transient "temporarily unavailable" state. + gasFundingUnavailable, + + /// The API rejected the prepare request (invalid recipient, self-transfer, + /// token-contract recipient, non-integer amount, or insufficient REALU). The + /// API message carries the specific detail. + invalidRequest, + + /// Any other unexpected error. + generic, +} + +sealed class SendProcessState extends Equatable { + const SendProcessState(); + + @override + List get props => []; +} + +class SendProcessInitial extends SendProcessState { + const SendProcessInitial(); +} + +/// `PUT /transfer` in flight — preparing the intent + EIP-7702 delegation data. +class SendProcessPreparing extends SendProcessState { + const SendProcessPreparing(); +} + +/// Signing the EIP-712 delegation + EIP-7702 authorization and confirming. +class SendProcessSigning extends SendProcessState { + const SendProcessSigning(); +} + +class SendProcessSuccess extends SendProcessState { + final String txHash; + + const SendProcessSuccess(this.txHash); + + @override + List get props => [txHash]; +} + +class SendProcessFailure extends SendProcessState { + final SendProcessFailureReason reason; + + /// Diagnostic detail for logs — not the user-facing copy. + final String? message; + + const SendProcessFailure(this.reason, {this.message}); + + @override + List get props => [reason, message]; +} diff --git a/lib/screens/send/cubits/send_recipient/send_recipient_cubit.dart b/lib/screens/send/cubits/send_recipient/send_recipient_cubit.dart new file mode 100644 index 000000000..993411f48 --- /dev/null +++ b/lib/screens/send/cubits/send_recipient/send_recipient_cubit.dart @@ -0,0 +1,55 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart'; +import 'package:web3dart/web3dart.dart' show EthereumAddress; + +part 'send_recipient_state.dart'; + +/// Captures the transfer recipient — either scanned from a QR code or pasted / +/// typed manually. Validation here is a client-side UX guard only (a malformed +/// address is rejected before the user can advance); the API remains the final +/// authority on the address. A scanned `ethereum:0x…` URI is normalized to the +/// bare address so a wallet QR that encodes an EIP-681 URI is accepted. +class SendRecipientCubit extends Cubit { + SendRecipientCubit() : super(const SendRecipientEmpty()); + + /// Called once per detected barcode while the scanner is open. Guards against + /// re-entry after a successful decode so a continuously-detecting scanner does + /// not re-emit. + void onCodeDetected(String raw) { + if (state is SendRecipientValid) return; + submit(raw); + } + + /// Validates a manually entered / pasted / scanned [input]. Emits + /// [SendRecipientValid] with the checksummed address on success, or + /// [SendRecipientInvalid] otherwise. + void submit(String input) { + final address = _extractAddress(input); + try { + final checksummed = EthereumAddress.fromHex(address).hexEip55; + emit(SendRecipientValid(checksummed)); + } catch (_) { + emit(SendRecipientInvalid(InvalidRecipientAddressException(input.trim()))); + } + } + + /// Clears the current selection so the field/scanner is ready for new input. + void reset() => emit(const SendRecipientEmpty()); + + /// Strips an optional `ethereum:` EIP-681 scheme and any `@chainId` / query + /// suffix, returning the bare hex address candidate. + static String _extractAddress(String input) { + var value = input.trim(); + if (value.toLowerCase().startsWith('ethereum:')) { + value = value.substring('ethereum:'.length); + } + final at = value.indexOf('@'); + if (at != -1) value = value.substring(0, at); + final query = value.indexOf('?'); + if (query != -1) value = value.substring(0, query); + final slash = value.indexOf('/'); + if (slash != -1) value = value.substring(0, slash); + return value.trim(); + } +} diff --git a/lib/screens/send/cubits/send_recipient/send_recipient_state.dart b/lib/screens/send/cubits/send_recipient/send_recipient_state.dart new file mode 100644 index 000000000..549d754e1 --- /dev/null +++ b/lib/screens/send/cubits/send_recipient/send_recipient_state.dart @@ -0,0 +1,33 @@ +part of 'send_recipient_cubit.dart'; + +sealed class SendRecipientState extends Equatable { + const SendRecipientState(); + + @override + List get props => []; +} + +/// No recipient entered yet. +class SendRecipientEmpty extends SendRecipientState { + const SendRecipientEmpty(); +} + +/// A syntactically valid EVM address, normalized to its EIP-55 checksum form. +class SendRecipientValid extends SendRecipientState { + final String address; + + const SendRecipientValid(this.address); + + @override + List get props => [address]; +} + +/// The entered/scanned value is not a valid EVM address (client-side UX guard). +class SendRecipientInvalid extends SendRecipientState { + final InvalidRecipientAddressException error; + + const SendRecipientInvalid(this.error); + + @override + List get props => [error.input]; +} diff --git a/lib/screens/send/send_amount_page.dart b/lib/screens/send/send_amount_page.dart new file mode 100644 index 000000000..bbb95ea08 --- /dev/null +++ b/lib/screens/send/send_amount_page.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/screens/sell/cubits/sell_balance/sell_balance_cubit.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_amount/send_amount_cubit.dart'; +import 'package:realunit_wallet/screens/send/send_confirm_page.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/styles/colors.dart'; + +/// Second step: choose the whole-share REALU amount. The available balance is +/// read via the shared [SellBalanceCubit] (a generic REALU balance watcher) and +/// fed into [SendAmountCubit] for the local over-balance UX guard. REALU has +/// `decimals = 0`, so the raw balance equals whole shares. +class SendAmountPage extends StatelessWidget { + final String recipient; + + const SendAmountPage({super.key, required this.recipient}); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => SellBalanceCubit(getIt(), getIt()), + ), + BlocProvider(create: (_) => SendAmountCubit()), + ], + child: SendAmountView(recipient: recipient), + ); + } +} + +class SendAmountView extends StatelessWidget { + final String recipient; + + const SendAmountView({super.key, required this.recipient}); + + @override + Widget build(BuildContext context) { + // The balance arrives over a stream; push every update into the amount + // cubit so the available hint + over-balance guard track the live balance + // (BlocProvider.create runs once, so the balance can't be captured there). + return BlocListener( + listener: (context, balance) => + context.read().availableSharesChanged(balance.balance), + child: _SendAmountBody(recipient: recipient), + ); + } +} + +class _SendAmountBody extends StatefulWidget { + final String recipient; + + const _SendAmountBody({required this.recipient}); + + @override + State<_SendAmountBody> createState() => _SendAmountBodyState(); +} + +class _SendAmountBodyState extends State<_SendAmountBody> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _syncController(String text) { + if (_controller.text == text) return; + _controller.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).sendAmountTitle)), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: BlocConsumer( + listenWhen: (previous, current) => previous.text != current.text, + listener: (context, state) => _syncController(state.text), + builder: (context, state) { + // The available hint tracks the live balance directly so it stays + // in sync with the stream. + final available = context.watch().state.balance; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + Text( + S.of(context).sendAmountAvailable(available.toString()), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + TextField( + controller: _controller, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + labelText: S.of(context).sendAmountLabel, + errorText: _errorText(context, state.status), + suffixIcon: TextButton( + onPressed: () => context.read().useMax(), + child: Text(S.of(context).max.toUpperCase()), + ), + ), + onChanged: (value) => context.read().amountChanged(value), + ), + const Spacer(), + FilledButton( + onPressed: state.isValid + ? () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SendConfirmPage( + recipient: widget.recipient, + amount: state.amount!, + ), + ), + ) + : null, + child: Text(S.of(context).next), + ), + ], + ); + }, + ), + ), + ), + ); + } + + String? _errorText(BuildContext context, SendAmountStatus status) => switch (status) { + SendAmountStatus.empty || SendAmountStatus.valid => null, + SendAmountStatus.invalid => S.of(context).sendAmountInvalid, + SendAmountStatus.insufficientBalance => S.of(context).sendAmountInsufficient, + }; +} diff --git a/lib/screens/send/send_confirm_page.dart b/lib/screens/send/send_confirm_page.dart new file mode 100644 index 000000000..f2ee52b63 --- /dev/null +++ b/lib/screens/send/send_confirm_page.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/send/send_process_page.dart'; +import 'package:realunit_wallet/styles/colors.dart'; + +/// Third step: review the recipient + amount before signing. Confirming starts +/// the on-chain process step. +class SendConfirmPage extends StatelessWidget { + final String recipient; + final int amount; + + const SendConfirmPage({super.key, required this.recipient, required this.amount}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).sendConfirmTitle)), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 24, + children: [ + const Spacer(), + Text( + S.of(context).sendConfirmSummary(amount.toString()), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium, + ), + _SummaryRow( + label: S.of(context).sendConfirmAmount, + value: S.of(context).sendShares(amount.toString()), + ), + _SummaryRow( + label: S.of(context).sendConfirmRecipient, + value: recipient, + ), + const Spacer(), + FilledButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SendProcessPage(recipient: recipient, amount: amount), + ), + ), + child: Text(S.of(context).sendConfirmButton), + ), + ], + ), + ), + ), + ); + } +} + +class _SummaryRow extends StatelessWidget { + final String label; + final String value; + + const _SummaryRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + value, + textAlign: TextAlign.end, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ); + } +} diff --git a/lib/screens/send/send_process_page.dart b/lib/screens/send/send_process_page.dart new file mode 100644 index 000000000..dc79c47a5 --- /dev/null +++ b/lib/screens/send/send_process_page.dart @@ -0,0 +1,134 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/styles/colors.dart'; + +/// Final step: prepare → sign (EIP-712 delegation + EIP-7702 authorization) → +/// confirm, then render the txHash success or a typed failure. The cubit drives +/// every outcome as a state — no error-string parsing in the view. +class SendProcessPage extends StatelessWidget { + final String recipient; + final int amount; + + const SendProcessPage({super.key, required this.recipient, required this.amount}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => SendProcessCubit( + transferService: getIt(), + appStore: getIt(), + recipient: recipient, + amount: amount, + )..start(), + child: const SendProcessView(), + ); + } +} + +class SendProcessView extends StatelessWidget { + const SendProcessView({super.key}); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (previous, current) => + current is SendProcessSuccess || current is SendProcessFailure, + listener: (context, state) async { + if (state is SendProcessSuccess) { + await _showResultSheet( + context, + icon: Icons.check_circle_rounded, + title: S.of(context).sendSuccess, + description: S.of(context).sendSuccessDescription, + ); + } else if (state is SendProcessFailure) { + await _showResultSheet( + context, + icon: Icons.error_rounded, + title: S.of(context).sendFailureTitle, + description: _failureMessage(context, state.reason), + ); + } + }, + builder: (context, state) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).sendProcessTitle)), + body: SafeArea( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 24, + children: [ + const CupertinoActivityIndicator(radius: 16), + Text( + _progressLabel(context, state), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ), + ); + }, + ); + } + + String _progressLabel(BuildContext context, SendProcessState state) => switch (state) { + SendProcessInitial() || SendProcessPreparing() => S.of(context).sendPreparing, + SendProcessSigning() => S.of(context).sendSigning, + SendProcessSuccess() => S.of(context).sendSuccess, + SendProcessFailure() => S.of(context).sendFailureTitle, + }; + + String _failureMessage(BuildContext context, SendProcessFailureReason reason) => switch (reason) { + SendProcessFailureReason.signatureUnsupported => S.of(context).sendFailureSignatureUnsupported, + SendProcessFailureReason.signatureCancelled => S.of(context).sendFailureSignatureCancelled, + SendProcessFailureReason.gasFundingUnavailable => S.of(context).sendFailureGasUnavailable, + SendProcessFailureReason.invalidRequest => S.of(context).sendFailureInvalidRequest, + SendProcessFailureReason.generic => S.of(context).sendFailureGeneric, + }; + + Future _showResultSheet( + BuildContext context, { + required IconData icon, + required String title, + required String description, + }) async { + await showModalBottomSheet( + context: context, + isDismissible: false, + builder: (_) => SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 24, + children: [ + Icon(icon, color: RealUnitColors.realUnitBlue, size: 64), + Text(title, style: Theme.of(context).textTheme.headlineMedium), + Text( + description, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(S.of(context).close), + ), + ], + ), + ), + ), + ); + if (context.mounted) Navigator.of(context).pop(); + } +} diff --git a/lib/screens/send/send_recipient_page.dart b/lib/screens/send/send_recipient_page.dart new file mode 100644 index 000000000..989e424ee --- /dev/null +++ b/lib/screens/send/send_recipient_page.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_recipient/send_recipient_cubit.dart'; +import 'package:realunit_wallet/screens/send/send_amount_page.dart'; +import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/scanner/qr_scanner_view.dart'; + +/// First step of the wallet-to-wallet send flow: pick the recipient by scanning +/// a wallet QR or pasting/typing the address. Reuses the shared +/// [QrScannerView] (same camera wrapper the OCP pay flow uses) so the scanner is +/// not duplicated; the EVM-address decode lives in [SendRecipientCubit]. +class SendRecipientPage extends StatelessWidget { + const SendRecipientPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => SendRecipientCubit(), + child: const SendRecipientView(), + ); + } +} + +class SendRecipientView extends StatefulWidget { + const SendRecipientView({super.key}); + + @override + State createState() => _SendRecipientViewState(); +} + +class _SendRecipientViewState extends State { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state is SendRecipientValid) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SendAmountPage(recipient: state.address), + ), + ); + context.read().reset(); + } + if (state is SendRecipientInvalid) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context).sendRecipientInvalid), + backgroundColor: RealUnitColors.status.red600, + ), + ); + } + }, + child: Scaffold( + appBar: AppBar(title: Text(S.of(context).sendRecipientTitle)), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: QrScannerView( + onDetect: (raw) => context.read().onCodeDetected(raw), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 12, + children: [ + Text( + S.of(context).sendRecipientManualHint, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + TextField( + controller: _controller, + autocorrect: false, + decoration: InputDecoration( + labelText: S.of(context).sendRecipientLabel, + suffixIcon: IconButton( + icon: const Icon(Icons.paste_rounded), + tooltip: S.of(context).sendPaste, + onPressed: () async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text; + if (text != null) _controller.text = text.trim(); + }, + ), + ), + ), + FilledButton( + onPressed: () => context.read().submit(_controller.text), + child: Text(S.of(context).next), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/setup/di.dart b/lib/setup/di.dart index fa7b20f12..e553209c8 100644 --- a/lib/setup/di.dart +++ b/lib/setup/di.dart @@ -29,9 +29,11 @@ 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'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart'; import 'package:realunit_wallet/packages/service/session_cache.dart'; import 'package:realunit_wallet/packages/service/settings_service.dart'; import 'package:realunit_wallet/packages/service/transaction_history_service.dart'; @@ -174,6 +176,9 @@ void setupServices() { getIt.registerFactory( () => RealUnitBuyPaymentInfoService(getIt(), getIt()), ); + getIt.registerFactory( + () => RealUnitPayService(getIt(), getIt()), + ); getIt.registerFactory( () => RealUnitPdfService(getIt(), getIt()), ); @@ -183,6 +188,9 @@ void setupServices() { getIt.registerFactory( () => RealUnitSellPaymentInfoService(getIt(), getIt()), ); + getIt.registerFactory( + () => RealUnitTransferService(getIt(), getIt()), + ); getIt.registerFactory(() => SettingsService(getIt())); getIt.registerFactory( () => DebugAuthService(getIt(), getIt()), diff --git a/lib/setup/routing/router_config.dart b/lib/setup/routing/router_config.dart index 3d0b9e263..d56aef7ee 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'; @@ -18,6 +19,7 @@ import 'package:realunit_wallet/screens/restore_wallet/restore_wallet_page.dart' import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/sell_payment_info.dart'; import 'package:realunit_wallet/screens/sell/sell_page.dart'; import 'package:realunit_wallet/screens/sell_bitbox/sell_bitbox_page.dart'; +import 'package:realunit_wallet/screens/send/send_recipient_page.dart'; import 'package:realunit_wallet/screens/settings/settings_page.dart'; import 'package:realunit_wallet/screens/settings_contact/settings_contact_page.dart'; import 'package:realunit_wallet/screens/settings_currencies/settings_currencies_page.dart'; @@ -150,6 +152,18 @@ final GoRouter routerConfig = GoRouter( builder: (_, state) => SellBitboxPage(paymentInfo: state.extra as SellPaymentInfo), ), + GoRoute( + name: AppRoutes.pay, + path: '/pay', + builder: (_, _) => const PayScanPage(), + ), + + GoRoute( + name: AppRoutes.send, + path: '/send', + builder: (_, _) => const SendRecipientPage(), + ), + GoRoute( name: LegalRoutes.disclaimer, path: '/legalDisclaimer', diff --git a/lib/setup/routing/routes/app_routes.dart b/lib/setup/routing/routes/app_routes.dart index 1ec2a07a9..bb7491a48 100644 --- a/lib/setup/routing/routes/app_routes.dart +++ b/lib/setup/routing/routes/app_routes.dart @@ -5,6 +5,8 @@ abstract final class AppRoutes { static const buy = 'buy'; static const sell = 'sell'; static const sellBitbox = 'sellBitbox'; + static const pay = 'pay'; + static const send = 'send'; static const kyc = 'kyc'; static const receive = 'receive'; diff --git a/lib/widgets/scanner/qr_scanner_view.dart b/lib/widgets/scanner/qr_scanner_view.dart new file mode 100644 index 000000000..9bf6020b6 --- /dev/null +++ b/lib/widgets/scanner/qr_scanner_view.dart @@ -0,0 +1,30 @@ +// @no-integration-test: the QR scanner is camera/MethodChannel-coupled +// (mobile_scanner) and can only be exercised on a real device with a live +// camera. This widget is the shared camera-preview wrapper reused by the OCP +// pay flow (LNURL decode) and the wallet-to-wallet send flow (EVM address +// decode); the per-flow decode logic it feeds is unit-tested in the respective +// cubit tests. +import 'package:flutter/widgets.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +/// Thin wrapper around [MobileScanner] that surfaces the first raw barcode +/// value of each capture via [onDetect]. Keeping the camera/MethodChannel +/// wiring in one widget lets every flow reuse the scanner without duplicating +/// it — each flow decides what the scanned payload means in its own cubit. +class QrScannerView extends StatelessWidget { + /// Invoked with the raw string value of the first detected barcode in a + /// capture. Null raw values are filtered out before this is called. + final ValueChanged onDetect; + + const QrScannerView({super.key, required this.onDetect}); + + @override + Widget build(BuildContext context) { + return MobileScanner( + onDetect: (capture) { + final raw = capture.barcodes.firstOrNull?.rawValue; + if (raw != null) onDetect(raw); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index bf626bd73..ef7333d8a 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 9877d9640..cf0a2b4dd 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 02631d184..61341c27b 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 84de91247..309a55088 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 000000000..f180456ff 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 000000000..9c782538d 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 000000000..18c71c3ad 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 000000000..0ce937c0b 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 000000000..66fa82dc2 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 000000000..c3ed67d3c 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_quote_page_unsupported_environment.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_unsupported_environment.png new file mode 100644 index 000000000..4c79db848 Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_quote_page_unsupported_environment.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 000000000..5f249d68a 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 000000000..80f8d4f30 --- /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 000000000..f0172da37 --- /dev/null +++ b/test/goldens/screens/pay/pay_quote_golden_test.dart @@ -0,0 +1,92 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_quote_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockPayQuoteCubit extends MockCubit implements PayQuoteCubit {} + +void main() { + late _MockPayQuoteCubit quoteCubit; + + setUp(() { + quoteCubit = _MockPayQuoteCubit(); + when(() => quoteCubit.state).thenReturn(const PayQuoteLoading()); + }); + + // PayQuotePage resolves its cubit from getIt and calls load(); the golden + // renders PayQuoteView directly with a mocked cubit so every state is + // deterministic without the service/DI graph. + group('$PayQuoteView', () { + goldenTest( + 'loading state', + fileName: 'pay_quote_page_loading', + constraints: phoneConstraints, + // The CupertinoActivityIndicator animates forever, so pumpAndSettle + // would time out; pumpOnce captures the first frame. + pumpBeforeTest: pumpOnce, + builder: () => wrapForGolden( + BlocProvider.value( + value: quoteCubit, + child: const PayQuoteView(), + ), + ), + ); + + goldenTest( + 'ready quote with CHF amount and ZCHF needed', + fileName: 'pay_quote_page_ready', + constraints: phoneConstraints, + builder: () { + when(() => quoteCubit.state).thenReturn( + const PayQuoteReady( + paymentLinkId: 'pl_abc', + quoteId: 'quote_xyz', + fiatAsset: 'CHF', + fiatAmount: 42.5, + zchfAmount: 42.7, + ), + ); + return wrapForGolden( + BlocProvider.value( + value: quoteCubit, + child: const PayQuoteView(), + ), + ); + }, + ); + + goldenTest( + 'unsupported environment message', + fileName: 'pay_quote_page_unsupported_environment', + constraints: phoneConstraints, + builder: () { + when(() => quoteCubit.state).thenReturn(const PayQuoteUnsupportedEnvironment()); + return wrapForGolden( + BlocProvider.value( + value: quoteCubit, + child: const PayQuoteView(), + ), + ); + }, + ); + + goldenTest( + 'expired quote message', + fileName: 'pay_quote_page_expired', + constraints: phoneConstraints, + builder: () { + when(() => quoteCubit.state).thenReturn(const PayQuoteExpired()); + return wrapForGolden( + BlocProvider.value( + value: quoteCubit, + child: const PayQuoteView(), + ), + ); + }, + ); + }); +} diff --git a/test/goldens/screens/pay/pay_scan_golden_test.dart b/test/goldens/screens/pay/pay_scan_golden_test.dart new file mode 100644 index 000000000..dacdb4306 --- /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/goldens/screens/send/goldens/macos/send_amount_page_empty.png b/test/goldens/screens/send/goldens/macos/send_amount_page_empty.png new file mode 100644 index 000000000..caeb2d35b Binary files /dev/null and b/test/goldens/screens/send/goldens/macos/send_amount_page_empty.png differ diff --git a/test/goldens/screens/send/goldens/macos/send_amount_page_insufficient.png b/test/goldens/screens/send/goldens/macos/send_amount_page_insufficient.png new file mode 100644 index 000000000..cfefba140 Binary files /dev/null and b/test/goldens/screens/send/goldens/macos/send_amount_page_insufficient.png differ diff --git a/test/goldens/screens/send/goldens/macos/send_confirm_page.png b/test/goldens/screens/send/goldens/macos/send_confirm_page.png new file mode 100644 index 000000000..25a4df850 Binary files /dev/null and b/test/goldens/screens/send/goldens/macos/send_confirm_page.png differ diff --git a/test/goldens/screens/send/goldens/macos/send_process_page_signing.png b/test/goldens/screens/send/goldens/macos/send_process_page_signing.png new file mode 100644 index 000000000..fc553f688 Binary files /dev/null and b/test/goldens/screens/send/goldens/macos/send_process_page_signing.png differ diff --git a/test/goldens/screens/send/goldens/macos/send_recipient_page_empty.png b/test/goldens/screens/send/goldens/macos/send_recipient_page_empty.png new file mode 100644 index 000000000..c22791cb5 Binary files /dev/null and b/test/goldens/screens/send/goldens/macos/send_recipient_page_empty.png differ diff --git a/test/goldens/screens/send/send_golden_test.dart b/test/goldens/screens/send/send_golden_test.dart new file mode 100644 index 000000000..71c63cbff --- /dev/null +++ b/test/goldens/screens/send/send_golden_test.dart @@ -0,0 +1,161 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/screens/sell/cubits/sell_balance/sell_balance_cubit.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_amount/send_amount_cubit.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_recipient/send_recipient_cubit.dart'; +import 'package:realunit_wallet/screens/send/send_amount_page.dart'; +import 'package:realunit_wallet/screens/send/send_confirm_page.dart'; +import 'package:realunit_wallet/screens/send/send_process_page.dart'; +import 'package:realunit_wallet/screens/send/send_recipient_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockSendRecipientCubit extends MockCubit implements SendRecipientCubit {} + +class _MockSellBalanceCubit extends MockCubit implements SellBalanceCubit {} + +class _MockSendAmountCubit extends MockCubit implements SendAmountCubit {} + +class _MockSendProcessCubit extends MockCubit implements SendProcessCubit {} + +Balance _balance(int shares) => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: '0xwallet', + balance: BigInt.from(shares), + asset: realUnitAsset, +); + +void main() { + setUpAll(() { + registerFallbackValue(BigInt.zero); + stubMobileScannerChannel(); + }); + + group('$SendRecipientView', () { + late _MockSendRecipientCubit recipientCubit; + + setUp(() { + recipientCubit = _MockSendRecipientCubit(); + when(() => recipientCubit.state).thenReturn(const SendRecipientEmpty()); + }); + + goldenTest( + 'scan + manual-entry state', + fileName: 'send_recipient_page_empty', + constraints: phoneConstraints, + // The camera preview never reaches an isInitialized frame headlessly, so + // pumpAndSettle would await a settle that never comes. pumpOnce captures + // the deterministic placeholder frame. + pumpBeforeTest: pumpOnce, + builder: () => wrapForGolden( + BlocProvider.value( + value: recipientCubit, + child: const SendRecipientView(), + ), + ), + ); + }); + + group('$SendAmountView', () { + late _MockSellBalanceCubit balanceCubit; + late _MockSendAmountCubit amountCubit; + + setUp(() { + balanceCubit = _MockSellBalanceCubit(); + amountCubit = _MockSendAmountCubit(); + when(() => balanceCubit.state).thenReturn(_balance(42)); + when(() => amountCubit.availableShares).thenReturn(BigInt.from(42)); + when(() => amountCubit.availableSharesChanged(any())).thenReturn(null); + when(() => amountCubit.state).thenReturn(const SendAmountState()); + }); + + goldenTest( + 'amount entry with available balance', + fileName: 'send_amount_page_empty', + constraints: phoneConstraints, + builder: () => wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: balanceCubit), + BlocProvider.value(value: amountCubit), + ], + child: const SendAmountView(recipient: '0xRecipient'), + ), + ), + ); + + goldenTest( + 'over-balance amount shows the insufficient error', + fileName: 'send_amount_page_insufficient', + constraints: phoneConstraints, + builder: () { + when(() => amountCubit.state).thenReturn( + const SendAmountState( + text: '99', + amount: 99, + status: SendAmountStatus.insufficientBalance, + ), + ); + return wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: balanceCubit), + BlocProvider.value(value: amountCubit), + ], + child: const SendAmountView(recipient: '0xRecipient'), + ), + ); + }, + ); + }); + + group('$SendConfirmPage', () { + goldenTest( + 'transfer summary', + fileName: 'send_confirm_page', + constraints: phoneConstraints, + builder: () => wrapForGolden( + const SendConfirmPage( + recipient: '0x9F5713DEacB8e9CAB6c2d3FaE1AFc2715F8D2D71', + amount: 5, + ), + ), + ); + }); + + group('$SendProcessView', () { + late _MockSendProcessCubit processCubit; + + setUp(() { + processCubit = _MockSendProcessCubit(); + when(() => processCubit.state).thenReturn(const SendProcessInitial()); + }); + + // Terminal states (success/failure) are surfaced via a modal sheet from the + // listener, not the build tree — exercised in the widget test. The build + // tree shows the in-progress indicator with a per-state label. + goldenTest( + 'in-progress signing state', + fileName: 'send_process_page_signing', + constraints: phoneConstraints, + // The CupertinoActivityIndicator animates forever; pumpOnce captures the + // first frame. + pumpBeforeTest: pumpOnce, + builder: () { + when(() => processCubit.state).thenReturn(const SendProcessSigning()); + return wrapForGolden( + BlocProvider.value( + value: processCubit, + child: const SendProcessView(), + ), + ); + }, + ); + }); +} diff --git a/test/helper/golden_plugin_stubs.dart b/test/helper/golden_plugin_stubs.dart index c1e4e2f54..22754bc89 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 d187e5a66..2c6a5cc8e 100644 --- a/test/packages/service/dfx/exceptions/exception_surface_test.dart +++ b/test/packages/service/dfx/exceptions/exception_surface_test.dart @@ -2,6 +2,8 @@ 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/service/dfx/exceptions/payment/transfer_exceptions.dart'; import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; // Guard against a recurring failure mode: an Exception subclass without a @@ -24,6 +26,12 @@ void main() { requiredLevel: 1, currentLevel: 0, ), + const InvalidPaymentLinkException('test'), + const PayUnsupportedEnvironmentException(), + const PaySignatureUnsupportedException(), + const InvalidRecipientAddressException('test'), + const TransferSignatureUnsupportedException(), + const TransferGasFundingUnavailableException(), ]; for (final ex in exceptions) { diff --git a/test/packages/service/dfx/lnurl_decoder_test.dart b/test/packages/service/dfx/lnurl_decoder_test.dart new file mode 100644 index 000000000..bd660bf64 --- /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 000000000..eab27d41a --- /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/models/payment/transfer/transfer_dtos_test.dart b/test/packages/service/dfx/models/payment/transfer/transfer_dtos_test.dart new file mode 100644 index 000000000..444f6bd34 --- /dev/null +++ b/test/packages/service/dfx/models/payment/transfer/transfer_dtos_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart'; + +Map _eip7702Json() => { + 'relayerAddress': '0xRelayer', + 'delegationManagerAddress': '0xManager', + 'delegatorAddress': '0xDelegator', + 'userNonce': 7, + 'domain': { + 'name': 'DelegationManager', + 'version': '1', + 'chainId': 11155111, + 'verifyingContract': '0xManager', + }, + 'types': { + 'Delegation': [ + {'name': 'delegate', 'type': 'address'}, + ], + 'Caveat': [ + {'name': 'enforcer', 'type': 'address'}, + ], + }, + 'message': { + 'delegate': '0xRelayer', + 'delegator': '0xSender', + 'authority': '0xRoot', + 'caveats': [], + 'salt': 3, + }, + 'tokenAddress': '0xRealu', + 'amountWei': '5', + 'recipient': '0xRecipient', +}; + +void main() { + group('RealUnitTransferDto', () { + test('toJson carries toAddress + amount', () { + const dto = RealUnitTransferDto(toAddress: '0xRecipient', amount: 5); + + expect(dto.toJson(), {'toAddress': '0xRecipient', 'amount': 5}); + }); + }); + + group('RealUnitTransferEip7702Data', () { + test('fromJson parses the recipient (transfer) shape', () { + final data = RealUnitTransferEip7702Data.fromJson(_eip7702Json()); + + expect(data.relayerAddress, '0xRelayer'); + expect(data.delegatorAddress, '0xDelegator'); + expect(data.userNonce, 7); + expect(data.domain.chainId, 11155111); + expect(data.types.delegation.first.name, 'delegate'); + expect(data.types.caveat.first.type, 'address'); + expect(data.message.delegator, '0xSender'); + expect(data.message.salt, 3); + expect(data.tokenAddress, '0xRealu'); + expect(data.amountWei, '5'); + expect(data.recipient, '0xRecipient'); + }); + + test('toEip7702Data maps recipient into the shared signer DTO depositAddress', () { + final data = RealUnitTransferEip7702Data.fromJson(_eip7702Json()); + + final shared = data.toEip7702Data(); + + // The recipient flows through depositAddress (the signers never read it), + // while every signed field is preserved verbatim. + expect(shared.depositAddress, '0xRecipient'); + expect(shared.relayerAddress, '0xRelayer'); + expect(shared.delegatorAddress, '0xDelegator'); + expect(shared.userNonce, 7); + expect(shared.domain.chainId, 11155111); + expect(shared.message.delegator, '0xSender'); + expect(shared.message.salt, 3); + expect(shared.tokenAddress, '0xRealu'); + expect(shared.amountWei, '5'); + }); + }); + + group('RealUnitTransferPaymentInfoDto', () { + test('fromJson parses the full prepare response', () { + final dto = RealUnitTransferPaymentInfoDto.fromJson({ + 'id': 99, + 'uid': 'RTabc', + 'toAddress': '0xRecipient', + 'amount': 5, + 'tokenAddress': '0xRealu', + 'chainId': 11155111, + 'eip7702': _eip7702Json(), + }); + + expect(dto.id, 99); + expect(dto.uid, 'RTabc'); + expect(dto.toAddress, '0xRecipient'); + expect(dto.amount, 5); + expect(dto.tokenAddress, '0xRealu'); + expect(dto.chainId, 11155111); + expect(dto.eip7702.recipient, '0xRecipient'); + expect(dto.eip7702.amountWei, '5'); + }); + + test('fromJson tolerates a numeric (double) amount from the API', () { + final dto = RealUnitTransferPaymentInfoDto.fromJson({ + 'id': 1, + 'uid': 'RTx', + 'toAddress': '0xRecipient', + 'amount': 5.0, + 'tokenAddress': '0xRealu', + 'chainId': 1, + 'eip7702': _eip7702Json(), + }); + + expect(dto.amount, 5); + }); + }); +} diff --git a/test/packages/service/dfx/real_unit_pay_service_test.dart b/test/packages/service/dfx/real_unit_pay_service_test.dart new file mode 100644 index 000000000..98d8f772a --- /dev/null +++ b/test/packages/service/dfx/real_unit_pay_service_test.dart @@ -0,0 +1,393 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/config/network_mode.dart'; +import 'package:realunit_wallet/packages/repository/cache_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_request_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/packages/service/session_cache.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; +import 'package:web3dart/web3dart.dart'; + +class _MockAppStore extends Mock implements AppStore {} + +class _MockWallet extends Mock implements AWallet {} + +class _MockAccount extends Mock implements AWalletAccount {} + +class _MockCacheRepository extends Mock implements CacheRepository {} + +class _MockWalletService extends Mock implements WalletService {} + +class _StubCreds extends Fake implements CredentialsWithKnownAddress { + @override + EthereumAddress get address => + EthereumAddress.fromHex('0x0000000000000000000000000000000000000001'); +} + +Map _swapInfoJson() => { + 'id': 99, + 'uid': 'MOCK-UID', + 'routeId': 7, + 'timestamp': '2026-06-03T00:00:00.000Z', + 'amount': 10, + 'estimatedAmount': 960, + 'targetAsset': 'ZCHF', + 'fees': {'dfx': 1, 'network': 0.5, 'total': 1.5}, + 'minVolume': 1, + 'maxVolume': 1000, + 'minVolumeTarget': 95, + 'maxVolumeTarget': 95000, + 'ethBalance': 1.0, + 'requiredGasEth': 0.001, + 'isValid': true, +}; + +void main() { + late _MockAppStore appStore; + late _MockWallet wallet; + late _MockAccount account; + late _MockWalletService walletService; + late SessionCache session; + + setUp(() { + appStore = _MockAppStore(); + wallet = _MockWallet(); + account = _MockAccount(); + walletService = _MockWalletService(); + session = SessionCache(_MockCacheRepository()); + session.setAuthToken('jwt-1'); + + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.sessionCache).thenReturn(session); + when(() => appStore.wallet).thenReturn(wallet); + when(() => wallet.currentAccount).thenReturn(account); + when(() => account.primaryAddress).thenReturn(_StubCreds()); + }); + + RealUnitPayService build(http.Client client) { + when(() => appStore.httpClient).thenReturn(client); + return RealUnitPayService(appStore, walletService); + } + + group('getPaymentDetails', () { + test('GETs the public lnurlp endpoint (no auth header) and parses it', () async { + Uri? sentUri; + Map? headers; + final client = MockClient((request) async { + sentUri = request.url; + headers = request.headers; + return http.Response( + jsonEncode({ + 'requestedAmount': {'asset': 'CHF', 'amount': 42.5}, + 'quote': {'id': 'quote_xyz', 'expiration': '2026-06-03T12:00:00.000Z'}, + 'transferAmounts': [ + { + 'method': 'Ethereum', + 'assets': [ + {'asset': 'ZCHF', 'amount': 42.7}, + ], + }, + ], + }), + 200, + ); + }); + + final dto = await build(client).getPaymentDetails('pl_abc'); + + expect(sentUri!.path, '/v1/lnurlp/pl_abc'); + expect(headers!.containsKey('Authorization'), isFalse); + expect(dto.quote.id, 'quote_xyz'); + expect(dto.transferAmounts.first.assets.first.amount, 42.7); + }); + + test('non-200 → ApiException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 404, 'message': 'gone'}), 404), + ); + expect( + () => build(client).getPaymentDetails('pl_abc'), + throwsA(isA()), + ); + }); + }); + + group('getSwapPaymentInfo', () { + test('PUTs /swap with targetAmount and parses the quote', () async { + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response(jsonEncode(_swapInfoJson()), 200); + }); + + final info = await build( + client, + ).getSwapPaymentInfo(const RealUnitSwapDto.fromTargetAmount(95.5)); + + expect(sentUri!.path, '/v1/realunit/swap'); + expect(body!['targetAmount'], 95.5); + expect(body!.containsKey('amount'), isFalse); + expect(info.id, 99); + expect(info.estimatedAmount, 960); + expect(info.isValid, isTrue); + }); + + test('non-200 → ApiException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'bad'}), 400), + ); + expect( + () => build(client).getSwapPaymentInfo(const RealUnitSwapDto.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', () { + test('PUTs /pay/unsigned-transaction with the payment refs', () async { + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode({ + 'unsignedTx': '0xtx', + 'tokenAddress': '0xzchf', + 'recipient': '0xrecipient', + 'amountWei': '5000000000000000000', + 'chainId': 1, + }), + 200, + ); + }); + + final dto = await build(client).createPayUnsignedTransaction( + const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q1'), + ); + + expect(sentUri!.path, '/v1/realunit/pay/unsigned-transaction'); + expect(body!['paymentLinkId'], 'pl_abc'); + expect(body!['quoteId'], 'q1'); + expect(dto.recipient, '0xrecipient'); + expect(dto.amountWei, '5000000000000000000'); + }); + + test('400 (mainnet-only fail-fast on testnet) → ApiException', () async { + final client = MockClient( + (_) async => + http.Response(jsonEncode({'statusCode': 400, 'message': 'unsupported method'}), 400), + ); + expect( + () => build(client).createPayUnsignedTransaction( + const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q1'), + ), + throwsA(isA()), + ); + }); + }); + + group('submitPay', () { + test('PUTs /pay/submit and returns txId', () async { + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'txId': '0xTxId'}), 200); + }); + + final txId = await build(client).submitPay( + const RealUnitOcpPaySubmitDto( + unsignedTx: '0xtx', + r: '0xr', + s: '0xs', + v: 27, + paymentLinkId: 'pl_abc', + quoteId: 'q1', + ), + ); + + expect(sentUri!.path, '/v1/realunit/pay/submit'); + expect(body!['paymentLinkId'], 'pl_abc'); + expect(txId, '0xTxId'); + }); + + test('non-200 → ApiException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'fail'}), 400), + ); + expect( + () => build(client).submitPay( + const RealUnitOcpPaySubmitDto( + unsignedTx: '0xtx', + r: '0xr', + s: '0xs', + v: 27, + paymentLinkId: 'pl_abc', + quoteId: 'q1', + ), + ), + throwsA(isA()), + ); + }); + }); + + group('isPaySupportedEnvironment (up-front capability gate)', () { + test('is true on mainnet', () { + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + final client = MockClient((_) async => http.Response('{}', 200)); + expect(build(client).isPaySupportedEnvironment, isTrue); + }); + + test('is false on testnet', () { + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet)); + final client = MockClient((_) async => http.Response('{}', 200)); + expect(build(client).isPaySupportedEnvironment, isFalse); + }); + }); + + group('testnet fail-fast (mainnet-only OCP settlement)', () { + test('createPayUnsignedTransaction throws PayUnsupportedEnvironmentException', () async { + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet)); + var clientCalled = false; + final client = MockClient((_) async { + clientCalled = true; + return http.Response('{}', 200); + }); + + await expectLater( + build(client).createPayUnsignedTransaction( + const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q1'), + ), + throwsA(isA()), + ); + expect(clientCalled, isFalse); + }); + + test('submitPay throws PayUnsupportedEnvironmentException without a round-trip', () async { + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet)); + var clientCalled = false; + final client = MockClient((_) async { + clientCalled = true; + return http.Response('{}', 200); + }); + + await expectLater( + build(client).submitPay( + const RealUnitOcpPaySubmitDto( + unsignedTx: '0xtx', + r: '0xr', + s: '0xs', + v: 27, + paymentLinkId: 'pl_abc', + quoteId: 'q1', + ), + ), + throwsA(isA()), + ); + expect(clientCalled, isFalse); + }); + }); + + group('getPayStatus', () { + test('GETs /pay/:id/status and parses the status', () async { + Uri? sentUri; + 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/packages/service/dfx/real_unit_transfer_service_test.dart b/test/packages/service/dfx/real_unit_transfer_service_test.dart new file mode 100644 index 000000000..8c9975d14 --- /dev/null +++ b/test/packages/service/dfx/real_unit_transfer_service_test.dart @@ -0,0 +1,342 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/config/network_mode.dart'; +import 'package:realunit_wallet/packages/repository/cache_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/packages/service/session_cache.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; +import 'package:web3dart/web3dart.dart'; + +class _MockAppStore extends Mock implements AppStore {} + +class _MockWallet extends Mock implements AWallet {} + +class _MockAccount extends Mock implements AWalletAccount {} + +class _MockCacheRepository extends Mock implements CacheRepository {} + +class _MockWalletService extends Mock implements WalletService {} + +// Deterministic test private key — a real EthPrivateKey credential the +// EIP-712 / EIP-7702 signers accept directly (they reject anything that isn't +// BitboxCredentials or EthPrivateKey). +const _testPrivateKeyHex = 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612'; + +final _privKey = EthPrivateKey.fromHex(_testPrivateKeyHex); +final _walletAddress = _privKey.address.hexEip55; + +const _metaMaskDelegator = '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b'; +const _delegationManager = '0xdb9b1e94b5b69df7e401ddbede43491141047db3'; + +/// Debug-wallet style credential: it is neither BitboxCredentials nor +/// EthPrivateKey, so `Eip712Signer.signDelegation` hits its `_ => throw +/// UnsupportedError(...)` branch — exactly the debug-wallet capability gap. +class _UnsupportedCreds extends Fake implements CredentialsWithKnownAddress { + @override + EthereumAddress get address => _privKey.address; +} + +Map _eip7702Json({int chainId = 1}) => { + 'relayerAddress': '0xrelay', + 'delegationManagerAddress': _delegationManager, + 'delegatorAddress': _metaMaskDelegator, + 'userNonce': 7, + 'domain': { + 'name': 'RealUnit', + 'version': '1', + 'chainId': chainId, + 'verifyingContract': _delegationManager, + }, + 'types': { + 'Delegation': >[], + 'Caveat': >[], + }, + 'message': { + 'delegate': '0xrelay', + 'delegator': _walletAddress, + 'authority': '0xauth', + 'caveats': >[], + 'salt': 0, + }, + // The asset address the mainnet RealUnit token resolves to in this fixture. + 'tokenAddress': '0x553C7f9C780316FC1D34b8e14ac2465Ab22a090B', + 'amountWei': '5', + 'recipient': '0xRecipient', +}; + +RealUnitTransferPaymentInfoDto _info() => RealUnitTransferPaymentInfoDto.fromJson({ + 'id': 42, + 'uid': 'RTabc', + 'toAddress': '0xRecipient', + 'amount': 5, + 'tokenAddress': '0xRealu', + 'chainId': 1, + 'eip7702': _eip7702Json(), +}); + +void main() { + late _MockAppStore appStore; + late _MockWallet wallet; + late _MockAccount account; + late _MockWalletService walletService; + late SessionCache session; + + setUp(() { + appStore = _MockAppStore(); + wallet = _MockWallet(); + account = _MockAccount(); + walletService = _MockWalletService(); + session = SessionCache(_MockCacheRepository()); + session.setAuthToken('jwt-1'); + + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.sessionCache).thenReturn(session); + when(() => appStore.wallet).thenReturn(wallet); + when(() => wallet.currentAccount).thenReturn(account); + when(() => account.primaryAddress).thenReturn(_privKey); + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); + when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); + }); + + RealUnitTransferService build(http.Client client) { + when(() => appStore.httpClient).thenReturn(client); + return RealUnitTransferService(appStore, walletService); + } + + group('prepareTransfer', () { + test('200 → parses the payment-info DTO and PUTs toAddress + amount', () async { + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode({ + 'id': 42, + 'uid': 'RTabc', + 'toAddress': '0xRecipient', + 'amount': 5, + 'tokenAddress': '0xRealu', + 'chainId': 1, + 'eip7702': _eip7702Json(), + }), + 200, + ); + }); + + final info = await build(client).prepareTransfer( + const RealUnitTransferDto(toAddress: '0xRecipient', amount: 5), + ); + + expect(sentUri!.path, '/v1/realunit/transfer'); + expect(body, {'toAddress': '0xRecipient', 'amount': 5}); + expect(info.id, 42); + expect(info.eip7702.recipient, '0xRecipient'); + }); + + test('503 → TransferGasFundingUnavailableException', () async { + final client = MockClient( + (_) async => http.Response( + jsonEncode({'statusCode': 503, 'message': 'W2W gas funding temporarily unavailable'}), + 503, + ), + ); + + expect( + () => build(client).prepareTransfer( + const RealUnitTransferDto(toAddress: '0xRecipient', amount: 5), + ), + throwsA(isA()), + ); + }); + + test('400 (invalid recipient / insufficient REALU) → ApiException', () async { + final client = MockClient( + (_) async => http.Response( + jsonEncode({'statusCode': 400, 'code': 'X', 'message': 'Invalid recipient address'}), + 400, + ), + ); + + expect( + () => build(client).prepareTransfer( + const RealUnitTransferDto(toAddress: 'bad', amount: 5), + ), + throwsA(isA()), + ); + }); + }); + + group('confirmTransfer (software wallet happy path)', () { + test('signs delegation + authorization, PUTs the envelope, returns txHash', () async { + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'txHash': '0xdeadbeef'}), 200); + }); + + final txHash = await build(client).confirmTransfer(_info()); + + expect(txHash, '0xdeadbeef'); + expect(sentUri!.path, '/v1/realunit/transfer/42/confirm'); + + final envelope = body!; + expect(envelope.containsKey('delegation'), isTrue); + expect(envelope.containsKey('authorization'), isTrue); + + final delegation = envelope['delegation'] as Map; + expect(delegation['delegate'], '0xrelay'); + expect(delegation['delegator'], _walletAddress); + expect(delegation['authority'], '0xauth'); + expect(delegation['salt'], '0'); + expect((delegation['signature'] as String).length, 132); + + final authorization = envelope['authorization'] as Map; + expect(authorization['chainId'], 1); + expect(authorization['address'], _metaMaskDelegator); + expect(authorization['nonce'], 7); + // r/s are always full 32-byte (64 hex char) big-endian values. + expect((authorization['r'] as String).substring(2).length, 64); + expect((authorization['s'] as String).substring(2).length, 64); + expect(authorization['yParity'], anyOf(0, 1)); + }); + + test('locks the wallet after signing (key never left resident)', () async { + final client = MockClient((_) async => http.Response(jsonEncode({'txHash': '0x1'}), 200)); + + await build(client).confirmTransfer(_info()); + + verify(() => walletService.ensureCurrentWalletUnlocked()).called(1); + verify(() => walletService.lockCurrentWallet()).called(1); + }); + + test('debug-wallet credentials → TransferSignatureUnsupportedException', () async { + when(() => account.primaryAddress).thenReturn(_UnsupportedCreds()); + final client = MockClient((_) async => http.Response('{}', 200)); + + expect( + () => build(client).confirmTransfer(_info()), + throwsA(isA()), + ); + }); + + test('503 on confirm → TransferGasFundingUnavailableException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'message': 'unavailable'}), 503), + ); + + expect( + () => build(client).confirmTransfer(_info()), + throwsA(isA()), + ); + }); + + test('4xx on confirm → ApiException', () async { + final client = MockClient( + (_) async => + http.Response(jsonEncode({'statusCode': 409, 'code': 'X', 'message': 'no'}), 409), + ); + + expect( + () => build(client).confirmTransfer(_info()), + throwsA(isA()), + ); + }); + + // Every pinned contract/field is rejected before signing, mirroring the + // sell software-confirm guard. Each case mutates one field of the otherwise + // valid eip7702 payload and asserts validation throws WITHOUT any PUT. + group('eip7702 validation pins (throw before any PUT)', () { + RealUnitTransferPaymentInfoDto infoWith(Map Function() mutate) => + RealUnitTransferPaymentInfoDto.fromJson({ + 'id': 42, + 'uid': 'RTabc', + 'toAddress': '0xRecipient', + 'amount': 5, + 'tokenAddress': '0xRealu', + 'chainId': 1, + 'eip7702': mutate(), + }); + + Future expectRejected(RealUnitTransferPaymentInfoDto info) async { + var called = false; + final client = MockClient((_) async { + called = true; + return http.Response('{}', 200); + }); + await expectLater(() => build(client).confirmTransfer(info), throwsException); + expect(called, isFalse, reason: 'no PUT must happen when validation rejects the payload'); + } + + test('wrong delegator (MetaMask delegator) contract', () async { + await expectRejected(infoWith(() => _eip7702Json()..['delegatorAddress'] = '0xWrong')); + }); + + test('wrong delegation manager contract', () async { + await expectRejected( + infoWith(() => _eip7702Json()..['delegationManagerAddress'] = '0xWrong'), + ); + }); + + test('verifying contract != delegation manager', () async { + await expectRejected( + infoWith(() { + final json = _eip7702Json(); + (json['domain'] as Map)['verifyingContract'] = '0xWrong'; + return json; + }), + ); + }); + + test('message delegator != wallet address', () async { + await expectRejected( + infoWith(() { + final json = _eip7702Json(); + (json['message'] as Map)['delegator'] = '0xSomeoneElse'; + return json; + }), + ); + }); + + test('chain id mismatch', () async { + await expectRejected(infoWith(() => _eip7702Json(chainId: 999))); + }); + + test('message delegate != relayer address', () async { + await expectRejected( + infoWith(() { + final json = _eip7702Json(); + (json['message'] as Map)['delegate'] = '0xOtherRelayer'; + return json; + }), + ); + }); + + test('token address != RealUnit token', () async { + await expectRejected(infoWith(() => _eip7702Json()..['tokenAddress'] = '0xNotRealu')); + }); + + test('amount-wei mismatch', () async { + await expectRejected(infoWith(() => _eip7702Json()..['amountWei'] = '6')); + }); + + test('unparseable amount-wei', () async { + await expectRejected(infoWith(() => _eip7702Json()..['amountWei'] = 'not-a-number')); + }); + }); + }); +} diff --git a/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart new file mode 100644 index 000000000..0192a2e36 --- /dev/null +++ b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/dashboard/widgets/sections/dashboard_actions.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; +import 'package:realunit_wallet/widgets/action_button.dart'; + +void main() { + late List pushedRoutes; + + setUp(() { + pushedRoutes = []; + }); + + // Routes the four action buttons can push. Each target records the pushed + // route name so the `onPressed` closures are both executed and asserted, + // instead of only painted. + GoRouter buildRouter() { + GoRoute target(String name, String path) => GoRoute( + name: name, + path: path, + builder: (_, _) { + pushedRoutes.add(name); + return Scaffold(body: Text('ROUTE:$name')); + }, + ); + + return GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const Scaffold(body: DashboardActions()), + ), + target(AppRoutes.buy, '/buy'), + target(AppRoutes.sell, '/sell'), + target(AppRoutes.pay, '/pay'), + target(AppRoutes.send, '/send'), + ], + ); + } + + Future pumpActions(WidgetTester tester) async { + final router = buildRouter(); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ); + await tester.pumpAndSettle(); + } + + Finder actionButtonByLabel(String label) => find.byWidgetPredicate( + (w) => w is ActionButton && w.label == label, + ); + + group('$DashboardActions', () { + testWidgets('renders the buy, sell, pay and send action buttons', (tester) async { + await pumpActions(tester); + + expect(actionButtonByLabel(S.current.buy), findsOneWidget); + expect(actionButtonByLabel(S.current.sell), findsOneWidget); + expect(actionButtonByLabel(S.current.pay), findsOneWidget); + expect(actionButtonByLabel(S.current.send), findsOneWidget); + // Each button is laid out inside an Expanded so the row divides the + // available width into four equal slots. + expect(find.byType(Expanded), findsNWidgets(4)); + }); + + testWidgets('renders the expected icons for each action', (tester) async { + await pumpActions(tester); + + expect(find.byIcon(Icons.add_circle_rounded), findsOneWidget); + expect(find.byIcon(Icons.do_not_disturb_on_rounded), findsOneWidget); + expect(find.byIcon(Icons.qr_code_scanner_rounded), findsOneWidget); + expect(find.byIcon(Icons.send_rounded), findsOneWidget); + }); + + testWidgets('buy button pushes the buy route', (tester) async { + await pumpActions(tester); + + await tester.tap(actionButtonByLabel(S.current.buy)); + await tester.pumpAndSettle(); + + expect(pushedRoutes, [AppRoutes.buy]); + }); + + testWidgets('sell button pushes the sell route', (tester) async { + await pumpActions(tester); + + await tester.tap(actionButtonByLabel(S.current.sell)); + await tester.pumpAndSettle(); + + expect(pushedRoutes, [AppRoutes.sell]); + }); + + testWidgets('pay button pushes the pay route', (tester) async { + await pumpActions(tester); + + await tester.tap(actionButtonByLabel(S.current.pay)); + await tester.pumpAndSettle(); + + expect(pushedRoutes, [AppRoutes.pay]); + }); + + testWidgets('send button pushes the send route', (tester) async { + await pumpActions(tester); + + await tester.tap(actionButtonByLabel(S.current.send)); + await tester.pumpAndSettle(); + + expect(pushedRoutes, [AppRoutes.send]); + }); + }); +} diff --git a/test/screens/pay/pay_process_cubit_test.dart b/test/screens/pay/pay_process_cubit_test.dart new file mode 100644 index 000000000..21027f0d5 --- /dev/null +++ b/test/screens/pay/pay_process_cubit_test.dart @@ -0,0 +1,652 @@ +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'); + // Default: the environment can settle OCP (mainnet). The up-front gate in + // start() reads this before any on-chain action. + when(() => payService.isPaySupportedEnvironment).thenReturn(true); + when(() => appStore.wallet).thenReturn(wallet); + when(() => wallet.walletType).thenReturn(WalletType.software); + when(() => wallet.currentAccount).thenReturn(account); + 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('unsupported environment → fails BEFORE any swap (no on-chain action)', () async { + wireHappyPath(); + when(() => payService.isPaySupportedEnvironment).thenReturn(false); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as PayProcessFailure; + expect(state.reason, PayProcessFailureReason.payUnsupportedEnvironment); + // The irreversible swap must never run on an unsupported environment. + verifyNever(() => payService.getSwapPaymentInfo(any())); + verifyNever(() => payService.createSwapUnsignedTransaction(any())); + verifyNever(() => payService.broadcastSwapTransaction(any(), any())); + await cubit.close(); + }); + + test('BitBox disconnect during the pay sign (after swap) → pay-only retry', () async { + wireHappyPath(); + // First sign (swap) succeeds; the second sign (pay) reports a dropped BLE + // 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 000000000..291221dd8 --- /dev/null +++ b/test/screens/pay/pay_process_page_test.dart @@ -0,0 +1,278 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_process_page.dart'; + +import '../../helper/helper.dart'; + +class _MockPayProcessCubit extends MockCubit implements PayProcessCubit {} + +class _MockPayService extends Mock implements RealUnitPayService {} + +class _MockFaucetService extends Mock implements DfxFaucetService {} + +class _MockBlockchainService extends Mock implements DfxBlockchainApiService {} + +class _MockWalletService extends Mock implements WalletService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +class _MockWallet extends Mock implements SoftwareWallet {} + +void main() { + late _MockPayProcessCubit processCubit; + + setUpAll(() { + final getIt = GetIt.instance; + // PayProcessPage resolves a full service graph from getIt and calls + // start(). A debug wallet makes start() settle immediately + // (signatureUnsupported) without touching the chain. + final payService = _MockPayService(); + when(() => payService.isPaySupportedEnvironment).thenReturn(true); + getIt.registerSingleton(payService); + getIt.registerSingleton(_MockFaucetService()); + getIt.registerSingleton(_MockBlockchainService()); + getIt.registerSingleton(_MockWalletService()); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + final wallet = _MockWallet(); + when(() => wallet.walletType).thenReturn(WalletType.debug); + when(() => appStore.wallet).thenReturn(wallet); + when(() => appStore.apiConfig).thenReturn(apiConfig); + getIt.registerSingleton(appStore); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + processCubit = _MockPayProcessCubit(); + when(() => processCubit.state).thenReturn(const PayProcessInitial()); + when(() => processCubit.retryPay()).thenAnswer((_) async {}); + }); + + Widget buildSubject() => BlocProvider.value( + value: processCubit, + child: const PayProcessView(), + ); + + group('$PayProcessPage', () { + testWidgets('builds its own cubit and renders $PayProcessView', (tester) async { + await tester.pumpApp(const PayProcessPage(paymentLinkId: 'pl_abc', zchfNeeded: 42.7)); + // start() runs and emits a failure on the debug wallet; pump a frame to + // let the cubit settle (the sheet animation is not awaited here). + await tester.pump(); + + expect(find.byType(PayProcessView), findsOne); + }); + }); + + group('$PayProcessView progress labels', () { + Future expectLabel(WidgetTester tester, PayProcessState state, String label) async { + when(() => processCubit.state).thenReturn(state); + await tester.pumpApp(buildSubject()); + + expect(find.byType(CupertinoActivityIndicator), findsOne); + expect(find.text(label), findsOne); + } + + testWidgets('initial shows preparing-swap', (tester) async { + await expectLabel(tester, const PayProcessInitial(), S.current.payPreparingSwap); + }); + + testWidgets('preparing-swap label', (tester) async { + await expectLabel(tester, const PayProcessPreparingSwap(), S.current.payPreparingSwap); + }); + + testWidgets('waiting-for-eth label', (tester) async { + await expectLabel(tester, const PayProcessWaitingForEth(), S.current.payWaitingForEth); + }); + + testWidgets('swapping label', (tester) async { + await expectLabel(tester, const PayProcessSwapping(), S.current.paySwapping); + }); + + testWidgets('refreshing-quote label', (tester) async { + await expectLabel(tester, const PayProcessRefreshingQuote(), S.current.payRefreshingQuote); + }); + + testWidgets('paying label', (tester) async { + await expectLabel(tester, const PayProcessPaying(), S.current.payPaying); + }); + + testWidgets('awaiting-settlement label', (tester) async { + await expectLabel( + tester, + const PayProcessAwaitingSettlement('0xtx'), + S.current.payAwaitingSettlement, + ); + }); + + testWidgets('success label', (tester) async { + await expectLabel(tester, const PayProcessSuccess(), S.current.paySuccess); + }); + + testWidgets('pay-retry label', (tester) async { + await expectLabel( + tester, + const PayProcessPayRetry(PayRetryReason.quoteExpired), + S.current.payRetryTitle, + ); + }); + + testWidgets('failure label', (tester) async { + await expectLabel( + tester, + const PayProcessFailure(PayProcessFailureReason.generic), + S.current.payFailureTitle, + ); + }); + }); + + // The result/retry sheets are modal bottom sheets shown from the listener. + // The PayProcessView keeps a CupertinoActivityIndicator animating behind the + // sheet, so pumpAndSettle never settles; pump fixed frames to open the sheet. + // A phone-sized surface keeps the taller retry sheet from overflowing the + // default 800x600 test viewport (mirrors the logout-sheet test convention). + Future pumpWithState(WidgetTester tester, PayProcessState terminal) async { + tester.view.physicalSize = const Size(1200, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + whenListen( + processCubit, + Stream.fromIterable([terminal]), + initialState: const PayProcessSwapping(), + ); + await tester.pumpApp(buildSubject()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + } + + group('$PayProcessView result sheet', () { + testWidgets('success emits a success sheet with title + description', (tester) async { + await pumpWithState(tester, const PayProcessSuccess()); + + expect(find.text(S.current.paySuccessDescription), findsOne); + expect(find.byIcon(Icons.check_circle_rounded), findsOne); + expect(find.text(S.current.close), findsOne); + + // Tapping close pops the sheet and then pops the page. + await tester.tap(find.text(S.current.close)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byIcon(Icons.check_circle_rounded), findsNothing); + }); + + testWidgets('insufficient-zchf failure emits a failure sheet', (tester) async { + await pumpWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.insufficientZchf), + ); + + // payFailureTitle also renders as the progress-label behind the sheet, + // so it appears twice; the reason message is the sheet-unique assertion. + expect(find.text(S.current.payFailureTitle), findsWidgets); + expect(find.text(S.current.payFailureInsufficientZchf), findsOne); + expect(find.byIcon(Icons.error_rounded), findsOne); + }); + + testWidgets('insufficient-eth failure message', (tester) async { + await pumpWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.insufficientEth), + ); + + expect(find.text(S.current.payFailureInsufficientEth), findsOne); + }); + + testWidgets('unsupported-environment failure message', (tester) async { + await pumpWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.payUnsupportedEnvironment), + ); + + expect(find.text(S.current.payFailureUnsupportedEnvironment), findsOne); + }); + + testWidgets('signature-unsupported failure message', (tester) async { + await pumpWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.signatureUnsupported), + ); + + expect(find.text(S.current.payFailureSignatureUnsupported), findsOne); + }); + + testWidgets('bitbox-required failure message', (tester) async { + await pumpWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.bitboxRequired), + ); + + expect(find.text(S.current.payFailureBitboxRequired), findsOne); + }); + + testWidgets('generic failure message', (tester) async { + await pumpWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.generic), + ); + + expect(find.text(S.current.payFailureGeneric), findsOne); + }); + }); + + group('$PayProcessView retry sheet', () { + testWidgets('pay-retry emits a retry sheet whose primary action calls retryPay', ( + tester, + ) async { + await pumpWithState(tester, const PayProcessPayRetry(PayRetryReason.quoteExpired)); + + expect(find.text(S.current.payRetryQuoteExpired), findsOne); + expect(find.byIcon(Icons.replay_rounded), findsOne); + + await tester.tap(find.text(S.current.payRetryButton)); + await tester.pump(); + + verify(() => processCubit.retryPay()).called(1); + }); + + testWidgets('retry sheet close action dismisses without retrying', (tester) async { + await pumpWithState(tester, const PayProcessPayRetry(PayRetryReason.transient)); + + expect(find.text(S.current.payRetryTransient), findsOne); + + await tester.tap(find.text(S.current.close)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + verifyNever(() => processCubit.retryPay()); + expect(find.text(S.current.payRetryTransient), findsNothing); + }); + + testWidgets('insufficient-zchf retry reason shows its message', (tester) async { + await pumpWithState(tester, const PayProcessPayRetry(PayRetryReason.insufficientZchf)); + + expect(find.text(S.current.payRetryInsufficientZchf), findsOne); + }); + }); +} diff --git a/test/screens/pay/pay_process_state_test.dart b/test/screens/pay/pay_process_state_test.dart new file mode 100644 index 000000000..354720b60 --- /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 000000000..d0579eea5 --- /dev/null +++ b/test/screens/pay/pay_quote_cubit_test.dart @@ -0,0 +1,114 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart'; + +class _MockPayService extends Mock implements RealUnitPayService {} + +LnurlpPaymentDto _details({ + required DateTime expiration, + bool withEthZchf = true, + double zchf = 42.7, +}) { + return LnurlpPaymentDto( + requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 42.5), + quote: LnurlpQuoteDto(id: 'quote_xyz', expiration: expiration), + 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(); + // Default: the environment can settle OCP (mainnet). load() checks this + // up-front before fetching the quote. + when(() => payService.isPaySupportedEnvironment).thenReturn(true); + }); + + PayQuoteCubit build() => PayQuoteCubit(payService, 'pl_abc'); + + blocTest( + 'an unsupported environment emits PayQuoteUnsupportedEnvironment without fetching', + build: build, + setUp: () { + when(() => payService.isPaySupportedEnvironment).thenReturn(false); + }, + act: (cubit) => cubit.load(), + expect: () => [isA(), isA()], + verify: (_) => verifyNever(() => payService.getPaymentDetails(any())), + ); + + blocTest( + 'a fresh quote with an Ethereum/ZCHF method emits PayQuoteReady', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => _details(expiration: DateTime.now().add(const Duration(minutes: 5))), + ); + }, + act: (cubit) => cubit.load(), + expect: () => [isA(), isA()], + verify: (cubit) { + final state = cubit.state as PayQuoteReady; + expect(state.quoteId, 'quote_xyz'); + expect(state.fiatAsset, 'CHF'); + expect(state.fiatAmount, 42.5); + expect(state.zchfAmount, 42.7); + }, + ); + + blocTest( + 'an expired quote emits PayQuoteExpired', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => _details(expiration: DateTime.now().subtract(const Duration(minutes: 1))), + ); + }, + act: (cubit) => cubit.load(), + expect: () => [isA(), isA()], + ); + + blocTest( + 'a link without an Ethereum/ZCHF method emits PayQuoteUnavailable', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => _details( + expiration: DateTime.now().add(const Duration(minutes: 5)), + withEthZchf: false, + ), + ); + }, + act: (cubit) => cubit.load(), + expect: () => [isA(), isA()], + ); + + blocTest( + 'a service error emits PayQuoteError', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_abc')).thenThrow( + const ApiException(code: 'X', message: 'boom'), + ); + }, + act: (cubit) => cubit.load(), + expect: () => [isA(), isA()], + ); +} diff --git a/test/screens/pay/pay_quote_page_test.dart b/test/screens/pay/pay_quote_page_test.dart new file mode 100644 index 000000000..55d8508bf --- /dev/null +++ b/test/screens/pay/pay_quote_page_test.dart @@ -0,0 +1,154 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_process_page.dart'; +import 'package:realunit_wallet/screens/pay/pay_quote_page.dart'; + +import '../../helper/helper.dart'; + +class _MockPayQuoteCubit extends MockCubit implements PayQuoteCubit {} + +class _MockPayService extends Mock implements RealUnitPayService {} + +class _MockFaucetService extends Mock implements DfxFaucetService {} + +class _MockBlockchainService extends Mock implements DfxBlockchainApiService {} + +class _MockWalletService extends Mock implements WalletService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +class _MockWallet extends Mock implements SoftwareWallet {} + +void main() { + late _MockPayQuoteCubit quoteCubit; + + const ready = PayQuoteReady( + paymentLinkId: 'pl_abc', + quoteId: 'quote_xyz', + fiatAsset: 'CHF', + fiatAmount: 42.5, + zchfAmount: 42.7, + ); + + setUpAll(() { + final getIt = GetIt.instance; + + // PayQuotePage resolves the pay service from getIt and calls load(); an + // unsupported environment short-circuits load() without any network. + final payService = _MockPayService(); + when(() => payService.isPaySupportedEnvironment).thenReturn(false); + getIt.registerSingleton(payService); + + // The confirm button pushes PayProcessPage, which resolves a full service + // graph from getIt and calls start(). A debug wallet makes start() settle + // immediately (signatureUnsupported) without touching the chain. + getIt.registerSingleton(_MockFaucetService()); + getIt.registerSingleton(_MockBlockchainService()); + getIt.registerSingleton(_MockWalletService()); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + final wallet = _MockWallet(); + when(() => wallet.walletType).thenReturn(WalletType.debug); + when(() => appStore.wallet).thenReturn(wallet); + when(() => appStore.apiConfig).thenReturn(apiConfig); + getIt.registerSingleton(appStore); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + quoteCubit = _MockPayQuoteCubit(); + when(() => quoteCubit.state).thenReturn(const PayQuoteLoading()); + }); + + Widget buildSubject() => BlocProvider.value( + value: quoteCubit, + child: const PayQuoteView(), + ); + + group('$PayQuotePage', () { + testWidgets('builds its own cubit and renders $PayQuoteView', (tester) async { + await tester.pumpApp(const PayQuotePage(paymentLinkId: 'pl_abc')); + + expect(find.byType(PayQuoteView), findsOne); + }); + }); + + group('$PayQuoteView', () { + testWidgets('loading state shows a $CupertinoActivityIndicator', (tester) async { + when(() => quoteCubit.state).thenReturn(const PayQuoteLoading()); + await tester.pumpApp(buildSubject()); + + expect(find.byType(CupertinoActivityIndicator), findsOne); + }); + + testWidgets('ready state shows the CHF amount, ZCHF needed and confirm button', (tester) async { + when(() => quoteCubit.state).thenReturn(ready); + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payQuoteSummary('42.50', 'CHF')), findsOne); + expect(find.text('42.50 CHF'), findsOne); + expect(find.text('42.70 ZCHF'), findsOne); + expect(find.text(S.current.payConfirmButton), findsOne); + }); + + testWidgets('confirm button navigates to the process step', (tester) async { + when(() => quoteCubit.state).thenReturn(ready); + await tester.pumpApp(buildSubject()); + + await tester.tap(find.text(S.current.payConfirmButton)); + // The process page renders a CupertinoActivityIndicator that animates + // forever, so pumpAndSettle would time out; pump fixed frames to drive + // the push transition instead. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byType(PayProcessView), findsOne); + }); + + testWidgets('expired state shows the re-scan message', (tester) async { + when(() => quoteCubit.state).thenReturn(const PayQuoteExpired()); + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payFailureQuoteExpired), findsOne); + }); + + testWidgets('unavailable state shows the unavailable message', (tester) async { + when(() => quoteCubit.state).thenReturn(const PayQuoteUnavailable()); + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payQuoteUnavailable), findsOne); + }); + + testWidgets('unsupported-environment state shows the environment message', (tester) async { + when(() => quoteCubit.state).thenReturn(const PayQuoteUnsupportedEnvironment()); + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payFailureUnsupportedEnvironment), findsOne); + }); + + testWidgets('error state shows the generic failure message', (tester) async { + when(() => quoteCubit.state).thenReturn(const PayQuoteError('boom')); + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payFailureGeneric), findsOne); + }); + }); +} diff --git a/test/screens/pay/pay_scan_cubit_test.dart b/test/screens/pay/pay_scan_cubit_test.dart new file mode 100644 index 000000000..5d17648f2 --- /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 000000000..15b6da803 --- /dev/null +++ b/test/screens/pay/pay_scan_page_test.dart @@ -0,0 +1,119 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/lnurl_decoder.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_quote_page.dart'; +import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; + +import '../../helper/helper.dart'; + +class _MockPayScanCubit extends MockCubit implements PayScanCubit {} + +class _MockPayService extends Mock implements RealUnitPayService {} + +void main() { + late _MockPayScanCubit scanCubit; + + setUpAll(() { + // pay_scan_page.dart carries the `@no-integration-test` note: the live + // camera is exercised only on a real device. The stub keeps the headless + // preview deterministic and free of MissingPluginException. + stubMobileScannerChannel(); + + // The decoded-link navigation pushes PayQuotePage, which resolves the pay + // service from getIt and triggers a load(); register a mock so the pushed + // route builds. The load is gated off via an unsupported environment so no + // network is touched. + final payService = _MockPayService(); + when(() => payService.isPaySupportedEnvironment).thenReturn(false); + GetIt.instance.registerSingleton(payService); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + scanCubit = _MockPayScanCubit(); + when(() => scanCubit.state).thenReturn(const PayScanScanning()); + when(() => scanCubit.reset()).thenReturn(null); + }); + + Widget buildSubject() => BlocProvider.value( + value: scanCubit, + child: const PayScanView(), + ); + + group('$PayScanPage', () { + testWidgets('builds its own cubit and renders $PayScanView', (tester) async { + await tester.pumpApp(const PayScanPage()); + + expect(find.byType(PayScanView), findsOne); + }); + }); + + group('$PayScanView', () { + testWidgets('renders the scan title and the scanner preview', (tester) async { + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payScanTitle), findsOne); + expect(find.byType(MobileScanner), findsOne); + }); + + testWidgets('onDetect forwards a scanned raw value to the cubit', (tester) async { + when(() => scanCubit.onCodeDetected(any())).thenReturn(null); + + await tester.pumpApp(buildSubject()); + + final scanner = tester.widget(find.byType(MobileScanner)); + // A capture with no barcodes is ignored (rawValue is null) … + scanner.onDetect!(const BarcodeCapture()); + // … while a barcode with a raw value is forwarded to the cubit. + scanner.onDetect!( + const BarcodeCapture(barcodes: [Barcode(rawValue: 'lnurl_raw')]), + ); + + verify(() => scanCubit.onCodeDetected('lnurl_raw')).called(1); + }); + + testWidgets('an invalid scan shows a snackbar and resets the cubit', (tester) async { + whenListen( + scanCubit, + Stream.fromIterable([const PayScanInvalid('bad code')]), + initialState: const PayScanScanning(), + ); + + await tester.pumpApp(buildSubject()); + await tester.pump(); + + expect(find.byType(SnackBar), findsOne); + expect(find.text(S.current.payScanInvalid), findsOne); + verify(() => scanCubit.reset()).called(1); + }); + + testWidgets('a decoded link navigates to the quote step and resets the cubit', (tester) async { + final link = DecodedPaymentLink( + id: 'pl_abc123', + lnurlpUrl: Uri.parse('https://api.dfx.swiss/v1/lnurlp/pl_abc123'), + ); + whenListen( + scanCubit, + Stream.fromIterable([PayScanDecoded(link)]), + initialState: const PayScanScanning(), + ); + + await tester.pumpApp(buildSubject()); + await tester.pumpAndSettle(); + + // The quote step is pushed and rendered; the cubit is reset so returning + // to the scanner re-arms detection. + expect(find.byType(PayQuoteView), findsOne); + verify(() => scanCubit.reset()).called(1); + }); + }); +} diff --git a/test/screens/send/cubits/send_amount_cubit_test.dart b/test/screens/send/cubits/send_amount_cubit_test.dart new file mode 100644 index 000000000..f9b528255 --- /dev/null +++ b/test/screens/send/cubits/send_amount_cubit_test.dart @@ -0,0 +1,87 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_amount/send_amount_cubit.dart'; + +void main() { + group('SendAmountCubit', () { + test('starts empty and not valid', () { + final cubit = SendAmountCubit(availableShares: BigInt.from(10)); + expect(cubit.state.status, SendAmountStatus.empty); + expect(cubit.state.isValid, isFalse); + }); + + blocTest( + 'empty text → empty status', + build: () => SendAmountCubit(availableShares: BigInt.from(10)), + act: (cubit) => cubit.amountChanged(' '), + verify: (cubit) => expect(cubit.state.status, SendAmountStatus.empty), + ); + + blocTest( + 'a whole number within balance is valid', + build: () => SendAmountCubit(availableShares: BigInt.from(10)), + act: (cubit) => cubit.amountChanged('5'), + verify: (cubit) { + expect(cubit.state.status, SendAmountStatus.valid); + expect(cubit.state.amount, 5); + expect(cubit.state.isValid, isTrue); + }, + ); + + blocTest( + 'zero is invalid', + build: () => SendAmountCubit(availableShares: BigInt.from(10)), + act: (cubit) => cubit.amountChanged('0'), + verify: (cubit) => expect(cubit.state.status, SendAmountStatus.invalid), + ); + + blocTest( + 'a non-integer is invalid', + build: () => SendAmountCubit(availableShares: BigInt.from(10)), + act: (cubit) => cubit.amountChanged('1.5'), + verify: (cubit) => expect(cubit.state.status, SendAmountStatus.invalid), + ); + + blocTest( + 'more than the available balance → insufficientBalance', + build: () => SendAmountCubit(availableShares: BigInt.from(10)), + act: (cubit) => cubit.amountChanged('11'), + verify: (cubit) { + expect(cubit.state.status, SendAmountStatus.insufficientBalance); + expect(cubit.state.isValid, isFalse); + }, + ); + + blocTest( + 'with unknown balance the over-balance guard is skipped (API validates)', + build: SendAmountCubit.new, + act: (cubit) => cubit.amountChanged('999999'), + verify: (cubit) => expect(cubit.state.status, SendAmountStatus.valid), + ); + + blocTest( + 'useMax fills the full available balance', + build: () => SendAmountCubit(availableShares: BigInt.from(42)), + act: (cubit) => cubit.useMax(), + verify: (cubit) { + expect(cubit.state.text, '42'); + expect(cubit.state.amount, 42); + expect(cubit.state.status, SendAmountStatus.valid); + }, + ); + + blocTest( + 'useMax is a no-op when the balance is zero', + build: () => SendAmountCubit(availableShares: BigInt.zero), + act: (cubit) => cubit.useMax(), + expect: () => [], + ); + + blocTest( + 'useMax is a no-op when the balance is unknown', + build: SendAmountCubit.new, + act: (cubit) => cubit.useMax(), + expect: () => [], + ); + }); +} diff --git a/test/screens/send/cubits/send_process/send_process_state_test.dart b/test/screens/send/cubits/send_process/send_process_state_test.dart new file mode 100644 index 000000000..4282915e7 --- /dev/null +++ b/test/screens/send/cubits/send_process/send_process_state_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart'; + +/// Equatable-`props` surface tests for `SendProcessState`. +void main() { + group('SendProcessState equality (Equatable props)', () { + test('payload-less states expose empty props and compare by type', () { + // Reading `.props` directly evaluates the inherited base getter; const + // canonicalization would otherwise let `==` short-circuit via identical + // and skip the getter line in the coverage report. + expect(const SendProcessInitial().props, isEmpty); + expect(const SendProcessPreparing().props, isEmpty); + expect(const SendProcessSigning().props, isEmpty); + + // Same type compares equal (base `props => []`). + expect(const SendProcessInitial(), const SendProcessInitial()); + expect(const SendProcessPreparing(), const SendProcessPreparing()); + expect(const SendProcessSigning(), const SendProcessSigning()); + + // Different payload-less subclasses are unequal. + expect( + const SendProcessInitial(), + isNot(equals(const SendProcessPreparing())), + ); + expect( + const SendProcessPreparing(), + isNot(equals(const SendProcessSigning())), + ); + expect( + const SendProcessSigning(), + isNot(equals(const SendProcessInitial())), + ); + }); + + test('SendProcessSuccess is keyed on txHash', () { + expect(const SendProcessSuccess('h'), const SendProcessSuccess('h')); + expect( + const SendProcessSuccess('h'), + isNot(equals(const SendProcessSuccess('g'))), + ); + expect(const SendProcessSuccess('h').props, ['h']); + }); + + test('SendProcessFailure is keyed on reason + message', () { + // Equal when reason and message match. + expect( + const SendProcessFailure( + SendProcessFailureReason.invalidRequest, + message: 'm', + ), + const SendProcessFailure( + SendProcessFailureReason.invalidRequest, + message: 'm', + ), + ); + + // Unequal when the reason differs. + expect( + const SendProcessFailure( + SendProcessFailureReason.invalidRequest, + message: 'm', + ), + isNot( + equals( + const SendProcessFailure( + SendProcessFailureReason.generic, + message: 'm', + ), + ), + ), + ); + + // Unequal when the message differs. + expect( + const SendProcessFailure( + SendProcessFailureReason.invalidRequest, + message: 'm', + ), + isNot( + equals( + const SendProcessFailure( + SendProcessFailureReason.invalidRequest, + message: 'n', + ), + ), + ), + ); + + // The default (no `message`) variant is equal to itself and unequal to + // the same reason carrying a message — both evaluate `props`. + expect( + const SendProcessFailure(SendProcessFailureReason.signatureCancelled), + const SendProcessFailure(SendProcessFailureReason.signatureCancelled), + ); + expect( + const SendProcessFailure(SendProcessFailureReason.signatureCancelled), + isNot( + equals( + const SendProcessFailure( + SendProcessFailureReason.signatureCancelled, + message: 'm', + ), + ), + ), + ); + + expect( + const SendProcessFailure( + SendProcessFailureReason.invalidRequest, + message: 'm', + ).props, + [SendProcessFailureReason.invalidRequest, 'm'], + ); + expect( + const SendProcessFailure(SendProcessFailureReason.signatureCancelled).props, + [SendProcessFailureReason.signatureCancelled, null], + ); + }); + }); +} diff --git a/test/screens/send/cubits/send_process_cubit_test.dart b/test/screens/send/cubits/send_process_cubit_test.dart new file mode 100644 index 000000000..142e85d48 --- /dev/null +++ b/test/screens/send/cubits/send_process_cubit_test.dart @@ -0,0 +1,262 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart'; + +class _MockTransferService extends Mock implements RealUnitTransferService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockWallet extends Mock implements AWallet {} + +RealUnitTransferPaymentInfoDto _info() => RealUnitTransferPaymentInfoDto.fromJson({ + 'id': 42, + 'uid': 'RTabc', + 'toAddress': '0xRecipient', + 'amount': 5, + 'tokenAddress': '0xRealu', + 'chainId': 1, + 'eip7702': { + 'relayerAddress': '0xrelay', + 'delegationManagerAddress': '0xmanager', + 'delegatorAddress': '0xdelegator', + 'userNonce': 0, + 'domain': {'name': 'd', 'version': '1', 'chainId': 1, 'verifyingContract': '0xmanager'}, + 'types': { + 'Delegation': >[], + 'Caveat': >[], + }, + 'message': { + 'delegate': '0xrelay', + 'delegator': '0xsender', + 'authority': '0xroot', + 'caveats': >[], + 'salt': 0, + }, + 'tokenAddress': '0xRealu', + 'amountWei': '5', + 'recipient': '0xRecipient', + }, +}); + +void main() { + late _MockTransferService service; + late _MockAppStore appStore; + late _MockWallet wallet; + + setUpAll(() { + registerFallbackValue(const RealUnitTransferDto(toAddress: '0x', amount: 1)); + registerFallbackValue(_info()); + }); + + setUp(() { + service = _MockTransferService(); + appStore = _MockAppStore(); + wallet = _MockWallet(); + when(() => appStore.wallet).thenReturn(wallet); + when(() => wallet.walletType).thenReturn(WalletType.software); + }); + + SendProcessCubit build() => SendProcessCubit( + transferService: service, + appStore: appStore, + recipient: '0xRecipient', + amount: 5, + ); + + void wireHappyPath() { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + when(() => service.confirmTransfer(any())).thenAnswer((_) async => '0xdeadbeef'); + } + + test('debug wallet → signatureUnsupported before any network call', () async { + when(() => wallet.walletType).thenReturn(WalletType.debug); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.signatureUnsupported); + verifyNever(() => service.prepareTransfer(any())); + await cubit.close(); + }); + + test('bitbox wallet → signatureUnsupported before any network call', () async { + when(() => wallet.walletType).thenReturn(WalletType.bitbox); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.signatureUnsupported); + verifyNever(() => service.prepareTransfer(any())); + await cubit.close(); + }); + + test('happy path: prepare → confirm → success with txHash', () async { + wireHappyPath(); + RealUnitTransferDto? sentDto; + when(() => service.prepareTransfer(any())).thenAnswer((invocation) async { + sentDto = invocation.positionalArguments.first as RealUnitTransferDto; + return _info(); + }); + + final cubit = build(); + await cubit.start(); + + expect(sentDto!.toAddress, '0xRecipient'); + expect(sentDto!.amount, 5); + final state = cubit.state as SendProcessSuccess; + expect(state.txHash, '0xdeadbeef'); + await cubit.close(); + }); + + test('emits Preparing then Signing then Success', () async { + wireHappyPath(); + + final cubit = build(); + final emitted = []; + final sub = cubit.stream.listen(emitted.add); + await cubit.start(); + // Let the final Success microtask flush before cancelling the subscription. + await Future.delayed(Duration.zero); + await sub.cancel(); + + expect(emitted.map((s) => s.runtimeType).toList(), [ + SendProcessPreparing, + SendProcessSigning, + SendProcessSuccess, + ]); + await cubit.close(); + }); + + test('service-reported unsupported signature → signatureUnsupported', () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + when( + () => service.confirmTransfer(any()), + ).thenThrow(const TransferSignatureUnsupportedException()); + + final cubit = build(); + await cubit.start(); + + expect( + (cubit.state as SendProcessFailure).reason, + SendProcessFailureReason.signatureUnsupported, + ); + await cubit.close(); + }); + + test('gas funding unavailable exception → gasFundingUnavailable', () async { + when( + () => service.prepareTransfer(any()), + ).thenThrow(const TransferGasFundingUnavailableException()); + + final cubit = build(); + await cubit.start(); + + expect( + (cubit.state as SendProcessFailure).reason, + SendProcessFailureReason.gasFundingUnavailable, + ); + await cubit.close(); + }); + + test('signing cancelled → signatureCancelled', () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + when(() => service.confirmTransfer(any())).thenThrow(const SigningCancelledException()); + + final cubit = build(); + await cubit.start(); + + expect( + (cubit.state as SendProcessFailure).reason, + SendProcessFailureReason.signatureCancelled, + ); + await cubit.close(); + }); + + test('bitbox not connected (defensive) → signatureUnsupported', () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + when(() => service.confirmTransfer(any())).thenThrow(const BitboxNotConnectedException()); + + final cubit = build(); + await cubit.start(); + + expect( + (cubit.state as SendProcessFailure).reason, + SendProcessFailureReason.signatureUnsupported, + ); + await cubit.close(); + }); + + test('API 400 (invalid recipient / insufficient REALU) → invalidRequest', () async { + when(() => service.prepareTransfer(any())).thenThrow( + const ApiException(statusCode: 400, code: 'X', message: 'Invalid recipient address'), + ); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.invalidRequest); + expect(state.message, 'Invalid recipient address'); + await cubit.close(); + }); + + test('API 404 → invalidRequest', () async { + when( + () => service.prepareTransfer(any()), + ).thenThrow(const ApiException(statusCode: 404, code: 'X', message: 'not found')); + + final cubit = build(); + await cubit.start(); + + expect((cubit.state as SendProcessFailure).reason, SendProcessFailureReason.invalidRequest); + await cubit.close(); + }); + + test('API 503 → gasFundingUnavailable', () async { + when( + () => service.prepareTransfer(any()), + ).thenThrow(const ApiException(statusCode: 503, code: 'X', message: 'unavailable')); + + final cubit = build(); + await cubit.start(); + + expect( + (cubit.state as SendProcessFailure).reason, + SendProcessFailureReason.gasFundingUnavailable, + ); + await cubit.close(); + }); + + test('API 500 → generic', () async { + when( + () => service.prepareTransfer(any()), + ).thenThrow(const ApiException(statusCode: 500, code: 'X', message: 'boom')); + + final cubit = build(); + await cubit.start(); + + expect((cubit.state as SendProcessFailure).reason, SendProcessFailureReason.generic); + await cubit.close(); + }); + + test('unexpected error → generic', () async { + when(() => service.prepareTransfer(any())).thenThrow(Exception('weird')); + + final cubit = build(); + await cubit.start(); + + expect((cubit.state as SendProcessFailure).reason, SendProcessFailureReason.generic); + await cubit.close(); + }); +} diff --git a/test/screens/send/cubits/send_recipient_cubit_test.dart b/test/screens/send/cubits/send_recipient_cubit_test.dart new file mode 100644 index 000000000..e247595ef --- /dev/null +++ b/test/screens/send/cubits/send_recipient_cubit_test.dart @@ -0,0 +1,73 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_recipient/send_recipient_cubit.dart'; + +void main() { + // A valid EVM address and its EIP-55 checksum form. + const lowercase = '0x9f5713deacb8e9cab6c2d3fae1afc2715f8d2d71'; + const checksummed = '0x9F5713DEacB8e9CAB6c2d3FaE1AFc2715F8D2D71'; + + group('SendRecipientCubit', () { + test('starts empty', () { + expect(SendRecipientCubit().state, isA()); + }); + + blocTest( + 'a valid address is normalized to its EIP-55 checksum', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit(lowercase), + verify: (cubit) { + final state = cubit.state as SendRecipientValid; + expect(state.address, checksummed); + }, + ); + + blocTest( + 'trims surrounding whitespace before validating', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit(' $checksummed '), + expect: () => [const SendRecipientValid(checksummed)], + ); + + blocTest( + 'strips an ethereum: EIP-681 scheme and @chainId / query suffix', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit('ethereum:$lowercase@1?value=0'), + expect: () => [const SendRecipientValid(checksummed)], + ); + + blocTest( + 'an invalid address emits SendRecipientInvalid', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit('not-an-address'), + expect: () => [isA()], + ); + + blocTest( + 'onCodeDetected decodes a scanned address like submit', + build: SendRecipientCubit.new, + act: (cubit) => cubit.onCodeDetected(checksummed), + expect: () => [const SendRecipientValid(checksummed)], + ); + + blocTest( + 'onCodeDetected ignores further detections once valid (no re-emit)', + build: SendRecipientCubit.new, + act: (cubit) { + cubit.onCodeDetected(checksummed); + cubit.onCodeDetected(checksummed); + }, + expect: () => [const SendRecipientValid(checksummed)], + ); + + blocTest( + 'reset returns to empty', + build: SendRecipientCubit.new, + act: (cubit) { + cubit.submit(checksummed); + cubit.reset(); + }, + expect: () => [const SendRecipientValid(checksummed), const SendRecipientEmpty()], + ); + }); +} diff --git a/test/screens/send/send_amount_page_test.dart b/test/screens/send/send_amount_page_test.dart new file mode 100644 index 000000000..360eeb3ac --- /dev/null +++ b/test/screens/send/send_amount_page_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/screens/send/send_amount_page.dart'; +import 'package:realunit_wallet/screens/send/send_confirm_page.dart'; + +import '../../helper/helper.dart'; + +class _MockBalanceRepository extends Mock implements BalanceRepository {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +void main() { + Balance balanceOf(BigInt value) => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: '0xwallet', + balance: value, + asset: realUnitAsset, + ); + + late _MockBalanceRepository balanceRepo; + + setUpAll(() { + registerFallbackValue( + Balance( + chainId: 1, + contractAddress: '0x', + walletAddress: '0x', + balance: BigInt.zero, + asset: realUnitAsset, + ), + ); + }); + + void registerGraph(BigInt available) { + final getIt = GetIt.instance; + balanceRepo = _MockBalanceRepository(); + when(() => balanceRepo.watchBalance(any())).thenAnswer( + (_) => Stream.value(balanceOf(available)), + ); + getIt.registerFactory(() => balanceRepo); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + when(() => appStore.apiConfig).thenReturn(apiConfig); + when(() => appStore.primaryAddress).thenReturn('0xwallet'); + getIt.registerSingleton(appStore); + } + + tearDown(() async => GetIt.instance.reset()); + + group('$SendAmountPage', () { + testWidgets('shows the available balance and gates the continue button', (tester) async { + registerGraph(BigInt.from(42)); + await tester.pumpApp(const SendAmountPage(recipient: '0xRecipient')); + await tester.pumpAndSettle(); + + expect(find.text(S.current.sendAmountAvailable('42')), findsOne); + + // Continue is disabled until a valid amount is entered. + final disabled = tester.widget(find.byType(FilledButton)); + expect(disabled.onPressed, isNull); + }); + + testWidgets('an over-balance amount surfaces the insufficient error', (tester) async { + registerGraph(BigInt.from(5)); + await tester.pumpApp(const SendAmountPage(recipient: '0xRecipient')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '6'); + await tester.pump(); + + expect(find.text(S.current.sendAmountInsufficient), findsOne); + final stillDisabled = tester.widget(find.byType(FilledButton)); + expect(stillDisabled.onPressed, isNull); + }); + + testWidgets('a valid amount enables continue and navigates to confirm', (tester) async { + registerGraph(BigInt.from(42)); + await tester.pumpApp(const SendAmountPage(recipient: '0xRecipient')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '5'); + await tester.pumpAndSettle(); + + // The continue button is now enabled. + final enabled = tester.widget(find.byType(FilledButton)); + expect(enabled.onPressed, isNotNull); + + await tester.tap(find.widgetWithText(FilledButton, S.current.next)); + await tester.pumpAndSettle(); + + expect(find.byType(SendConfirmPage), findsOne); + }); + + testWidgets('MAX fills the available balance', (tester) async { + registerGraph(BigInt.from(42)); + await tester.pumpApp(const SendAmountPage(recipient: '0xRecipient')); + await tester.pumpAndSettle(); + + await tester.tap(find.text(S.current.max.toUpperCase())); + await tester.pumpAndSettle(); + + // MAX fills the field with the available balance and the amount validates. + expect(find.text('42'), findsOneWidget); + final enabled = tester.widget(find.byType(FilledButton)); + expect(enabled.onPressed, isNotNull); + }); + }); +} diff --git a/test/screens/send/send_confirm_page_test.dart b/test/screens/send/send_confirm_page_test.dart new file mode 100644 index 000000000..3637df059 --- /dev/null +++ b/test/screens/send/send_confirm_page_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/send/send_confirm_page.dart'; +import 'package:realunit_wallet/screens/send/send_process_page.dart'; + +import '../../helper/helper.dart'; + +class _MockTransferService extends Mock implements RealUnitTransferService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +class _MockWallet extends Mock implements SoftwareWallet {} + +void main() { + setUpAll(() { + // The confirm button pushes SendProcessPage, which builds a cubit off getIt + // and calls start(). A debug wallet makes start() settle immediately without + // any network call. + final getIt = GetIt.instance; + getIt.registerSingleton(_MockTransferService()); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + final wallet = _MockWallet(); + when(() => wallet.walletType).thenReturn(WalletType.debug); + when(() => appStore.wallet).thenReturn(wallet); + when(() => appStore.apiConfig).thenReturn(apiConfig); + getIt.registerSingleton(appStore); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + group('$SendConfirmPage', () { + testWidgets('renders the recipient + amount summary', (tester) async { + await tester.pumpApp( + const SendConfirmPage( + recipient: '0x9F5713dEAcb8e9CaB6c2D3FaE1aFc2715F8D2D71', + amount: 5, + ), + ); + + expect(find.text(S.current.sendConfirmTitle), findsOne); + expect(find.text(S.current.sendConfirmSummary('5')), findsOne); + expect(find.text('0x9F5713dEAcb8e9CaB6c2D3FaE1aFc2715F8D2D71'), findsOne); + expect(find.text(S.current.sendShares('5')), findsOne); + }); + + testWidgets('confirming starts the process step', (tester) async { + await tester.pumpApp( + const SendConfirmPage(recipient: '0xRecipient', amount: 5), + ); + + await tester.tap(find.text(S.current.sendConfirmButton)); + // Let the page-route transition advance (the process page never settles — + // it keeps a CupertinoActivityIndicator animating — so pump fixed frames). + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byType(SendProcessView), findsOne); + }); + }); +} diff --git a/test/screens/send/send_process_page_test.dart b/test/screens/send/send_process_page_test.dart new file mode 100644 index 000000000..d18de6661 --- /dev/null +++ b/test/screens/send/send_process_page_test.dart @@ -0,0 +1,183 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart'; +import 'package:realunit_wallet/screens/send/send_process_page.dart'; + +import '../../helper/helper.dart'; + +class _MockSendProcessCubit extends MockCubit implements SendProcessCubit {} + +class _MockTransferService extends Mock implements RealUnitTransferService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +class _MockWallet extends Mock implements SoftwareWallet {} + +void main() { + late _MockSendProcessCubit processCubit; + + setUpAll(() { + final getIt = GetIt.instance; + // SendProcessPage resolves the service + AppStore from getIt and calls + // start(). A debug wallet makes start() settle immediately + // (signatureUnsupported) without touching the network. + getIt.registerSingleton(_MockTransferService()); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + final wallet = _MockWallet(); + when(() => wallet.walletType).thenReturn(WalletType.debug); + when(() => appStore.wallet).thenReturn(wallet); + when(() => appStore.apiConfig).thenReturn(apiConfig); + getIt.registerSingleton(appStore); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + processCubit = _MockSendProcessCubit(); + when(() => processCubit.state).thenReturn(const SendProcessInitial()); + }); + + Widget buildSubject() => BlocProvider.value( + value: processCubit, + child: const SendProcessView(), + ); + + group('$SendProcessPage', () { + testWidgets('builds its own cubit and renders $SendProcessView', (tester) async { + await tester.pumpApp(const SendProcessPage(recipient: '0xRecipient', amount: 5)); + await tester.pump(); + + expect(find.byType(SendProcessView), findsOne); + }); + }); + + group('$SendProcessView progress labels', () { + Future expectLabel(WidgetTester tester, SendProcessState state, String label) async { + when(() => processCubit.state).thenReturn(state); + await tester.pumpApp(buildSubject()); + + expect(find.byType(CupertinoActivityIndicator), findsOne); + expect(find.text(label), findsOne); + } + + testWidgets('initial shows preparing', (tester) async { + await expectLabel(tester, const SendProcessInitial(), S.current.sendPreparing); + }); + + testWidgets('preparing label', (tester) async { + await expectLabel(tester, const SendProcessPreparing(), S.current.sendPreparing); + }); + + testWidgets('signing label', (tester) async { + await expectLabel(tester, const SendProcessSigning(), S.current.sendSigning); + }); + + testWidgets('success label', (tester) async { + await expectLabel(tester, const SendProcessSuccess('0xtx'), S.current.sendSuccess); + }); + + testWidgets('failure label', (tester) async { + await expectLabel( + tester, + const SendProcessFailure(SendProcessFailureReason.generic), + S.current.sendFailureTitle, + ); + }); + }); + + // The result sheet is a modal bottom sheet shown from the listener. The view + // keeps a CupertinoActivityIndicator animating behind it, so pumpAndSettle + // never settles; pump fixed frames to open the sheet. + Future pumpWithState(WidgetTester tester, SendProcessState terminal) async { + tester.view.physicalSize = const Size(1200, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + whenListen( + processCubit, + Stream.fromIterable([terminal]), + initialState: const SendProcessSigning(), + ); + await tester.pumpApp(buildSubject()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + } + + group('$SendProcessView result sheet', () { + testWidgets('success emits a success sheet with title + description', (tester) async { + await pumpWithState(tester, const SendProcessSuccess('0xtx')); + + expect(find.text(S.current.sendSuccessDescription), findsOne); + expect(find.byIcon(Icons.check_circle_rounded), findsOne); + expect(find.text(S.current.close), findsOne); + + await tester.tap(find.text(S.current.close)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byIcon(Icons.check_circle_rounded), findsNothing); + }); + + testWidgets('signature-unsupported failure message', (tester) async { + await pumpWithState( + tester, + const SendProcessFailure(SendProcessFailureReason.signatureUnsupported), + ); + + expect(find.text(S.current.sendFailureSignatureUnsupported), findsOne); + expect(find.byIcon(Icons.error_rounded), findsOne); + }); + + testWidgets('signature-cancelled failure message', (tester) async { + await pumpWithState( + tester, + const SendProcessFailure(SendProcessFailureReason.signatureCancelled), + ); + + expect(find.text(S.current.sendFailureSignatureCancelled), findsOne); + }); + + testWidgets('gas-unavailable failure message', (tester) async { + await pumpWithState( + tester, + const SendProcessFailure(SendProcessFailureReason.gasFundingUnavailable), + ); + + expect(find.text(S.current.sendFailureGasUnavailable), findsOne); + }); + + testWidgets('invalid-request failure message', (tester) async { + await pumpWithState( + tester, + const SendProcessFailure(SendProcessFailureReason.invalidRequest), + ); + + expect(find.text(S.current.sendFailureInvalidRequest), findsOne); + }); + + testWidgets('generic failure message', (tester) async { + await pumpWithState( + tester, + const SendProcessFailure(SendProcessFailureReason.generic), + ); + + expect(find.text(S.current.sendFailureGeneric), findsOne); + }); + }); +} diff --git a/test/screens/send/send_recipient_page_test.dart b/test/screens/send/send_recipient_page_test.dart new file mode 100644 index 000000000..4d793d44e --- /dev/null +++ b/test/screens/send/send_recipient_page_test.dart @@ -0,0 +1,181 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_recipient/send_recipient_cubit.dart'; +import 'package:realunit_wallet/screens/send/send_amount_page.dart'; +import 'package:realunit_wallet/screens/send/send_recipient_page.dart'; + +import '../../helper/helper.dart'; + +class _MockSendRecipientCubit extends MockCubit implements SendRecipientCubit {} + +class _MockBalanceRepository extends Mock implements BalanceRepository {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +void main() { + late _MockSendRecipientCubit recipientCubit; + + Balance balanceOf(BigInt value) => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: '0xwallet', + balance: value, + asset: realUnitAsset, + ); + + setUpAll(() { + registerFallbackValue( + Balance( + chainId: 1, + contractAddress: '0x', + walletAddress: '0x', + balance: BigInt.zero, + asset: realUnitAsset, + ), + ); + stubMobileScannerChannel(); + + // A valid recipient pushes SendAmountPage, which builds a SellBalanceCubit + // off getIt; register a balance repository + app store so the pushed route + // resolves and renders. + final getIt = GetIt.instance; + final balanceRepo = _MockBalanceRepository(); + when(() => balanceRepo.watchBalance(any())).thenAnswer( + (_) => Stream.value(balanceOf(BigInt.from(100))), + ); + getIt.registerFactory(() => balanceRepo); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + when(() => appStore.apiConfig).thenReturn(apiConfig); + when(() => appStore.primaryAddress).thenReturn('0xwallet'); + getIt.registerSingleton(appStore); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + recipientCubit = _MockSendRecipientCubit(); + when(() => recipientCubit.state).thenReturn(const SendRecipientEmpty()); + when(() => recipientCubit.reset()).thenReturn(null); + when(() => recipientCubit.submit(any())).thenReturn(null); + when(() => recipientCubit.onCodeDetected(any())).thenReturn(null); + }); + + Widget buildSubject() => BlocProvider.value( + value: recipientCubit, + child: const SendRecipientView(), + ); + + group('$SendRecipientPage', () { + testWidgets('builds its own cubit and renders $SendRecipientView', (tester) async { + await tester.pumpApp(const SendRecipientPage()); + + expect(find.byType(SendRecipientView), findsOne); + }); + }); + + group('$SendRecipientView', () { + testWidgets('renders the title, scanner preview and the manual field', (tester) async { + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.sendRecipientTitle), findsOne); + expect(find.byType(MobileScanner), findsOne); + expect(find.text(S.current.sendRecipientManualHint), findsOne); + }); + + testWidgets('onDetect forwards a scanned raw value to the cubit', (tester) async { + await tester.pumpApp(buildSubject()); + + final scanner = tester.widget(find.byType(MobileScanner)); + scanner.onDetect!(const BarcodeCapture()); + scanner.onDetect!(const BarcodeCapture(barcodes: [Barcode(rawValue: '0xabc')])); + + verify(() => recipientCubit.onCodeDetected('0xabc')).called(1); + }); + + testWidgets('the continue button submits the typed address', (tester) async { + await tester.pumpApp(buildSubject()); + + await tester.enterText(find.byType(TextField), '0xRecipient'); + await tester.tap(find.text(S.current.next)); + await tester.pump(); + + verify(() => recipientCubit.submit('0xRecipient')).called(1); + }); + + testWidgets('the paste button fills the field from the clipboard', (tester) async { + // Stub the clipboard platform channel so getData returns a known address. + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.getData') { + return {'text': ' 0xPasted '}; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + + await tester.pumpApp(buildSubject()); + + await tester.tap(find.byIcon(Icons.paste_rounded)); + await tester.pump(); + + final field = tester.widget(find.byType(TextField)); + expect(field.controller!.text, '0xPasted'); + }); + + testWidgets('an invalid recipient shows a snackbar', (tester) async { + whenListen( + recipientCubit, + Stream.fromIterable([ + const SendRecipientInvalid(InvalidRecipientAddressException('bad')), + ]), + initialState: const SendRecipientEmpty(), + ); + + await tester.pumpApp(buildSubject()); + await tester.pump(); + + expect(find.byType(SnackBar), findsOne); + expect(find.text(S.current.sendRecipientInvalid), findsOne); + }); + + testWidgets('a valid recipient navigates to the amount step and resets', (tester) async { + whenListen( + recipientCubit, + Stream.fromIterable([ + const SendRecipientValid('0x9F5713dEAcb8e9CaB6c2D3FaE1aFc2715F8D2D71'), + ]), + initialState: const SendRecipientEmpty(), + ); + + await tester.pumpApp(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.byType(SendAmountView), findsOne); + verify(() => recipientCubit.reset()).called(1); + }); + }); +} diff --git a/test/setup/di_pay_transfer_service_test.dart b/test/setup/di_pay_transfer_service_test.dart new file mode 100644 index 000000000..239f1b514 --- /dev/null +++ b/test/setup/di_pay_transfer_service_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/repository/settings_repository.dart'; +import 'package:realunit_wallet/packages/repository/wallet_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/setup/di.dart'; + +class _MockAppStore extends Mock implements AppStore {} + +class _MockBalanceRepository extends Mock implements BalanceRepository {} + +class _MockSettingsRepository extends Mock implements SettingsRepository {} + +class _MockWalletRepository extends Mock implements WalletRepository {} + +void main() { + // The pay and wallet-to-wallet flows wire their backend clients through + // `setupServices()` as factories: + // () => RealUnitPayService(getIt(), getIt()) + // () => RealUnitTransferService(getIt(), getIt()) + // This test exercises both registrations and then resolves each factory. + // + // `setupServices()` constructs `BalanceService` (eager singleton) up front, + // so `AppStore` + `BalanceRepository` must already be registered. Resolving + // either service then pulls the lazy `WalletService`, whose dependencies + // bottom out at `WalletRepository` + `SettingsRepository` + `AppStore` + // (`BitboxService` is registered by `setupServices()` itself). Registering + // those leaf mocks keeps the whole chain construct-only — none of the mocked + // collaborators perform I/O on construction. + setUp(() { + getIt.reset(); + getIt.registerSingleton(_MockAppStore()); + getIt.registerSingleton(_MockBalanceRepository()); + getIt.registerSingleton(_MockSettingsRepository()); + getIt.registerSingleton(_MockWalletRepository()); + }); + + tearDown(() => getIt.reset()); + + test('setupServices registers a resolvable RealUnitPayService factory', () { + setupServices(); + + expect(getIt.isRegistered(), isTrue); + + final service = getIt(); + expect(service, isA()); + expect(identical(service, getIt()), isFalse); + }); + + test('setupServices registers a resolvable RealUnitTransferService factory', () { + setupServices(); + + expect(getIt.isRegistered(), isTrue); + + final service = getIt(); + expect(service, isA()); + expect(identical(service, getIt()), isFalse); + }); +} diff --git a/test/setup/routing/pay_send_routes_test.dart b/test/setup/routing/pay_send_routes_test.dart new file mode 100644 index 000000000..27667f561 --- /dev/null +++ b/test/setup/routing/pay_send_routes_test.dart @@ -0,0 +1,75 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; +import 'package:realunit_wallet/screens/send/send_recipient_page.dart'; +import 'package:realunit_wallet/setup/routing/router_config.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; + +import '../../helper/helper.dart'; + +class _MockHomeBloc extends MockBloc implements HomeBloc {} + +void main() { + late _MockHomeBloc homeBloc; + + setUpAll(() { + // Both routed pages embed a QrScannerView (MobileScanner); the stub keeps + // the headless camera preview deterministic and free of + // MissingPluginException. + stubMobileScannerChannel(); + }); + + setUp(() { + homeBloc = _MockHomeBloc(); + when(() => homeBloc.state).thenReturn(const HomeState()); + }); + + // Mirrors the production wiring in main.dart: the routed pages read their + // blocs from above MaterialApp.router, so HomeBloc (used by the initial + // /home route) is provided here. Navigation then drives the real + // `routerConfig` to the /pay and /send GoRoutes under test. + Future pumpRouter(WidgetTester tester) async { + await tester.pumpWidget( + BlocProvider.value( + value: homeBloc, + child: MaterialApp.router( + routerConfig: routerConfig, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ), + ); + await tester.pumpAndSettle(); + } + + // Restore the global router singleton to its initial location after each + // test so the /pay or /send location does not leak into any later test. + tearDown(() => routerConfig.goNamed(AppRoutes.home)); + + testWidgets('the pay route builds PayScanPage', (tester) async { + await pumpRouter(tester); + + routerConfig.goNamed(AppRoutes.pay); + await tester.pumpAndSettle(); + + expect(find.byType(PayScanPage), findsOneWidget); + }); + + testWidgets('the send route builds SendRecipientPage', (tester) async { + await pumpRouter(tester); + + routerConfig.goNamed(AppRoutes.send); + await tester.pumpAndSettle(); + + expect(find.byType(SendRecipientPage), findsOneWidget); + }); +}