diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8732c0366..7fdbe341a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -39,6 +39,7 @@ jobs: outputs: proceed: ${{ steps.check.outputs.proceed }} is_prerelease: ${{ steps.check.outputs.is_prerelease }} + sentry_environment: ${{ steps.check.outputs.sentry_environment }} steps: - name: Inspect tag id: check @@ -48,19 +49,28 @@ jobs: # Strict shape: vX.Y.Z with all parts numeric, no suffix. if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then echo "::notice::Tag $TAG is not a plain vX.Y.Z tag; skipping release." - echo "proceed=false" >> $GITHUB_OUTPUT - echo "is_prerelease=false" >> $GITHUB_OUTPUT + { + echo "proceed=false" + echo "is_prerelease=false" + echo "sentry_environment=internal" + } >> "$GITHUB_OUTPUT" exit 0 fi PATCH=$(echo "$TAG" | cut -d. -f3) if [ "$PATCH" -eq 0 ]; then echo "::notice::Tag $TAG has PATCH=0; running production-candidate release." - echo "proceed=true" >> $GITHUB_OUTPUT - echo "is_prerelease=false" >> $GITHUB_OUTPUT + { + echo "proceed=true" + echo "is_prerelease=false" + echo "sentry_environment=production" + } >> "$GITHUB_OUTPUT" else echo "::notice::Tag $TAG has PATCH=$PATCH; running internal release." - echo "proceed=true" >> $GITHUB_OUTPUT - echo "is_prerelease=true" >> $GITHUB_OUTPUT + { + echo "proceed=true" + echo "is_prerelease=true" + echo "sentry_environment=internal" + } >> "$GITHUB_OUTPUT" fi store-metadata-preflight: @@ -172,6 +182,9 @@ jobs: working-directory: android env: NEW_VERSION: ${{ github.ref_name }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_ENVIRONMENT: ${{ needs.guard.outputs.sentry_environment }} - name: Upload APK artifact uses: actions/upload-artifact@v4 @@ -269,6 +282,9 @@ jobs: working-directory: ios env: NEW_VERSION: ${{ github.ref_name }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_ENVIRONMENT: ${{ needs.guard.outputs.sentry_environment }} - name: Upload IPA artifact uses: actions/upload-artifact@v4 diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index 93593b8eb..eb2a22c02 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -13,8 +13,51 @@ # Uncomment the line if you want fastlane to automatically update itself # update_fastlane +require "json" +require "tempfile" + default_platform(:android) +# Keeps the DSN out of the process arguments Fastlane prints. Flutter reads +# both compile-time values from this short-lived, mode-0600 JSON file. +def with_crash_reporting_defines + dsn = ENV.fetch("SENTRY_DSN", "") + UI.user_error!("SENTRY_DSN is required for store release builds") if dsn.empty? + + environment = ENV.fetch("SENTRY_ENVIRONMENT", "production") + file = Tempfile.new(["sentry-defines", ".json"]) + File.chmod(0o600, file.path) + file.write(JSON.generate({ + "SENTRY_DSN" => dsn, + "SENTRY_ENVIRONMENT" => environment, + })) + file.flush + yield file.path +ensure + file&.close! +end + +def upload_sentry_symbols(marketing_version:, version_code:, symbols_path:, dart_symbol_map_path:) + ENV["SENTRY_RELEASE"] = "swiss.realunit.app@#{marketing_version}+#{version_code}" + ENV["SENTRY_DIST"] = version_code.to_s + Dir.chdir("../..") do + # The plugin only warns and continues on missing input, which would ship + # a release with degraded (or no) crash symbolication silently. Fail the + # build instead. + if Dir.glob("#{symbols_path}/**/*").select { |f| File.file?(f) }.empty? + UI.user_error!("Sentry upload: no native debug symbols found under #{symbols_path}") + end + unless File.exist?(dart_symbol_map_path) + UI.user_error!("Sentry upload: Dart obfuscation map not found at #{dart_symbol_map_path}") + end + + sh("dart", "run", "sentry_dart_plugin", + "--sentry-define=symbols_path=#{symbols_path}", + "--sentry-define=dart_symbol_map_path=#{dart_symbol_map_path}" + ) + end +end + # Runs tool/generate_release_info.dart (single source of truth for tag → # marketing version + version code) and reads the values back from the # generated Dart file. The generated file is the canonical artefact — @@ -61,29 +104,48 @@ platform :android do marketing_version = info["marketing_version"] version_code = info["version_code"] - Dir.chdir("..") do - sh("flutter", "build", "appbundle", - "--release", - "--build-name=#{marketing_version}", - "--build-number=#{version_code}", - "--no-tree-shake-icons", - "--obfuscate", - "--split-debug-info=build/debug_info" - ) - sh("flutter", "build", "apk", - "--release", - "--build-name=#{marketing_version}", - "--build-number=#{version_code}", - "--no-tree-shake-icons", - "--obfuscate", - "--split-debug-info=build/debug_info" - ) - File.rename( - "../build/app/outputs/flutter-apk/app-release.apk", - "../build/app/outputs/flutter-apk/realunit-#{tag_name}.apk" - ) + with_crash_reporting_defines do |defines_file| + Dir.chdir("..") do + sh("flutter", "build", "appbundle", + "--release", + "--build-name=#{marketing_version}", + "--build-number=#{version_code}", + "--dart-define-from-file=#{defines_file}", + "--no-tree-shake-icons", + "--obfuscate", + "--split-debug-info=build/debug_info/appbundle", + "--extra-gen-snapshot-options=--save-obfuscation-map=build/appbundle-obfuscation-map.json" + ) + sh("flutter", "build", "apk", + "--release", + "--build-name=#{marketing_version}", + "--build-number=#{version_code}", + "--dart-define-from-file=#{defines_file}", + "--no-tree-shake-icons", + "--obfuscate", + "--split-debug-info=build/debug_info/apk", + "--extra-gen-snapshot-options=--save-obfuscation-map=build/apk-obfuscation-map.json" + ) + File.rename( + "../build/app/outputs/flutter-apk/app-release.apk", + "../build/app/outputs/flutter-apk/realunit-#{tag_name}.apk" + ) + end end + upload_sentry_symbols( + marketing_version: marketing_version, + version_code: version_code, + symbols_path: "build/debug_info/appbundle", + dart_symbol_map_path: "build/appbundle-obfuscation-map.json" + ) + upload_sentry_symbols( + marketing_version: marketing_version, + version_code: version_code, + symbols_path: "build/debug_info/apk", + dart_symbol_map_path: "build/apk-obfuscation-map.json" + ) + # Upload the binary + changelog only. The changelog is tied to this # build's version code (taken from the AAB); the listing metadata and # images are pushed by the dedicated call below, keeping the two diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 3d7b53fc6..d27b1caea 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -175,10 +175,45 @@ "or": "Oder", "originalPdf": "Original-PDF", "pay": "Bezahlen", + "payAwaitingSettlement": "Zahlung wird abgeschlossen", + "payConfirmButton": "Bezahlen", + "payFailureBitboxRequired": "Bitte verbinden Sie Ihre BitBox, um fortzufahren.", + "payFailureGeneric": "Bei der Zahlung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "payFailureInsufficientEth": "Es konnten nicht genügend ETH für die Netzwerkgebühren bereitgestellt werden. Bitte versuchen Sie es später erneut.", + "payFailureInsufficientZchf": "Ihr REALU-Bestand reicht für diesen Betrag nicht aus.", + "payFailureQuoteExpired": "Das Zahlungsangebot ist abgelaufen. Bitte scannen Sie den Code erneut.", + "payFailureSignatureUnsupported": "Diese Wallet kann keine Transaktionen signieren. Wechseln Sie zu einer Software- oder BitBox-Wallet.", + "payFailureTitle": "Zahlung fehlgeschlagen", "paymentInformationFailed": "Beim Abrufen der Zahlungsinformationen ist ein Fehler aufgetreten.", "paymentInformationFailedDescription": "Bitte versuchen Sie es später erneut. Wenn der Fehler weiterhin besteht, wenden Sie sich an unseren Support.", "payoutAccountAdd": "Auszahlungskonto hinzufügen", "payoutAccountSelect": "Auszahlungskonto auswählen", + "payPaying": "Zahlung wird gesendet", + "payPreparingSwap": "Tausch wird vorbereitet", + "payQuoteMerchant": "Empfänger", + "payQuoteRealuAmount": "Zu verkaufende REALU-Anteile", + "payQuoteRealuEstimated": "Erwarteter Erlös", + "payQuoteRealuFees": "Gebühren", + "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.", + "payRetryUnsignedTxMismatch": "Die vom Server gelieferten Zahlungsdetails stimmten nicht mit der Anfrage überein. Ihre getauschten ZCHF bleiben in Ihrer Wallet – bitte versuchen Sie es erneut.", + "payScanCameraPermissionDenied": "Für das Scannen eines Zahlungscodes wird Kamerazugriff benötigt. Bitte aktivieren Sie den Kamerazugriff für diese App in den Geräteeinstellungen.", + "payScanCameraUnavailable": "Die Kamera konnte nicht gestartet werden. Bitte versuchen Sie es erneut.", + "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", @@ -204,10 +239,11 @@ "pinVerifyFailed": "Die PIN ist falsch. Versuchen Sie es erneut.", "pinVerifying": "Anmeldung…", "pinVerifyLocked": "Zu viele Fehlversuche. Nutzen Sie 'PIN vergessen?', um zurückzusetzen.", + "pinVerifyLockedGate": "Zu viele Fehlversuche. Schließen Sie die App und starten Sie sie neu; auf dem Sperrbildschirm setzen Sie die Wallet über 'PIN vergessen?' zurück.", "pinVerifyLockedTemporarily": "Zu viele Fehlversuche. Versuchen Sie es in ${remaining} erneut.", "pinVerifySeedDescription": "Geben Sie Ihre PIN ein, um Ihre Seed-Phrase anzuzeigen", "pinVerifyUnverifiable": "Ihre PIN kann nicht überprüft werden (Speicherfehler). Setzen Sie die Wallet über 'PIN vergessen?' zurück und stellen Sie sie mit Ihrer Seed-Phrase wieder her.", - "pinVerifyUnverifiableGate": "Ihre PIN kann nicht überprüft werden (Speicherfehler). Sperren Sie die App und setzen Sie die Wallet über den Sperrbildschirm zurück.", + "pinVerifyUnverifiableGate": "Ihre PIN kann nicht überprüft werden (Speicherfehler). Schließen Sie die App und starten Sie sie neu; auf dem Sperrbildschirm setzen Sie die Wallet über 'PIN vergessen?' zurück.", "pleaseSelect": "Bitte auswählen", "portfolio": "Bestand", "portfolioDevelopment": "Bestandsentwicklung", @@ -294,7 +330,37 @@ "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", + "sendFailureConfirmMismatch": "Die Überweisungsdetails stimmen nicht mehr mit Ihrer Bestätigung überein. Bitte gehen Sie zurück und versuchen Sie es erneut.", + "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.", + "sendFailureRegistrationOrKycRequired": "Bitte schliessen Sie zuerst Ihre Registrierung oder Identitätsprüfung ab, um REALU zu senden.", + "sendFailureSignatureCancelled": "Die Signatur wurde abgebrochen. Ihre REALU bleiben unangetastet.", + "sendFailureSignatureUnsupported": "Diese Wallet kann keine gaslosen Überweisungen signieren. Wechseln Sie zu einer Software-Wallet, um REALU zu senden.", + "sendFailureTitle": "Überweisung fehlgeschlagen", "sending": "Wird gesendet", + "sendPaste": "Einfügen", + "sendPreparing": "Überweisung wird vorbereitet", + "sendProcessTitle": "REALU wird gesendet", + "sendRecipientInvalid": "Dies ist keine gültige Wallet-Adresse.", + "sendRecipientLabel": "Empfängeradresse", + "sendRecipientManualHint": "Scannen Sie einen Wallet-QR-Code oder fügen Sie die Empfängeradresse unten ein.", + "sendRecipientTitle": "Empfänger scannen", + "sendShares": "${shares} REALU", + "sendSigning": "Bestätigen Sie die Überweisung in Ihrer Wallet", + "sendSuccess": "Überweisung gesendet", + "sendSuccessDescription": "Ihre REALU sind auf dem Weg zum Empfänger.", "setNationalityFailed": "Ihre Staatsangehörigkeit konnte nicht gesetzt werden:\n${message}", "settings": "Einstellungen", "settingsAppVersion": "Version ${tag}", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 7e9dcaf86..913f4061a 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -175,10 +175,45 @@ "or": "Or", "originalPdf": "Original PDF", "pay": "Pay", + "payAwaitingSettlement": "Completing payment", + "payConfirmButton": "Pay", + "payFailureBitboxRequired": "Please connect your BitBox to continue.", + "payFailureGeneric": "Something went wrong with the payment. Please try again.", + "payFailureInsufficientEth": "Could not provision enough ETH for network fees. Please try again later.", + "payFailureInsufficientZchf": "Your REALU holdings are not enough for this amount.", + "payFailureQuoteExpired": "The payment quote expired. Please scan the code again.", + "payFailureSignatureUnsupported": "This wallet cannot sign transactions. Switch to a software or BitBox wallet.", + "payFailureTitle": "Payment failed", "paymentInformationFailed": "An error occurred while getting the payment information.", "paymentInformationFailedDescription": "Please try again later. If the error persists, contact our support team.", "payoutAccountAdd": "Add payout account", "payoutAccountSelect": "Select payout account", + "payPaying": "Sending payment", + "payPreparingSwap": "Preparing swap", + "payQuoteMerchant": "Paying to", + "payQuoteRealuAmount": "REALU shares to sell", + "payQuoteRealuEstimated": "Estimated proceeds", + "payQuoteRealuFees": "Fees", + "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.", + "payRetryUnsignedTxMismatch": "The payment details returned by the server did not match what was requested. Your swapped ZCHF stays in your wallet — please try again.", + "payScanCameraPermissionDenied": "Camera access is required to scan a payment code. Please enable camera access for this app in your device settings.", + "payScanCameraUnavailable": "The camera could not be started. Please try 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", @@ -204,10 +239,11 @@ "pinVerifyFailed": "PIN is wrong. Try again.", "pinVerifying": "Signing in…", "pinVerifyLocked": "Too many failed attempts. Use 'Forgot PIN?' to reset.", + "pinVerifyLockedGate": "Too many failed attempts. Close and reopen the app; on the lock screen, reset the wallet via 'Forgot PIN?'.", "pinVerifyLockedTemporarily": "Too many failed attempts. Try again in ${remaining}.", "pinVerifySeedDescription": "Enter your PIN to view your seed phrase", "pinVerifyUnverifiable": "Your PIN cannot be verified (storage error). Reset the wallet via 'Forgot PIN?' and restore it with your seed phrase.", - "pinVerifyUnverifiableGate": "Your PIN cannot be verified (storage error). Lock the app and reset the wallet from the lock screen.", + "pinVerifyUnverifiableGate": "Your PIN cannot be verified (storage error). Close and reopen the app; on the lock screen, reset the wallet via 'Forgot PIN?'.", "pleaseSelect": "Please select", "portfolio": "Portfolio", "portfolioDevelopment": "Portfolio development", @@ -294,7 +330,37 @@ "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", + "sendFailureConfirmMismatch": "The transfer details no longer match what you confirmed. Please go back and try again.", + "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.", + "sendFailureRegistrationOrKycRequired": "Please complete your registration or identity verification before sending REALU.", + "sendFailureSignatureCancelled": "The signature was cancelled. Your REALU is untouched.", + "sendFailureSignatureUnsupported": "This wallet cannot sign gasless transfers. Switch to a software wallet to send REALU.", + "sendFailureTitle": "Transfer failed", "sending": "Sending", + "sendPaste": "Paste", + "sendPreparing": "Preparing transfer", + "sendProcessTitle": "Sending REALU", + "sendRecipientInvalid": "This is not a valid wallet address.", + "sendRecipientLabel": "Recipient address", + "sendRecipientManualHint": "Scan a wallet QR code, or paste the recipient address below.", + "sendRecipientTitle": "Scan recipient", + "sendShares": "${shares} REALU", + "sendSigning": "Confirm the transfer in your wallet", + "sendSuccess": "Transfer sent", + "sendSuccessDescription": "Your REALU is on its way to the recipient.", "setNationalityFailed": "Could not set your nationality:\n${message}", "settings": "Settings", "settingsAppVersion": "Version ${tag}", diff --git a/ios/Podfile.lock b/ios/Podfile.lock index bd793d782..78b4b8d8b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -26,6 +26,9 @@ PODS: - local_auth_darwin (0.0.1): - Flutter - FlutterMacOS + - mobile_scanner (7.0.0): + - Flutter + - FlutterMacOS - no_screenshot (0.10.0): - Flutter - open_file_ios (1.0.3): @@ -49,6 +52,7 @@ DEPENDENCIES: - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) + - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) - no_screenshot (from `.symlinks/plugins/no_screenshot/ios`) - open_file_ios (from `.symlinks/plugins/open_file_ios/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) @@ -78,6 +82,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/image_picker_ios/ios" local_auth_darwin: :path: ".symlinks/plugins/local_auth_darwin/darwin" + mobile_scanner: + :path: ".symlinks/plugins/mobile_scanner/darwin" no_screenshot: :path: ".symlinks/plugins/no_screenshot/ios" open_file_ios: @@ -99,6 +105,7 @@ SPEC CHECKSUMS: IdensicMobileSDK: a8ec2cf5c216ae138b00e2ff32e0e31c5e366cec image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb + mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 no_screenshot: 03c8ac6586f9652cd45e3d12d74e5992256403ac open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0 OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 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/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 80d96da79..63eae30cc 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -13,8 +13,45 @@ # Uncomment the line if you want fastlane to automatically update itself # update_fastlane +require "json" +require "tempfile" + default_platform(:ios) +# Keeps the DSN out of the process arguments Fastlane prints. Flutter writes +# the encoded defines into Generated.xcconfig before gym invokes xcodebuild. +def with_crash_reporting_defines + dsn = ENV.fetch("SENTRY_DSN", "") + UI.user_error!("SENTRY_DSN is required for store release builds") if dsn.empty? + + environment = ENV.fetch("SENTRY_ENVIRONMENT", "production") + file = Tempfile.new(["sentry-defines", ".json"]) + File.chmod(0o600, file.path) + file.write(JSON.generate({ + "SENTRY_DSN" => dsn, + "SENTRY_ENVIRONMENT" => environment, + })) + file.flush + yield file.path +ensure + file&.close! +end + +def upload_sentry_symbols(marketing_version:, version_code:) + ENV["SENTRY_RELEASE"] = "swiss.realunit.app@#{marketing_version}+#{version_code}" + ENV["SENTRY_DIST"] = version_code.to_s + Dir.chdir("../..") do + # The plugin only warns and continues on missing input, which would ship + # a release with degraded (or no) crash symbolication silently. Fail the + # build instead. + if Dir.glob("build/ios/archive/**/*.dSYM").empty? + UI.user_error!("Sentry upload: no dSYMs found under build/ios/archive") + end + + sh("dart", "run", "sentry_dart_plugin") + end +end + def get_api_key app_store_connect_api_key( key_id: ENV['FASTLANE_APPLE_API_KEY_ID'], @@ -110,13 +147,29 @@ platform :ios do xcodeproj: "Runner.xcodeproj" ) - gym( - workspace: "Runner.xcworkspace", - scheme: "Runner", - output_name: "realunit-#{tag_name}.ipa", - xcargs: "-verbose", - verbose: true - ) + with_crash_reporting_defines do |defines_file| + Dir.chdir("../..") do + sh("flutter", "build", "ios", + "--config-only", + "--release", + "--build-name=#{marketing_version}", + "--build-number=#{version_code}", + "--dart-define-from-file=#{defines_file}" + ) + end + gym( + workspace: "Runner.xcworkspace", + scheme: "Runner", + archive_path: File.expand_path("../../build/ios/archive/Runner.xcarchive", __dir__), + output_name: "realunit-#{tag_name}.ipa", + xcargs: "-verbose", + verbose: true + ) + upload_sentry_symbols( + marketing_version: marketing_version, + version_code: version_code + ) + end upload_to_testflight( api_key: get_api_key, diff --git a/lib/main.dart b/lib/main.dart index 996c5ed0a..cb13b4100 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,3 @@ -import 'dart:developer' as developer; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -11,7 +9,7 @@ import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; import 'package:realunit_wallet/setup/di.dart'; -import 'package:realunit_wallet/setup/error_handling/realunit_error_view.dart'; +import 'package:realunit_wallet/setup/error_handling/error_handlers.dart'; import 'package:realunit_wallet/setup/lifecycle_initializer.dart'; import 'package:realunit_wallet/setup/routing/boot_navigation.dart'; import 'package:realunit_wallet/setup/routing/router_config.dart'; @@ -20,7 +18,7 @@ import 'package:realunit_wallet/styles/themes.dart'; Future main() async { final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); - _installErrorHandlers(); + installErrorHandlers(); // only preserve splash screen for 3 seconds for release version if (kReleaseMode) { @@ -41,25 +39,6 @@ Future _initialize() async { ); } -/// Defense-in-depth against uncaught build/paint exceptions. Without this, a -/// single throw inside a widget's `build` (e.g. the empty-BitBox-address -/// `EthereumAddress.fromHex("")` crash) surfaces in release as a bare grey -/// [ErrorWidget] with no branding and no signal to the user. We log every such -/// error and replace the grey box with a minimal on-brand surface. -void _installErrorHandlers() { - final defaultOnError = FlutterError.onError; - FlutterError.onError = (details) { - developer.log( - 'uncaught Flutter error: ${details.exceptionAsString()}', - name: 'WalletApp', - error: details.exception, - stackTrace: details.stack, - ); - defaultOnError?.call(details); - }; - ErrorWidget.builder = (details) => RealUnitErrorView(details: details); -} - Future _initializeWithSplashDuration() async { await Future.wait([ _initialize(), diff --git a/lib/packages/service/dfx/eip1559_unsigned_tx_decoder.dart b/lib/packages/service/dfx/eip1559_unsigned_tx_decoder.dart new file mode 100644 index 000000000..b7a561302 --- /dev/null +++ b/lib/packages/service/dfx/eip1559_unsigned_tx_decoder.dart @@ -0,0 +1,317 @@ +/// Minimal, purpose-built decoder for the unsigned EIP-1559 (type 2) ZCHF-transfer +/// (pay-leg) transaction the OCP pay backend hands the app to sign +/// (`PUT /v1/realunit/pay/unsigned-transaction`). It exists so the app can verify — +/// BEFORE signing the pay leg — that the raw ERC20-transfer bytes match the DTO's +/// accompanying metadata (`tokenAddress`, `recipient`, `amountWei`, `chainId`) and +/// that gas/fee fields stay within local caps, rather than blindly signing whatever +/// bytes come back. See `PayProcessCubit._validatePayUnsignedTx`. +/// +/// Scope is the pay leg only. The earlier REALU→ZCHF swap leg +/// (`RealUnitSwapUnsignedTransactionDto`) is signed blind today: that DTO carries only +/// a raw `swap` hex string with no comparable recipient/amount/chainId metadata, so this +/// decoder is not applied to it (closing that gap needs a backend DTO extension). +/// +/// This is intentionally narrow: it only understands the exact shape the backend produces +/// for the pay leg (a type-2 tx whose `data` is a plain ERC20 `transfer(address,uint256)` +/// call) and rejects (fail-closed) anything that does not match that shape, rather than +/// being a general-purpose Ethereum RLP/ABI library. +library; + +import 'dart:typed_data'; + +import 'package:convert/convert.dart' as convert; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart'; + +/// A decoded (still unsigned) EIP-1559 transaction — the fields this app needs to +/// cross-check against `RealUnitOcpPayUnsignedTransactionDto` and to apply local +/// gas/fee caps before signing the pay leg. +class DecodedEip1559Transaction { + final BigInt chainId; + + /// EIP-1559 max priority fee per gas (wei), from RLP field index 2. + final BigInt maxPriorityFeePerGas; + + /// EIP-1559 max fee per gas (wei), from RLP field index 3. + final BigInt maxFeePerGas; + + /// Gas limit, from RLP field index 4. + final BigInt gasLimit; + + /// The tx's `to` address, normalized to `0x` + 40 lowercase hex chars. + final String to; + + /// The tx's native ETH value (wei). An ERC20 transfer must carry 0. + final BigInt value; + + /// The tx's calldata, unparsed. + final Uint8List data; + + const DecodedEip1559Transaction({ + required this.chainId, + required this.maxPriorityFeePerGas, + required this.maxFeePerGas, + required this.gasLimit, + required this.to, + required this.value, + required this.data, + }); +} + +/// Decodes the RLP-encoded, unsigned, type-2 (EIP-1559) transaction hex the OCP pay backend +/// returns for the pay leg. Throws `PayUnsignedTxMismatchException` — never silently +/// truncates/pads/skips — on any structural anomaly, since a transaction the app cannot +/// fully parse is one it must refuse to sign. +abstract final class Eip1559UnsignedTxDecoder { + static const _typeByte = 0x02; + static const _fieldCount = 9; + static const _chainIdFieldIndex = 0; + static const _nonceFieldIndex = 1; + static const _maxPriorityFeePerGasFieldIndex = 2; + static const _maxFeePerGasFieldIndex = 3; + static const _gasLimitFieldIndex = 4; + static const _toFieldIndex = 5; + static const _valueFieldIndex = 6; + static const _dataFieldIndex = 7; + static const _accessListFieldIndex = 8; + + static DecodedEip1559Transaction decode(String rawTransaction) { + final hex = rawTransaction.startsWith('0x') + ? rawTransaction.substring(2) + : rawTransaction; + final Uint8List bytes; + try { + bytes = Uint8List.fromList(convert.hex.decode(hex)); + } on FormatException { + throw const PayUnsignedTxMismatchException('unsigned tx is not valid hex'); + } + + if (bytes.isEmpty || bytes[0] != _typeByte) { + throw const PayUnsignedTxMismatchException( + 'unsigned tx is not an EIP-1559 (type 2) transaction', + ); + } + + final decoded = _decodeItem(bytes, 1); + if (decoded.end != bytes.length) { + throw const PayUnsignedTxMismatchException( + 'unsigned tx has trailing bytes after RLP decode', + ); + } + final root = decoded.value; + if (root is! List) { + throw const PayUnsignedTxMismatchException('unsigned tx RLP root is not a list'); + } + if (root.length != _fieldCount) { + throw PayUnsignedTxMismatchException( + 'unsigned tx has ${root.length} RLP fields, expected $_fieldCount', + ); + } + + final to = root[_toFieldIndex]; + if (to is! Uint8List || to.length != 20) { + throw const PayUnsignedTxMismatchException('unsigned tx "to" is not a 20-byte address'); + } + final data = root[_dataFieldIndex]; + if (data is! Uint8List) { + throw const PayUnsignedTxMismatchException('unsigned tx "data" is not a byte string'); + } + // This narrow decoder only ever handles the plain ERC20-transfer shape the backend + // produces for the pay leg, which never carries an access list. Reject non-empty lists + // fail-closed rather than signing payload bytes we have not inspected. + final accessList = root[_accessListFieldIndex]; + if (accessList is! List || accessList.isNotEmpty) { + throw const PayUnsignedTxMismatchException( + 'unsigned tx "accessList" must be an empty RLP list', + ); + } + + // Canonical RLP for the six quantity fields (including nonce, which is part of the + // signed payload even though its decoded value is not exposed). Leading-zero-byte + // integers are non-canonical and rejected before any BigInt conversion. + _requireCanonicalInteger(root[_chainIdFieldIndex], 'chainId'); + _requireCanonicalInteger(root[_nonceFieldIndex], 'nonce'); + _requireCanonicalInteger(root[_maxPriorityFeePerGasFieldIndex], 'maxPriorityFeePerGas'); + _requireCanonicalInteger(root[_maxFeePerGasFieldIndex], 'maxFeePerGas'); + _requireCanonicalInteger(root[_gasLimitFieldIndex], 'gasLimit'); + _requireCanonicalInteger(root[_valueFieldIndex], 'value'); + + return DecodedEip1559Transaction( + chainId: _asBigInt(root[_chainIdFieldIndex]), + maxPriorityFeePerGas: _asBigInt(root[_maxPriorityFeePerGasFieldIndex]), + maxFeePerGas: _asBigInt(root[_maxFeePerGasFieldIndex]), + gasLimit: _asBigInt(root[_gasLimitFieldIndex]), + to: '0x${convert.hex.encode(to)}', + value: _asBigInt(root[_valueFieldIndex]), + data: data, + ); + } + + /// Canonical RLP integers: empty byte string = 0; otherwise no leading zero byte. + /// Zero itself must be the empty string (`0x80`), never `0x00`. + static void _requireCanonicalInteger(Object field, String name) { + if (field is! Uint8List) { + throw PayUnsignedTxMismatchException( + 'unsigned tx "$name" is not a byte string', + ); + } + if (field.isNotEmpty && field[0] == 0) { + throw PayUnsignedTxMismatchException( + 'unsigned tx "$name" has a non-canonical leading zero byte', + ); + } + } + + static BigInt _asBigInt(Object field) { + if (field is! Uint8List) { + throw const PayUnsignedTxMismatchException('unsigned tx field is not a byte string'); + } + if (field.isEmpty) return BigInt.zero; + return BigInt.parse(convert.hex.encode(field), radix: 16); + } + + /// Decodes a single RLP item starting at [pos]. Returns the decoded value (`Uint8List` for a + /// byte string, `List` for a list) and the index immediately after the item. + static ({Object value, int end}) _decodeItem(Uint8List data, int pos) { + if (pos >= data.length) { + throw const PayUnsignedTxMismatchException('RLP: unexpected end of data'); + } + final prefix = data[pos]; + if (prefix < 0x80) { + return (value: Uint8List.fromList([prefix]), end: pos + 1); + } else if (prefix < 0xb8) { + final length = prefix - 0x80; + final start = pos + 1; + _requireBytes(data, start, length); + final value = data.sublist(start, start + length); + if (length == 1 && value[0] < 0x80) { + throw const PayUnsignedTxMismatchException( + 'RLP: non-canonical single-byte short string (must be the bare byte, not a length-1 string)', + ); + } + return (value: value, end: start + length); + } else if (prefix < 0xc0) { + final lengthOfLength = prefix - 0xb7; + final start = pos + 1; + _requireBytes(data, start, lengthOfLength); + final length = _bytesToLength(data.sublist(start, start + lengthOfLength)); + // Non-minimal long-form string: lengths ≤ 55 must use the short-form prefix. + if (length <= 55) { + throw const PayUnsignedTxMismatchException( + 'RLP: non-minimal long-form string length encoding', + ); + } + final start2 = start + lengthOfLength; + _requireBytes(data, start2, length); + return (value: data.sublist(start2, start2 + length), end: start2 + length); + } else if (prefix < 0xf8) { + final length = prefix - 0xc0; + final start = pos + 1; + _requireBytes(data, start, length); + return _decodeListBody(data, start, start + length); + } else { + final lengthOfLength = prefix - 0xf7; + final start = pos + 1; + _requireBytes(data, start, lengthOfLength); + final length = _bytesToLength(data.sublist(start, start + lengthOfLength)); + // Non-minimal long-form list: lengths ≤ 55 must use the short-form prefix. + if (length <= 55) { + throw const PayUnsignedTxMismatchException( + 'RLP: non-minimal long-form list length encoding', + ); + } + final start2 = start + lengthOfLength; + _requireBytes(data, start2, length); + return _decodeListBody(data, start2, start2 + length); + } + } + + static ({Object value, int end}) _decodeListBody(Uint8List data, int start, int end) { + final items = []; + var pos = start; + while (pos < end) { + final item = _decodeItem(data, pos); + items.add(item.value); + pos = item.end; + } + if (pos != end) { + throw const PayUnsignedTxMismatchException('RLP: list body length mismatch'); + } + return (value: items, end: end); + } + + static void _requireBytes(Uint8List data, int start, int length) { + if (start < 0 || length < 0 || start + length > data.length) { + throw const PayUnsignedTxMismatchException('RLP: declared length exceeds available data'); + } + } + + static int _bytesToLength(Uint8List bytes) { + // Cap length-of-length at 4 bytes so `start + length` in long-form RLP branches + // cannot overflow a 64-bit signed int (lengthOfLength=8 can encode ~2^63 and wrap + // past `_requireBytes`, letting a raw RangeError escape from `sublist`). + if (bytes.length > 4) { + throw const PayUnsignedTxMismatchException( + 'RLP: declared length-of-length exceeds practical transaction size', + ); + } + // Minimal length-of-length encoding never needs a leading zero byte. + if (bytes.length > 1 && bytes[0] == 0) { + throw const PayUnsignedTxMismatchException( + 'RLP: non-minimal length-of-length encoding (leading zero byte)', + ); + } + var value = 0; + for (final b in bytes) { + value = (value << 8) | b; + } + return value; + } +} + +/// A decoded ERC20 `transfer(address,uint256)` call. +class DecodedErc20Transfer { + /// Normalized to `0x` + 40 lowercase hex chars. + final String recipient; + final BigInt amountWei; + + const DecodedErc20Transfer({required this.recipient, required this.amountWei}); +} + +/// Decodes ERC20 `transfer(address,uint256)` calldata — the only shape the OCP pay backend ever +/// asks the app to sign for the pay leg. Anything else (wrong selector, wrong length, a +/// non-zero-padded address slot) is rejected, never partially parsed. +abstract final class Erc20TransferCalldataDecoder { + static const _selector = [0xa9, 0x05, 0x9c, 0xbb]; // transfer(address,uint256) + static const _calldataLength = 68; // 4 (selector) + 32 (address) + 32 (amount) + + static DecodedErc20Transfer decode(Uint8List data) { + if (data.length != _calldataLength) { + throw PayUnsignedTxMismatchException( + 'unsigned tx calldata is ${data.length} bytes, expected $_calldataLength ' + '(ERC20 transfer)', + ); + } + for (var i = 0; i < _selector.length; i++) { + if (data[i] != _selector[i]) { + throw const PayUnsignedTxMismatchException( + 'unsigned tx calldata is not an ERC20 transfer(address,uint256) call', + ); + } + } + + final addressWord = data.sublist(4, 36); + for (var i = 0; i < 12; i++) { + if (addressWord[i] != 0) { + throw const PayUnsignedTxMismatchException( + 'unsigned tx calldata recipient slot is not zero-padded', + ); + } + } + final recipient = '0x${convert.hex.encode(addressWord.sublist(12, 32))}'; + + final amountWord = data.sublist(36, 68); + final amountWei = BigInt.parse(convert.hex.encode(amountWord), radix: 16); + + return DecodedErc20Transfer(recipient: recipient, amountWei: amountWei); + } +} 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..8e4a0f8a8 --- /dev/null +++ b/lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart @@ -0,0 +1,47 @@ +// Typed failures for the OCP pay flow (scan → swap → pay). Each one renders a +// human-readable string (see `exception_surface_test.dart`) so it can surface +// cleanly in logs, Sentry, and user-facing error states instead of the Dart +// default `Instance of '...'`. + +/// The scanned QR / pasted code is not a DFX Open CryptoPay payment link. +class InvalidPaymentLinkException implements Exception { + final String reason; + + const InvalidPaymentLinkException(this.reason); + + @override + String toString() => 'InvalidPaymentLinkException: $reason'; +} + +/// The loaded wallet cannot produce EIP-1559 signatures (today: the debug +/// wallet). The pay flow needs to sign the swap and pay transactions locally, +/// so it cannot proceed in this wallet mode. +class PaySignatureUnsupportedException implements Exception { + // Only ever thrown / constructed as a const expression, so the zero-arg + // body never registers a runtime line hit; toString() below is exercised. + const PaySignatureUnsupportedException(); // coverage:ignore-line + + @override + String toString() => + 'PaySignatureUnsupportedException: this wallet mode cannot sign transactions'; +} + +/// The unsigned pay-leg (ZCHF ERC20-transfer) transaction the backend returned for signing does +/// not match its accompanying security metadata (`tokenAddress`, `recipient`, `amountWei`, +/// `chainId`), exceeds local gas/fee caps, mismatches the app's locally configured chainId, or +/// could not be parsed as the expected EIP-1559 ERC20-transfer shape. Thrown by +/// Eip1559UnsignedTxDecoder / Erc20TransferCalldataDecoder and +/// PayProcessCubit._validatePayUnsignedTx BEFORE any pay-leg signing happens, so a +/// malformed/compromised backend pay response can never be blindly signed. +/// +/// Scope is the pay leg only. The earlier REALU→ZCHF swap leg +/// (`RealUnitSwapUnsignedTransactionDto`) is signed without this validation today (that DTO +/// has no comparable metadata); closing that gap needs a backend DTO extension. +class PayUnsignedTxMismatchException implements Exception { + final String reason; + + const PayUnsignedTxMismatchException(this.reason); + + @override + String toString() => 'PayUnsignedTxMismatchException: $reason'; +} 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..d97d403e3 --- /dev/null +++ b/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart @@ -0,0 +1,88 @@ +// 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. + +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; + +/// 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'; +} + +/// The prepare response's recipient/amount does not match what the user +/// confirmed on-screen. Fail-closed before any signature is produced so a +/// mismatched backend echo cannot be blind-signed. +class TransferConfirmMismatchException implements Exception { + /// Diagnostic detail (which field diverged), for logs. + final String detail; + + const TransferConfirmMismatchException([ + this.detail = 'prepare response does not match user-confirmed recipient/amount', + ]); + + @override + String toString() => 'TransferConfirmMismatchException: $detail'; +} + +/// The transfer was already confirmed server-side (HTTP 409 with an +/// "already confirmed" message). An earlier confirm for the same transfer `id` +/// landed but its response was lost (e.g. transport failure after the server +/// processed the request). Callers must treat this as success, not failure — +/// mirrors the sell flow's [AlreadyConfirmedException] for the W2W path. +class TransferAlreadyConfirmedException extends ApiException { + /// Server-reported tx hash when present on the 409 body; null otherwise. + final String? txHash; + + const TransferAlreadyConfirmedException({ + super.statusCode, + required super.code, + required super.message, + this.txHash, + }); + + @override + String toString() => 'TransferAlreadyConfirmedException: $message'; +} diff --git a/lib/packages/service/dfx/lnurl_decoder.dart b/lib/packages/service/dfx/lnurl_decoder.dart new file mode 100644 index 000000000..6b85fe0e2 --- /dev/null +++ b/lib/packages/service/dfx/lnurl_decoder.dart @@ -0,0 +1,195 @@ +/// 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'); + final candidate = (lnurlpIndex != -1 && lnurlpIndex + 1 < segments.length) + ? segments[lnurlpIndex + 1] + : (segments.isNotEmpty ? segments.last : null); + if (candidate != null && _hasValidPaymentLinkPrefix(candidate)) return candidate; + throw InvalidPaymentLinkException('No payment id in: $uri'); + } + + static bool _hasValidPaymentLinkPrefix(String id) => + id.startsWith('pl_') || id.startsWith('plp_'); + + /// 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..06eb2d85d --- /dev/null +++ b/lib/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart @@ -0,0 +1,164 @@ +/// 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 mapped as [LnurlpRecipientDto?] (nullable +/// structured object). Only `name` and `city` are carried — the fields the +/// pay-quote screen displays. Everything else on the backend +/// `PaymentLinkRecipientDto` object is intentionally left unmapped. When the +/// merchant configured no recipient the field is absent and parses to null. +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; + + final LnurlpRecipientDto? recipient; + + const LnurlpPaymentDto({ + required this.requestedAmount, + required this.quote, + required this.transferAmounts, + this.recipient, + }); + + factory LnurlpPaymentDto.fromJson(Map json) { + final transfersRaw = json['transferAmounts']; + if (transfersRaw is! List) { + throw FormatException( + 'transferAmounts is required and must be a list, got: $transfersRaw', + ); + } + return LnurlpPaymentDto( + requestedAmount: LnurlpRequestedAmountDto.fromJson( + json['requestedAmount'] as Map, + ), + quote: LnurlpQuoteDto.fromJson(json['quote'] as Map), + transferAmounts: transfersRaw + .map((e) => LnurlpTransferAmountDto.fromJson(e as Map)) + .toList(), + recipient: json['recipient'] == null + ? null + : LnurlpRecipientDto.fromJson(json['recipient'] as Map), + ); + } +} + +/// Merchant/recipient fields surfaced on the pay-quote screen. Only `name` and +/// `city` (from nested `address`) are mapped; other backend fields are unused. +class LnurlpRecipientDto { + final String? name; + final String? city; + + const LnurlpRecipientDto({this.name, this.city}); + + factory LnurlpRecipientDto.fromJson(Map json) { + final address = json['address'] as Map?; + return LnurlpRecipientDto( + name: json['name'] as String?, + city: address?['city'] as String?, + ); + } +} + +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: _parseAmount('requestedAmount.amount', json['amount']), + ); + } +} + +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 assetsRaw = json['assets']; + if (assetsRaw is! List) { + throw FormatException( + 'transferAmounts.assets is required and must be a list, got: $assetsRaw', + ); + } + return LnurlpTransferAmountDto( + method: json['method'] as String, + assets: assetsRaw + .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; + + /// Exact JSON amount string as received (before `double` conversion), when + /// present. Used by the pay-process settlement guard for plain-decimal exact + /// comparison so binary-double drift cannot flip a boundary check. Additive — + /// callers that only read [amount] (e.g. [PayQuoteCubit]) are unaffected. + final String? rawAmount; + + const LnurlpTransferAssetDto({ + required this.asset, + this.amount, + this.rawAmount, + }); + + factory LnurlpTransferAssetDto.fromJson(Map json) { + final raw = json['amount']; + if (raw == null) { + return LnurlpTransferAssetDto(asset: json['asset'] as String); + } + final rawAmount = raw.toString(); + return LnurlpTransferAssetDto( + asset: json['asset'] as String, + amount: _parseAmount('transferAmounts.assets.amount', raw), + rawAmount: rawAmount, + ); + } +} + +/// Parses a money amount from JSON. Rejects NaN, ±Infinity and negatives fail- +/// closed via [FormatException] (same type [double.parse] already throws on +/// non-numeric input — no new exception class). +double _parseAmount(String fieldName, Object? raw) { + final value = double.parse(raw.toString()); + if (!value.isFinite) { + throw FormatException('$fieldName is not a finite number: $raw'); + } + if (value.isNegative) { + throw FormatException('$fieldName must not be negative: $raw'); + } + return value; +} 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..a16bc1d49 --- /dev/null +++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart @@ -0,0 +1,56 @@ +import 'dart:developer' as developer; + +/// 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); + + /// Mirrors the backend status 1:1. An unrecognised value is deliberately + /// mapped to `unknown` (forward-compat, not a bug) rather than crashing — + /// but the raw value is logged so the drift stays visible instead of being + /// silently swallowed. The `unknown` sentinel (`unknown('')`) is never matched + /// by value inside the known-value loop — only via the fall-through log+return + /// path — so an empty/unrecognised backend status always hits the log. + static OcpPaymentStatus fromValue(String value) { + for (final status in OcpPaymentStatus.values) { + if (status == OcpPaymentStatus.unknown) continue; + if (status.value == value) return status; + } + developer.log( + 'Unrecognised OcpPaymentStatus "$value" — mapped to unknown (forward-compat).', + name: 'OcpPaymentStatus', + ); + return OcpPaymentStatus.unknown; + } + + /// Polling stops once the payment reaches a final state. + /// `unknown` is intentionally terminal so an unrecognised status can never cause an unbounded poll. + bool get isTerminal => + this == completed || this == cancelled || this == expired || this == unknown; + + 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..a40714ae1 --- /dev/null +++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart @@ -0,0 +1,74 @@ +/// 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; + final RealUnitSwapFeeDto? fees; + + 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, + this.fees, + }); + + 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?, + fees: json['fees'] == null + ? null + : RealUnitSwapFeeDto.fromJson(json['fees'] as Map), + ); + } +} + +/// Fee breakdown from the swap quote. Only `total` is mapped for display. +class RealUnitSwapFeeDto { + final double total; + + const RealUnitSwapFeeDto({required this.total}); + + factory RealUnitSwapFeeDto.fromJson(Map json) { + return RealUnitSwapFeeDto(total: (json['total'] as num).toDouble()); + } +} 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..b0d237812 --- /dev/null +++ b/lib/packages/service/dfx/models/payment/pay/swap_payment_info.dart @@ -0,0 +1,54 @@ +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; + final double? feesTotal; + + 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, + this.feesTotal, + }); + + 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, + feesTotal: dto.fees?.total, + ); + + @override + List get props => [ + id, + amount, + estimatedAmount, + targetAsset, + ethBalance, + requiredGasEth, + isValid, + error, + feesTotal, + ]; +} 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..2f631793b --- /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 RealUnitTransferEip7702DataDto { + 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 RealUnitTransferEip7702DataDto({ + 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 RealUnitTransferEip7702DataDto.fromJson(Map json) { + return RealUnitTransferEip7702DataDto( + 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..a510a5887 --- /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 RealUnitTransferEip7702DataDto 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: RealUnitTransferEip7702DataDto.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..33a085ac5 --- /dev/null +++ b/lib/packages/service/dfx/real_unit_pay_service.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; + +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_request_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_response_dto.dart'; + +/// Backend client for the Open CryptoPay pay flow (DFXswiss/api #3819, all under +/// `/v1/realunit/...`). Subclasses [DFXAuthService] for the JWT handshake + +/// retry-on-401 the sell flow already uses; the public lnurlp read is the only +/// unauthenticated call. +class RealUnitPayService extends DFXAuthService { + static const _lnurlpPath = '/v1/lnurlp'; + static const _swapPath = '/v1/realunit/swap'; + static String _swapUnsignedTxPath(int id) => '/v1/realunit/swap/$id/unsigned-transaction'; + static String _swapBroadcastPath(int id) => '/v1/realunit/swap/$id/broadcast'; + static const _payUnsignedTxPath = '/v1/realunit/pay/unsigned-transaction'; + static const _paySubmitPath = '/v1/realunit/pay/submit'; + static String _payStatusPath(String id) => '/v1/realunit/pay/$id/status'; + + static const _httpTimeout = Duration(seconds: 20); + + RealUnitPayService(super.appStore, super.walletService); + + /// Public OCP payment-link read (no auth). Returns the requested fiat amount, + /// the active quote (id + expiration) and the per-method transfer amounts. + Future getPaymentDetails(String id) async { + final uri = buildUri(host, '$_lnurlpPath/$id'); + final response = await appStore.httpClient + .get(uri, headers: {'accept': 'application/json'}) + .timeout(_httpTimeout); + + if (response.statusCode != 200) { + _throwApi(response.body, response.statusCode); + } + return LnurlpPaymentDto.fromJson(jsonDecode(response.body) as Map); + } + + // --- Swap (REALU → ZCHF, proceeds stay in the user wallet) --- + + Future getSwapPaymentInfo(RealUnitSwapDto dto) async { + final uri = buildUri(host, _swapPath); + final response = await authenticatedPut( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(dto.toJson()), + ).timeout(_httpTimeout); + + 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'}, + ).timeout(_httpTimeout); + + 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()), + ).timeout(_httpTimeout); + + if (response.statusCode != 200 && response.statusCode != 201) { + _throwApi(response.body, response.statusCode); + } + return BroadcastTransactionResponseDto.fromJson( + jsonDecode(response.body) as Map, + ).txHash; + } + + // --- OCP pay (settle a ZCHF payment-link quote via the lnurlp flow) --- + + Future createPayUnsignedTransaction( + RealUnitOcpPayDto dto, + ) async { + final uri = buildUri(host, _payUnsignedTxPath); + final response = await authenticatedPut( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(dto.toJson()), + ).timeout(_httpTimeout); + + if (response.statusCode != 200 && response.statusCode != 201) { + _throwApi(response.body, response.statusCode); + } + return RealUnitOcpPayUnsignedTransactionDto.fromJson( + jsonDecode(response.body) as Map, + ); + } + + Future submitPay(RealUnitOcpPaySubmitDto dto) async { + final uri = buildUri(host, _paySubmitPath); + final response = await authenticatedPut( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(dto.toJson()), + ).timeout(_httpTimeout); + + 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).timeout(_httpTimeout); + + if (response.statusCode != 200) { + _throwApi(response.body, response.statusCode); + } + return RealUnitOcpPayStatusDto.fromJson( + jsonDecode(response.body) as Map, + ); + } + + Never _throwApi(String body, int statusCode) { + final errorJson = jsonDecode(body) as Map; + throw ApiException.fromJson(errorJson, httpStatusCode: statusCode); + } +} diff --git a/lib/packages/service/dfx/real_unit_transfer_service.dart b/lib/packages/service/dfx/real_unit_transfer_service.dart new file mode 100644 index 000000000..e6c1a5b87 --- /dev/null +++ b/lib/packages/service/dfx/real_unit_transfer_service.dart @@ -0,0 +1,253 @@ +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. + /// + /// [confirmedRecipient] and [confirmedAmount] are the values the user reviewed + /// and approved on the confirm screen. They are required so every call site + /// (including retries) must explicitly supply them; the service fail-closes if + /// the prepare response echoes different values before any signature is + /// produced. + /// + /// 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, { + required String confirmedRecipient, + required int confirmedAmount, + }) 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; + _validateAgainstUserConfirmation(info, confirmedRecipient, confirmedAmount); + _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(); + } + } + + /// Fail-closed blind-sign guard: the prepare response must echo the recipient + /// and amount the user just confirmed on-screen before any signature is + /// produced. + /// + /// Honesty note — the actually-signed EIP-712 struct (`Eip7702Message`: + /// delegate/delegator/authority/caveats/salt) carries no recipient/amount + /// field of its own. `recipient` / `amountWei` / `tokenAddress` are unsigned + /// metadata sitting alongside the signed message. This check only closes the + /// gap of "did the backend's prepare response echo something different than + /// what the user just confirmed on-screen" before signing. It is NOT a + /// decode/validation of the delegation framework's `caveats` payload (the + /// actual on-chain enforcement mechanism), which remains unparsed exactly as + /// before. This change alone does NOT establish a full on-chain + /// recipient/amount cryptographic binding. + void _validateAgainstUserConfirmation( + RealUnitTransferPaymentInfoDto info, + String confirmedRecipient, + int confirmedAmount, + ) { + if (info.toAddress.toLowerCase() != confirmedRecipient.toLowerCase()) { + throw TransferConfirmMismatchException( + 'toAddress ${info.toAddress} does not match confirmed recipient $confirmedRecipient', + ); + } + if (info.eip7702.recipient.toLowerCase() != confirmedRecipient.toLowerCase()) { + throw TransferConfirmMismatchException( + 'eip7702.recipient ${info.eip7702.recipient} does not match confirmed recipient $confirmedRecipient', + ); + } + if (info.amount != confirmedAmount) { + throw TransferConfirmMismatchException( + 'amount ${info.amount} does not match confirmed amount $confirmedAmount', + ); + } + } + + 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(), + ); + } + final error = ApiException.fromJson(errorJson, httpStatusCode: response.statusCode); + // 409 "already confirmed": an earlier confirm for this id landed but its + // response was lost. Mirrors RealUnitSellPaymentInfoService._sendConfirm. + if (error.statusCode == 409 && error.message.toLowerCase().contains('already confirmed')) { + final rawTxHash = errorJson['txHash']; + throw TransferAlreadyConfirmedException( + statusCode: error.statusCode, + code: error.code, + message: error.message, + txHash: rawTxHash is String ? rawTxHash : null, + ); + } + throw error; + } + + 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( + RealUnitTransferEip7702DataDto 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/packages/utils/plain_decimal.dart b/lib/packages/utils/plain_decimal.dart new file mode 100644 index 000000000..9716ee996 --- /dev/null +++ b/lib/packages/utils/plain_decimal.dart @@ -0,0 +1,124 @@ +/// Exact comparison of plain (non-scientific) decimal number strings via +/// [BigInt], without floating-point conversion. Dependency-free (`dart:core` +/// only) — used where money amounts must be ordered without binary-double drift. +/// +/// Accepts an optional leading `+`/`-`, an integer part, an optional fractional +/// part, and nothing else. Scientific notation (`1e3`, `1E-2`) and any other +/// malformed input throw [FormatException] — fail closed, never coerced. +library; + +/// Returns a negative value if [a] < [b], zero if equal, positive if [a] > [b]. +int comparePlainDecimalStrings(String a, String b) { + final parsedA = _parsePlainDecimal(a); + final parsedB = _parsePlainDecimal(b); + + // Different signs: negative is always smaller (and -0 == +0 is handled below + // once both sides are scaled to integer magnitude). + if (parsedA.negative != parsedB.negative) { + final aZero = parsedA.integerDigits == '0' && parsedA.fractionDigits.isEmpty; + final bZero = parsedB.integerDigits == '0' && parsedB.fractionDigits.isEmpty; + if (aZero && bZero) return 0; + return parsedA.negative ? -1 : 1; + } + + final scale = parsedA.fractionDigits.length > parsedB.fractionDigits.length + ? parsedA.fractionDigits.length + : parsedB.fractionDigits.length; + final scaledA = _toScaledInteger(parsedA, scale); + final scaledB = _toScaledInteger(parsedB, scale); + + final cmp = scaledA.compareTo(scaledB); + // Same sign: if both negative, the larger magnitude is the smaller number. + return parsedA.negative ? -cmp : cmp; +} + +({bool negative, String integerDigits, String fractionDigits}) _parsePlainDecimal( + String raw, +) { + var s = raw.trim(); + if (s.isEmpty) { + throw FormatException('plain decimal is empty: $raw'); + } + // Reject scientific notation and any non-decimal characters up front. + if (s.contains('e') || s.contains('E')) { + throw FormatException('plain decimal must not use scientific notation: $raw'); + } + + var negative = false; + if (s.startsWith('-')) { + negative = true; + s = s.substring(1); + } else if (s.startsWith('+')) { + s = s.substring(1); + } + if (s.isEmpty) { + throw FormatException('plain decimal has no digits: $raw'); + } + + final parts = s.split('.'); + if (parts.length > 2) { + throw FormatException('plain decimal has too many decimal points: $raw'); + } + final integerPart = parts[0]; + final fractionPart = parts.length == 2 ? parts[1] : ''; + + // Integer part may be empty only when a fraction is present (".5"); fraction + // may be empty ("42." / "42"). At least one side must carry a digit. + if (integerPart.isEmpty && fractionPart.isEmpty) { + throw FormatException('plain decimal has no digits: $raw'); + } + if (integerPart.isNotEmpty && !_isAllDigits(integerPart)) { + throw FormatException('plain decimal has non-digit characters: $raw'); + } + if (fractionPart.isNotEmpty && !_isAllDigits(fractionPart)) { + throw FormatException('plain decimal has non-digit characters: $raw'); + } + + // Normalize integer digits: strip leading zeros but keep a single "0" when + // the integer magnitude is zero (including ".5" → integer "0"). + final integerDigits = integerPart.isEmpty + ? '0' + : (BigInt.parse(integerPart).toString()); // drops leading zeros + // Strip trailing zeros from the fraction so "1.50" and "1.5" compare equal + // without needing a shared scale for zero-equality of the fractional tail. + final fractionDigits = _stripTrailingZeros(fractionPart); + + // -0 and +0: treat as non-negative zero so sign handling above is clean. + if (integerDigits == '0' && fractionDigits.isEmpty) { + return (negative: false, integerDigits: '0', fractionDigits: ''); + } + + return ( + negative: negative, + integerDigits: integerDigits, + fractionDigits: fractionDigits, + ); +} + +BigInt _toScaledInteger( + ({bool negative, String integerDigits, String fractionDigits}) parsed, + int scale, +) { + final fraction = parsed.fractionDigits.padRight(scale, '0'); + final combined = '${parsed.integerDigits}$fraction'; + // combined is digits-only (possibly empty only if both parts empty — already + // rejected). BigInt.parse('0' * n) is fine; empty cannot happen here. + return BigInt.parse(combined.isEmpty ? '0' : combined); +} + +bool _isAllDigits(String s) { + for (var i = 0; i < s.length; i++) { + final c = s.codeUnitAt(i); + if (c < 0x30 || c > 0x39) return false; + } + return true; +} + +String _stripTrailingZeros(String fraction) { + if (fraction.isEmpty) return fraction; + var end = fraction.length; + while (end > 0 && fraction.codeUnitAt(end - 1) == 0x30) { + end--; + } + return fraction.substring(0, end); +} diff --git a/lib/packages/wallet/eip712_signer.dart b/lib/packages/wallet/eip712_signer.dart index f19f0e789..e4a1d093e 100644 --- a/lib/packages/wallet/eip712_signer.dart +++ b/lib/packages/wallet/eip712_signer.dart @@ -24,11 +24,20 @@ class Eip712Signer { required bool swissTaxResidence, required String registrationDate, }) { + // The BitBox02 firmware refuses to sign typed data whose EIP712Domain has + // no chainId ("typed data has no chain ID" shown on the device), so + // hardware wallets sign with the chainId-extended domain. Software wallets + // keep the legacy chainId-less domain until Aktionariat has confirmed its + // signature re-verification accepts the extended variant — the API + // accepts both (pair PR DFXswiss/api#4542). + final includeChainIdInDomain = credentials is BitboxCredentials; + final Map typedDataMap = { 'types': { 'EIP712Domain': [ {'name': 'name', 'type': 'string'}, {'name': 'version', 'type': 'string'}, + if (includeChainIdInDomain) {'name': 'chainId', 'type': 'uint256'}, ], 'RealUnitUser': [ {'name': 'email', 'type': 'string'}, @@ -47,7 +56,11 @@ class Eip712Signer { ], }, 'primaryType': 'RealUnitUser', - 'domain': {'name': 'RealUnitUser', 'version': '1'}, + 'domain': { + 'name': 'RealUnitUser', + 'version': '1', + if (includeChainIdInDomain) 'chainId': chainId, + }, 'message': { 'email': email, 'name': name, diff --git a/lib/screens/dashboard/widgets/pending_transaction_row.dart b/lib/screens/dashboard/widgets/pending_transaction_row.dart index 6fb45f789..6cc57035f 100644 --- a/lib/screens/dashboard/widgets/pending_transaction_row.dart +++ b/lib/screens/dashboard/widgets/pending_transaction_row.dart @@ -71,7 +71,7 @@ class PendingTransactionRow extends StatelessWidget { ), if (transaction.date != null) Text( - DateFormat('MMM dd, yyyy').format(transaction.date!), + DateFormat('MMM dd, yyyy').format(transaction.date!.toLocal()), textAlign: .end, maxLines: 1, overflow: .ellipsis, 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/dashboard/widgets/transaction_row.dart b/lib/screens/dashboard/widgets/transaction_row.dart index fc96b81ea..cb2b412c4 100644 --- a/lib/screens/dashboard/widgets/transaction_row.dart +++ b/lib/screens/dashboard/widgets/transaction_row.dart @@ -87,7 +87,7 @@ class TransactionRow extends StatelessWidget { ), ), Text( - DateFormat('MMM dd, yyyy | H:mm').format(transaction.timestamp), + DateFormat('MMM dd, yyyy | H:mm').format(transaction.timestamp.toLocal()), style: const TextStyle( fontSize: 12, height: 16 / 12, @@ -187,7 +187,7 @@ class SavingsTransactionRow extends StatelessWidget { Row( children: [ Text( - DateFormat('MMM dd, yyyy').format(transaction.timestamp), + DateFormat('MMM dd, yyyy').format(transaction.timestamp.toLocal()), style: _secondRowTextStyle, ), ], diff --git a/lib/screens/home/bloc/home_bloc.dart b/lib/screens/home/bloc/home_bloc.dart index 6fd9d36ba..f57e4bdf5 100644 --- a/lib/screens/home/bloc/home_bloc.dart +++ b/lib/screens/home/bloc/home_bloc.dart @@ -9,6 +9,7 @@ import 'package:realunit_wallet/packages/service/settings_service.dart'; import 'package:realunit_wallet/packages/service/transaction_history_service.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/setup/routing/boot_navigation.dart'; part 'home_event.dart'; part 'home_state.dart'; @@ -102,6 +103,10 @@ class HomeBloc extends Bloc { await _walletService.deleteCurrentWallet(); _settingsService.setTermsAccepted(false); } + // Drop any stashed payment deeplink so it cannot replay into a re-onboarded + // wallet. Covers every DeleteCurrentWalletEvent path (settings delete and + // BitBox recovery cancel), including those that never call PinAuthCubit.reset(). + clearPendingPaymentDeeplink(); emit( HomeState( hasWallet: false, 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..f8f4993cd --- /dev/null +++ b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart @@ -0,0 +1,619 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:convert/convert.dart' as convert; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web3dart/crypto.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/eip1559_unsigned_tx_decoder.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/utils/plain_decimal.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.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; + + /// Guards overlapping ETH-poll ticks from each calling [_executeSwap]. Set + /// synchronously before the first await in a tick; released in `finally` on + /// every path out of a tick (success, max-attempts, isClosed, transient + /// error) so a later polling cycle never starts with a stuck `true`. + bool _swapInFlight = false; + + /// Test-only visibility into the ETH-poll re-entrancy guard — lets tests prove it is released on + /// every abort/end path of a poll tick (see [_startEthPolling]) instead of only inferring it + /// indirectly from timer behavior. + @visibleForTesting + bool get debugSwapInFlight => _swapInFlight; + + /// 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; + + /// 24 attempts * 5s interval ≈ 2 minutes, mirrors the status-poll budget. + /// Bounds ETH-balance polling so a faucet that never funds (or a hung + /// balance RPC) cannot leave [_swapInFlight] wedged indefinitely. + static const _ethPollMaxAttempts = 24; + + /// Per-request cap on [DfxBlockchainApiService.getEthBalance] so a hung HTTP + /// layer always resolves (as [TimeoutException], treated as transient by the + /// catch path) instead of blocking every subsequent poll tick. + static const _ethPollTimeout = Duration(seconds: 10); + + int _ethPollAttempts = 0; + + /// 40 attempts * 3s interval = 2 minutes. Bounds the poll so a status that + /// never turns terminal (or a backend that keeps erroring) cannot poll + /// forever; beyond this the swap already left the user holding ZCHF, so it + /// surfaces the existing pay-only retry state rather than a new failure mode. + static const _statusPollMaxAttempts = 40; + + /// Bumped every [_startStatusPolling] call. A tick captures the generation + /// active when it starts; if that generation is stale by the time its + /// (possibly slow) request returns, the tick is a leftover from an earlier + /// polling cycle and must not act on the timer/state of a newer cycle. + int _statusPollGeneration = 0; + int _statusPollAttempts = 0; + bool _statusPollInFlight = false; + + /// 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; + + /// Local cap on the pay-leg tx's gasLimit. An ERC20 `transfer` costs roughly + /// 60–80k gas; 200k gives >2× headroom so a legitimate tx is never rejected + /// while still bounding a compromised backend that would set an absurd limit. + static const _maxGasLimit = 200000; + + /// Local cap on the pay-leg tx's declared max total fee + /// (`maxFeePerGas * gasLimit`), in wei (0.05 ETH). Combined with + /// [_maxGasLimit] this still tolerates fee spikes up to ~250 gwei/gas, well + /// above ordinary mainnet congestion, while ensuring a compromised backend + /// can never make the app commit to burning more than 0.05 ETH in fees. + /// `static final` (not `const`): BigInt is not const-constructible in Dart. + static final _maxTotalFeeWei = BigInt.parse('50000000000000000'); + + static const _ethPollInterval = Duration(seconds: 5); + static const _statusPollInterval = Duration(seconds: 3); + + PayProcessCubit({ + required RealUnitPayService payService, + required DfxFaucetService faucetService, + required DfxBlockchainApiService blockchainService, + required WalletService walletService, + required AppStore appStore, + required String paymentLinkId, + required double zchfNeeded, + }) : _payService = payService, + _faucetService = faucetService, + _blockchainService = blockchainService, + _walletService = walletService, + _appStore = appStore, + _paymentLinkId = paymentLinkId, + _zchfNeeded = zchfNeeded, + super(const PayProcessInitial()); + + /// Entry point — called by the view once the user confirms the quote. + Future start() async { + // Capability gate — checked BEFORE any on-chain action: the debug wallet + // cannot produce EIP-1559 signatures, so the irreversible REALU→ZCHF swap + // must never run on it. The backend settles OCP on every environment + // (Sepolia off-PRD, mainnet+L2 on PRD), so there is no environment gate + // here — the flow requests the real quote and surfaces a typed backend + // error if one ever comes back. + if (_appStore.wallet.walletType == WalletType.debug) { + emit(const PayProcessFailure(PayProcessFailureReason.signatureUnsupported)); + return; + } + await _requestSwapQuote(); + } + + Future _requestSwapQuote() async { + try { + emit(const PayProcessPreparingSwap()); + final swap = await _payService.getSwapPaymentInfo( + RealUnitSwapDto.fromTargetAmount(_zchfNeeded * _slippageBuffer), + ); + if (isClosed) return; + _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) { + if (isClosed) return; + 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(); + if (isClosed) return; + _startEthPolling(swap); + } catch (e) { + if (isClosed) return; + emit(PayProcessFailure(PayProcessFailureReason.insufficientEth, message: e.toString())); + } + } + + void _startEthPolling(SwapPaymentInfo swap) { + _ethPollingTimer?.cancel(); + _ethPollAttempts = 0; + _ethPollingTimer = Timer.periodic(_ethPollInterval, (_) async { + if (_swapInFlight) return; + _swapInFlight = true; + _ethPollAttempts++; + try { + final balance = await _blockchainService + .getEthBalance(_appStore.primaryAddress) + .timeout(_ethPollTimeout); + if (isClosed) return; + if (balance >= swap.requiredGasEth) { + _ethPollingTimer?.cancel(); + await _executeSwap(); + } else if (_ethPollAttempts >= _ethPollMaxAttempts) { + _ethPollingTimer?.cancel(); + emit( + const PayProcessFailure( + PayProcessFailureReason.insufficientEth, + message: 'eth balance polling exceeded max attempts', + ), + ); + } + // else: balance still short — falls through to `finally`, which releases + // `_swapInFlight` so the next tick can retry. + } catch (_) { + if (isClosed) return; + if (_ethPollAttempts >= _ethPollMaxAttempts) { + _ethPollingTimer?.cancel(); + emit( + const PayProcessFailure( + PayProcessFailureReason.insufficientEth, + message: 'eth balance polling exceeded max attempts', + ), + ); + return; + } + // keep polling on transient errors (including per-request timeout) — `finally` below + // releases `_swapInFlight`. + } finally { + // Every path out of this tick (success+swap-triggered, max-attempts emit, isClosed + // abort, transient-error retry) must release the guard — otherwise a stuck `true` from + // one polling cycle silently wedges every tick of a LATER cycle (`_startEthPolling` + // never resets this itself). Safe even on the swap-triggered success path: the timer for + // THIS cycle is already cancelled above before `_executeSwap()` runs. + _swapInFlight = false; + } + }); + } + + Future _executeSwap() async { + final swap = _swap; + if (swap == null) return; + try { + emit(const PayProcessSwapping()); + final unsigned = await _payService.createSwapUnsignedTransaction(swap.id); + if (isClosed) return; + final signed = await _signTransaction(unsigned.swap); + if (isClosed) return; + await _payService.broadcastSwapTransaction(swap.id, signed); + if (isClosed) return; + // The swap is now irreversible — the user holds ZCHF. From here every + // recovery path retries the PAY leg only; the swap is never redone. + _swapCompleted = true; + _acquiredZchf = swap.estimatedAmount; + await _refreshQuoteAndPay(); + } on PaySignatureUnsupportedException { + if (isClosed) return; + emit(const PayProcessFailure(PayProcessFailureReason.signatureUnsupported)); + } on BitboxNotConnectedException { + if (isClosed) return; + emit(const PayProcessFailure(PayProcessFailureReason.bitboxRequired)); + } catch (e) { + if (isClosed) return; + 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. + if (isClosed) return; + emit(PayProcessPayRetry(PayRetryReason.transient, message: e.toString())); + return; + } + + if (isClosed) 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. + // + // Comparison prefers exact plain-decimal strings on the fresh side + // ([LnurlpTransferAssetDto.rawAmount]) vs. [_acquiredZchf].toString(). Note + // that [_acquiredZchf] is still ultimately double-derived upstream + // (swap.estimatedAmount via RealUnitSwapPaymentInfoDto — out of this fix's + // scope); this only removes binary-comparison artifacts at the comparison + // site itself and gives exact precision on the freshZchf side, without + // pretending end-to-end exactness. When the raw string is missing or a plain-decimal + // comparison cannot be performed (parsing throws), the comparison is fail-closed: + // [_settlementExceedsAcquired] returns `true` (= exceeds acquired), triggering the + // [PayRetryReason.insufficientZchf] retry — never treated as "covered". + final freshZchf = _zchfTransferAmount(details); + if (freshZchf != null && + _settlementExceedsAcquired(freshZchf.amount, freshZchf.raw, _acquiredZchf)) { + emit( + PayProcessPayRetry( + PayRetryReason.insufficientZchf, + message: + 'fresh settlement ${freshZchf.amount} 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), + ); + if (isClosed) return; + _validatePayUnsignedTx(unsigned); + final signed = await _signTransaction(unsigned.unsignedTx); + if (isClosed) return; + final txId = await _payService.submitPay( + RealUnitOcpPaySubmitDto( + unsignedTx: signed.unsignedTx, + r: signed.r, + s: signed.s, + v: signed.v, + paymentLinkId: _paymentLinkId, + quoteId: quoteId, + ), + ); + if (isClosed) return; + emit(PayProcessAwaitingSettlement(txId)); + _startStatusPolling(); + } on PayUnsignedTxMismatchException catch (e) { + // The backend's own unsigned tx does not match its own metadata — never sign it. The swap + // already happened; recovery retries the pay leg, which re-fetches AND re-validates a fresh + // unsigned tx from scratch, so a bad tx can never slip through on retry. + if (isClosed) return; + emit(PayProcessPayRetry(PayRetryReason.unsignedTxMismatch, message: e.toString())); + } 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. + if (isClosed) return; + 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. + /// Also surfaces the raw JSON amount string when present for exact decimal + /// comparison (see [_settlementExceedsAcquired]). + static ({double amount, String? raw})? _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') continue; + final amount = asset.amount; + if (amount == null) return null; + return (amount: amount, raw: asset.rawAmount); + } + } + return null; + } + + /// True when [freshAmount] strictly exceeds [acquired] — or when an exact plain-decimal + /// comparison cannot be established at all. Never falls back to a rounding-prone double `>` + /// comparison: doing so could wrongly report "not exceeding" (falsely "covered") when the true + /// decimal values differ. Fail-closed: any inability to prove exact coverage is treated as + /// "exceeds acquired", which routes the caller into the existing retryable + /// [PayRetryReason.insufficientZchf] path rather than risking an under-swapped settlement. + static bool _settlementExceedsAcquired( + double freshAmount, + String? freshRaw, + double acquired, + ) { + if (freshRaw == null) { + return true; + } + final acquiredRaw = acquired.toString(); + try { + return comparePlainDecimalStrings(freshRaw, acquiredRaw) > 0; + } on FormatException { + // Not a plain decimal on one/both sides (e.g. scientific notation from double.toString) — + // cannot prove exact coverage. Fail closed rather than falling back to a rounding-prone + // double comparison that could wrongly say "covered". + return true; + } + } + + /// Locally re-derives the security-relevant fields of the pay-leg [unsigned] raw tx (the ZCHF + /// ERC20-transfer `to`/recipient/amount/chainId/gas/fees) from the RLP bytes themselves and + /// checks them against the DTO metadata, the app's locally configured chainId + /// (`apiConfig.asset.chainId`), and local gas/fee caps BEFORE the pay tx is signed. Scope is + /// the pay leg only — the earlier REALU→ZCHF swap (`RealUnitSwapUnsignedTransactionDto`) is + /// signed without an equivalent decode+validate step today (that DTO has no comparable + /// metadata; closing the gap needs a backend extension). The backend is untrusted for this: a + /// compromised/buggy backend must never be able to make the app sign a pay transfer to the + /// wrong token, recipient, amount, chain, or with unbounded fees. Throws + /// [PayUnsignedTxMismatchException] fail-closed on any mismatch or structural anomaly. + void _validatePayUnsignedTx(RealUnitOcpPayUnsignedTransactionDto unsigned) { + final tx = Eip1559UnsignedTxDecoder.decode(unsigned.unsignedTx); + + // Network + fee sanity (DTO self-consistency, local trusted chain, gas/fee caps) + // before content checks (to / recipient / amount). + final expectedChainId = BigInt.from(unsigned.chainId); + if (tx.chainId != expectedChainId) { + throw PayUnsignedTxMismatchException( + 'chainId mismatch: tx=${tx.chainId} dto=${unsigned.chainId}', + ); + } + + // Independent of the DTO: bind against the chainId baked into the app build + // (same value `_signTransaction` passes to `signToSignature`). A compromised + // backend that returns a self-consistent but wrong chainId cannot pass this. + final localChainId = BigInt.from(_appStore.apiConfig.asset.chainId); + if (tx.chainId != localChainId) { + throw PayUnsignedTxMismatchException( + 'chainId ${tx.chainId} does not match locally configured chain $localChainId ' + '(apiConfig.asset.chainId) — refusing to sign for an unexpected network', + ); + } + + if (tx.gasLimit > BigInt.from(_maxGasLimit)) { + throw PayUnsignedTxMismatchException( + 'unsigned tx gasLimit ${tx.gasLimit} exceeds local cap $_maxGasLimit', + ); + } + final totalFeeWei = tx.maxFeePerGas * tx.gasLimit; + if (totalFeeWei > _maxTotalFeeWei) { + throw PayUnsignedTxMismatchException( + 'unsigned tx max total fee $totalFeeWei wei exceeds local cap $_maxTotalFeeWei wei', + ); + } + + if (tx.value != BigInt.zero) { + throw PayUnsignedTxMismatchException( + 'unsigned tx sends native value ${tx.value}, expected 0', + ); + } + + final expectedToken = _normalizeAddress(unsigned.tokenAddress, 'tokenAddress'); + if (tx.to != expectedToken) { + throw PayUnsignedTxMismatchException( + 'tokenAddress mismatch: tx.to=${tx.to} dto.tokenAddress=${unsigned.tokenAddress}', + ); + } + + final transfer = Erc20TransferCalldataDecoder.decode(tx.data); + + final expectedRecipient = _normalizeAddress(unsigned.recipient, 'recipient'); + if (transfer.recipient != expectedRecipient) { + throw PayUnsignedTxMismatchException( + 'recipient mismatch: calldata=${transfer.recipient} dto.recipient=${unsigned.recipient}', + ); + } + + final BigInt expectedAmount; + try { + expectedAmount = BigInt.parse(unsigned.amountWei); + } on FormatException { + throw PayUnsignedTxMismatchException('amountWei is not a valid integer: ${unsigned.amountWei}'); + } + if (transfer.amountWei != expectedAmount) { + throw PayUnsignedTxMismatchException( + 'amount mismatch: calldata=${transfer.amountWei} dto.amountWei=${unsigned.amountWei}', + ); + } + } + + /// Normalizes an address DTO field to `0x` + 40 lowercase hex chars, or throws + /// [PayUnsignedTxMismatchException] if it isn't one — refusing to compare against a malformed + /// address is safer than silently truncating/padding it. + static String _normalizeAddress(String raw, String fieldName) { + final hex = (raw.startsWith('0x') || raw.startsWith('0X')) ? raw.substring(2) : raw; + if (!RegExp(r'^[0-9a-fA-F]{40}$').hasMatch(hex)) { + throw PayUnsignedTxMismatchException('$fieldName is not a valid 20-byte address: $raw'); + } + return '0x${hex.toLowerCase()}'; + } + + void _startStatusPolling() { + _statusPollingTimer?.cancel(); + final generation = ++_statusPollGeneration; + _statusPollAttempts = 0; + _statusPollInFlight = false; + _statusPollingTimer = Timer.periodic(_statusPollInterval, (_) async { + if (generation != _statusPollGeneration || _statusPollInFlight) return; + _statusPollInFlight = true; + try { + final status = await _payService.getPayStatus(_paymentLinkId); + if (isClosed || generation != _statusPollGeneration) return; + _statusPollAttempts++; + if (!status.status.isTerminal) { + if (_statusPollAttempts >= _statusPollMaxAttempts) { + _statusPollingTimer?.cancel(); + emit( + const PayProcessPayRetry( + PayRetryReason.transient, + message: 'status polling exceeded max attempts', + ), + ); + return; + } + _statusPollInFlight = false; + return; + } + _statusPollingTimer?.cancel(); + if (status.status.isCompleted) { + emit(const PayProcessSuccess()); + } else { + emit(const PayProcessPayRetry(PayRetryReason.transient)); + } + } catch (_) { + if (isClosed || generation != _statusPollGeneration) return; + _statusPollAttempts++; + if (_statusPollAttempts >= _statusPollMaxAttempts) { + _statusPollingTimer?.cancel(); + emit( + const PayProcessPayRetry( + PayRetryReason.transient, + message: 'status polling exceeded max attempts', + ), + ); + return; + } + _statusPollInFlight = false; + } + }); + } + + /// 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..9f501ff68 --- /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, + + /// 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, + + /// The unsigned tx the backend returned for signing did not match its own security metadata + /// (token/recipient/amount/chain) — see [PayUnsignedTxMismatchException]. Never signed. Retrying + /// re-fetches AND re-validates a fresh unsigned tx from scratch. + unsignedTxMismatch, +} + +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..4e0d7e07d --- /dev/null +++ b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart @@ -0,0 +1,80 @@ +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/models/payment/pay/dto/real_unit_swap_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; + +part 'pay_quote_state.dart'; + +/// Reads the public OCP payment-link quote (`GET /v1/lnurlp/:id`) and surfaces +/// the requested fiat amount + the exact ZCHF amount the Ethereum method +/// requires. The amount comes from the API `transferAmounts` (ZCHF on the +/// Ethereum entry) — the app never computes it. An expired quote surfaces as a +/// typed state so the view can prompt a re-scan. +class PayQuoteCubit extends Cubit { + final RealUnitPayService _payService; + final String _paymentLinkId; + + PayQuoteCubit(this._payService, this._paymentLinkId) : super(const PayQuoteLoading()); + + Future load() async { + emit(const PayQuoteLoading()); + + try { + final details = await _payService.getPaymentDetails(_paymentLinkId); + if (isClosed) return; + + if (details.quote.expiration.isBefore(DateTime.now())) { + emit(const PayQuoteExpired()); + return; + } + + final zchfAmount = _zchfTransferAmount(details); + if (zchfAmount == null) { + emit(const PayQuoteUnavailable()); + return; + } + + // Preview-only swap quote using the plain bill ZCHF amount (no slippage + // buffer). Shown on the pay-quote screen so the user sees expected REALU + // sold, ZCHF proceeds and fees for this bill before confirming. The + // actual swap executed later in PayProcessCubit re-requests its own quote + // independently with its own slippage buffer and remains the sole source + // of truth for what is actually swapped. + final swap = await _payService.getSwapPaymentInfo( + RealUnitSwapDto.fromTargetAmount(zchfAmount), + ); + if (isClosed) return; + + emit( + PayQuoteReady( + paymentLinkId: _paymentLinkId, + quoteId: details.quote.id, + fiatAsset: details.requestedAmount.asset, + fiatAmount: details.requestedAmount.amount, + zchfAmount: zchfAmount, + merchantName: details.recipient?.name, + merchantCity: details.recipient?.city, + realuAmount: swap.amount, + realuEstimatedZchf: swap.estimatedAmount, + realuFeesTotal: swap.feesTotal, + ), + ); + } catch (e) { + if (isClosed) return; + 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..8a62dc347 --- /dev/null +++ b/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart @@ -0,0 +1,71 @@ +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; + final String? merchantName; + final String? merchantCity; + final double? realuAmount; + final double? realuEstimatedZchf; + final double? realuFeesTotal; + + const PayQuoteReady({ + required this.paymentLinkId, + required this.quoteId, + required this.fiatAsset, + required this.fiatAmount, + required this.zchfAmount, + this.merchantName, + this.merchantCity, + this.realuAmount, + this.realuEstimatedZchf, + this.realuFeesTotal, + }); + + @override + List get props => [ + paymentLinkId, + quoteId, + fiatAsset, + fiatAmount, + zchfAmount, + merchantName, + merchantCity, + realuAmount, + realuEstimatedZchf, + realuFeesTotal, + ]; +} + +/// The quote attached to the scanned link has expired — the user must re-scan. +class PayQuoteExpired extends PayQuoteState { + const PayQuoteExpired(); +} + +/// The payment link offers no Ethereum/ZCHF transfer method. +class PayQuoteUnavailable extends PayQuoteState { + const PayQuoteUnavailable(); +} + +class PayQuoteError extends PayQuoteState { + final String message; + + const PayQuoteError(this.message); + + @override + List get props => [message]; +} diff --git a/lib/screens/pay/cubits/pay_scan/pay_scan_cubit.dart b/lib/screens/pay/cubits/pay_scan/pay_scan_cubit.dart new file mode 100644 index 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..23ac7f8ff --- /dev/null +++ b/lib/screens/pay/pay_process_page.dart @@ -0,0 +1,213 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/styles/colors.dart'; + +class PayProcessPage extends StatelessWidget { + final String paymentLinkId; + final double zchfNeeded; + + const PayProcessPage({ + super.key, + required this.paymentLinkId, + required this.zchfNeeded, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => PayProcessCubit( + payService: getIt(), + faucetService: getIt(), + blockchainService: getIt(), + walletService: getIt(), + appStore: getIt(), + paymentLinkId: paymentLinkId, + zchfNeeded: zchfNeeded, + )..start(), + child: const PayProcessView(), + ); + } +} + +class PayProcessView extends StatelessWidget { + const PayProcessView({super.key}); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (previous, current) => + current is PayProcessSuccess || + current is PayProcessFailure || + current is PayProcessPayRetry, + listener: (context, state) async { + if (state is PayProcessSuccess) { + await _showResultSheet( + context, + icon: Icons.check_circle_rounded, + title: S.of(context).paySuccess, + description: S.of(context).paySuccessDescription, + ); + } else if (state is PayProcessPayRetry) { + // The swap already succeeded — offer to retry the PAY leg only. The + // ZCHF stays in the wallet; this never re-swaps. + await _showRetrySheet(context, state.reason); + } else if (state is PayProcessFailure) { + await _showResultSheet( + context, + icon: Icons.error_rounded, + title: S.of(context).payFailureTitle, + description: _failureMessage(context, state.reason), + ); + } + }, + builder: (context, state) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).pay)), + body: SafeArea( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 24, + children: [ + const CupertinoActivityIndicator(radius: 16), + Text( + _progressLabel(context, state), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ), + ); + }, + ); + } + + String _progressLabel(BuildContext context, PayProcessState state) => switch (state) { + PayProcessInitial() || PayProcessPreparingSwap() => S.of(context).payPreparingSwap, + PayProcessWaitingForEth() => S.of(context).payWaitingForEth, + PayProcessSwapping() => S.of(context).paySwapping, + PayProcessRefreshingQuote() => S.of(context).payRefreshingQuote, + PayProcessPaying() => S.of(context).payPaying, + PayProcessAwaitingSettlement() => S.of(context).payAwaitingSettlement, + PayProcessSuccess() => S.of(context).paySuccess, + PayProcessPayRetry() => S.of(context).payRetryTitle, + PayProcessFailure() => S.of(context).payFailureTitle, + }; + + String _failureMessage(BuildContext context, PayProcessFailureReason reason) => switch (reason) { + PayProcessFailureReason.insufficientZchf => S.of(context).payFailureInsufficientZchf, + PayProcessFailureReason.insufficientEth => S.of(context).payFailureInsufficientEth, + PayProcessFailureReason.signatureUnsupported => S.of(context).payFailureSignatureUnsupported, + PayProcessFailureReason.bitboxRequired => S.of(context).payFailureBitboxRequired, + PayProcessFailureReason.generic => S.of(context).payFailureGeneric, + }; + + String _retryMessage(BuildContext context, PayRetryReason reason) => switch (reason) { + PayRetryReason.quoteExpired => S.of(context).payRetryQuoteExpired, + PayRetryReason.transient => S.of(context).payRetryTransient, + PayRetryReason.insufficientZchf => S.of(context).payRetryInsufficientZchf, + PayRetryReason.unsignedTxMismatch => S.of(context).payRetryUnsignedTxMismatch, + }; + + 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..df9c33703 --- /dev/null +++ b/lib/screens/pay/pay_quote_page.dart @@ -0,0 +1,210 @@ +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/packages/utils/default_assets.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'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; + +class PayQuotePage extends StatelessWidget { + final String paymentLinkId; + + const PayQuotePage({super.key, required this.paymentLinkId}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => PayQuoteCubit(getIt(), paymentLinkId)..load(), + child: const PayQuoteView(), + ); + } +} + +class PayQuoteView extends StatelessWidget { + const PayQuoteView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).payQuoteTitle)), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: BlocBuilder( + builder: (context, state) => switch (state) { + PayQuoteLoading() => const Center(child: CupertinoActivityIndicator()), + PayQuoteReady() => _PayQuoteReadyView(state: state), + PayQuoteExpired() => _PayQuoteMessage(message: S.of(context).payFailureQuoteExpired), + PayQuoteUnavailable() => _PayQuoteMessage(message: S.of(context).payQuoteUnavailable), + PayQuoteError() => _PayQuoteMessage( + message: S.of(context).payFailureGeneric, + onRetry: () => context.read().load(), + ), + }, + ), + ), + ), + ); + } +} + +class _PayQuoteReadyView extends StatefulWidget { + final PayQuoteReady state; + + const _PayQuoteReadyView({required this.state}); + + @override + State<_PayQuoteReadyView> createState() => _PayQuoteReadyViewState(); +} + +class _PayQuoteReadyViewState extends State<_PayQuoteReadyView> { + bool _navigating = false; + + @override + Widget build(BuildContext context) { + final state = widget.state; + return ScrollableActionsLayout( + centerBody: true, + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 24, + children: [ + Text( + S + .of(context) + .payQuoteSummary( + state.fiatAmount.toStringAsFixed(2), + state.fiatAsset, + ), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium, + ), + if (state.merchantName != null) ...[ + _AmountRow( + label: S.of(context).payQuoteMerchant, + value: state.merchantCity != null + ? '${state.merchantName}, ${state.merchantCity}' + : state.merchantName!, + ), + ], + _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', + ), + if (state.realuAmount != null) ...[ + _AmountRow( + label: S.of(context).payQuoteRealuAmount, + value: '${state.realuAmount!.toStringAsFixed(0)} ${realUnitAsset.symbol}', + ), + ], + if (state.realuEstimatedZchf != null) ...[ + _AmountRow( + label: S.of(context).payQuoteRealuEstimated, + value: '${state.realuEstimatedZchf!.toStringAsFixed(2)} ZCHF', + ), + ], + if (state.realuFeesTotal != null) ...[ + _AmountRow( + label: S.of(context).payQuoteRealuFees, + value: '${state.realuFeesTotal!.toStringAsFixed(2)} ${realUnitAsset.symbol}', + ), + ], + ], + ), + actions: [ + FilledButton( + onPressed: _navigating + ? null + : () { + setState(() => _navigating = true); + 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, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Flexible( + child: Text( + label, + softWrap: true, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + ), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + softWrap: true, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ); + } +} + +class _PayQuoteMessage extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + + const _PayQuoteMessage({required this.message, this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + if (onRetry != null) ...[ + const SizedBox(height: 16), + OutlinedButton( + key: const ValueKey('payQuoteRetryButton'), + onPressed: onRetry, + child: Text(S.of(context).retry), + ), + ], + ], + ), + ); + } +} diff --git a/lib/screens/pay/pay_scan_page.dart b/lib/screens/pay/pay_scan_page.dart new file mode 100644 index 000000000..7d982ac5a --- /dev/null +++ b/lib/screens/pay/pay_scan_page.dart @@ -0,0 +1,120 @@ +// @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/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_quote_page.dart'; +import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/scanner/qr_scanner_view.dart'; + +class PayScanPage extends StatelessWidget { + /// A DFX OpenCryptoPay payload already extracted from a payment deeplink + /// (e.g. `lightning:LNURL1...`), fed into the cubit as if it had been + /// scanned. Null for the normal live-camera entry (dashboard "Pay" button). + final String? initialPayload; + + const PayScanPage({super.key, this.initialPayload}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => PayScanCubit(), + child: PayScanView(initialPayload: initialPayload), + ); + } +} + +class PayScanView extends StatefulWidget { + final String? initialPayload; + + const PayScanView({super.key, this.initialPayload}); + + @override + State createState() => _PayScanViewState(); +} + +class _PayScanViewState extends State { + /// True while waiting for the one-shot [initialPayload] decode; drives the + /// spinner so it can be dismissed after invalid/decoded (unlike the + /// immutable [PayScanView.initialPayload] widget field). + bool _awaitingInitialDecode = false; + + @override + void initState() { + super.initState(); + final payload = widget.initialPayload; + if (payload == null) return; + _awaitingInitialDecode = true; + // Deferred to after the first frame: BlocConsumer below only subscribes + // during that first build. Calling onCodeDetected synchronously here + // (before it subscribes) would emit PayScanDecoded/Invalid before + // anything is listening — BlocConsumer would just pick it up as its + // INITIAL state on first build, and its `listener` callback never fires + // for the initial state, only for later transitions. Deferring one frame + // guarantees the listener is already subscribed when the emission + // happens, so the PayQuotePage push / invalid-snackbar actually fires. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().onCodeDetected(payload); + }); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + if (state is PayScanDecoded) { + setState(() => _awaitingInitialDecode = false); + 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) { + setState(() => _awaitingInitialDecode = false); + 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: _awaitingInitialDecode + ? const Center(child: CupertinoActivityIndicator()) + : QrScannerView( + onDetect: (raw) => context.read().onCodeDetected(raw), + errorBuilder: (context, error) { + final message = error.errorCode == MobileScannerErrorCode.permissionDenied + ? S.of(context).payScanCameraPermissionDenied + : S.of(context).payScanCameraUnavailable; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500), + ), + ), + ); + }, + ), + ); + }, + ); + } +} diff --git a/lib/screens/pin/bloc/auth/pin_auth_cubit.dart b/lib/screens/pin/bloc/auth/pin_auth_cubit.dart index c4e321efc..1a6ed44ac 100644 --- a/lib/screens/pin/bloc/auth/pin_auth_cubit.dart +++ b/lib/screens/pin/bloc/auth/pin_auth_cubit.dart @@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/storage/secure_storage.dart'; import 'package:realunit_wallet/screens/pin/constants/pin_constants.dart'; +import 'package:realunit_wallet/setup/routing/boot_navigation.dart'; part 'pin_auth_state.dart'; @@ -79,6 +80,9 @@ class PinAuthCubit extends Cubit { _secureStorage.resetPinLockout(), ]); _resumeLocation = null; + // A stashed payment deeplink must not survive into a freshly reset / + // re-onboarded wallet (Forgot PIN / Delete Wallet). + clearPendingPaymentDeeplink(); emit(const PinAuthState()); } } diff --git a/lib/screens/pin/verify_pin_page.dart b/lib/screens/pin/verify_pin_page.dart index 702e8a0d6..3cc7ab698 100644 --- a/lib/screens/pin/verify_pin_page.dart +++ b/lib/screens/pin/verify_pin_page.dart @@ -167,11 +167,14 @@ class _VerifyPinViewState extends State { .pinVerifyLockedTemporarily( _formatRemaining(s.lockedUntil), ), - VerifyPinLocked _ => S.of(context).pinVerifyLocked, // The 'Forgot PIN?' button only exists in - // the appLock variant (bottom != null); - // gate flows get a text without that - // dangling button reference. + // the appLock variant (bottom != null); gate + // flows get a text without that dangling + // button reference — both the permanent-lock + // and the unverifiable messages branch on it. + VerifyPinLocked _ => widget.bottom != null + ? S.of(context).pinVerifyLocked + : S.of(context).pinVerifyLockedGate, VerifyPinUnverifiable _ => widget.bottom != null ? S.of(context).pinVerifyUnverifiable : S.of(context).pinVerifyUnverifiableGate, 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..6923478fa --- /dev/null +++ b/lib/screens/send/cubits/send_process/send_process_cubit.dart @@ -0,0 +1,259 @@ +import 'dart:developer' as developer; + +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/buy_exceptions.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'; + +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. +/// +/// Prepare and confirm are split so a failed confirm can be retried against the +/// same server-assigned transfer `id` (DFXswiss/api#3820 keys idempotency off +/// that `id`). A new prepare would mint a second on-chain transfer intent. +/// +/// 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; + + /// Successfully prepared transfer intent (carries the server-assigned `id`). + /// Stored for the lifetime of the cubit so [retryConfirm] can re-confirm the + /// same intent without a second prepare. + RealUnitTransferPaymentInfoDto? _preparedInfo; + + /// Guards against concurrent confirm attempts (start + retry, or double-tap). + bool _confirmInFlight = false; + + 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; + } + + // Phase 1 — prepare. Failures here are terminal and non-retryable: no + // transfer `id` was ever obtained, so there is nothing safe to retry. + // Classification into a local failure state happens in the try/catch; + // emission is gated once after so a closed cubit never emits. + SendProcessState? prepareFailure; + try { + emit(const SendProcessPreparing()); + final info = await _transferService.prepareTransfer( + RealUnitTransferDto(toAddress: _recipient, amount: _amount), + ); + if (isClosed) { + return; + } + _preparedInfo = info; + } on TransferSignatureUnsupportedException { + prepareFailure = const SendProcessFailure(SendProcessFailureReason.signatureUnsupported); + } on TransferGasFundingUnavailableException { + prepareFailure = const SendProcessFailure(SendProcessFailureReason.gasFundingUnavailable); + } on SigningCancelledException { + prepareFailure = const SendProcessFailure(SendProcessFailureReason.signatureCancelled); + } on BitboxNotConnectedException { + prepareFailure = const SendProcessFailure(SendProcessFailureReason.signatureUnsupported); + } on RegistrationRequiredException catch (e) { + prepareFailure = SendProcessFailure( + SendProcessFailureReason.registrationOrKycRequired, + message: e.message, + ); + } on KycLevelRequiredException catch (e) { + prepareFailure = SendProcessFailure( + SendProcessFailureReason.registrationOrKycRequired, + message: e.message, + ); + } on ApiException catch (e) { + // The API is the authority on recipient/amount/eligibility. Render its + // signaled reason rather than re-deriving limits locally. + prepareFailure = SendProcessFailure(_reasonForApi(e), message: e.message); + } catch (e) { + prepareFailure = SendProcessFailure(SendProcessFailureReason.generic, message: e.toString()); + } + + if (prepareFailure != null) { + if (isClosed) { + return; + } + emit(prepareFailure); + return; + } + + // Phase 2 — confirm against the stored prepare result. + await _confirmPrepared(); + } + + /// Re-signs and re-PUTs `/confirm` for the same prepared transfer `id`. + /// + /// Safe because the backend is idempotent per id: re-signing the same message + /// is not a new transfer. Fails loud when there is nothing prepared, when a + /// confirm is already in flight, or when the cubit is not in a retryable + /// failure state. + Future retryConfirm() async { + if (_preparedInfo == null) { + throw StateError('Cannot retry confirm: no prepared transfer info is stored'); + } + if (_confirmInFlight) { + throw StateError('Cannot retry confirm: a confirm is already in flight'); + } + final current = state; + if (current is! SendProcessFailure || !current.canRetry) { + throw StateError('Cannot retry confirm: cubit is not in a retryable failure state'); + } + await _confirmPrepared(); + } + + /// Shared confirm path used by [start] and [retryConfirm]. Never calls + /// prepare — only signs + PUTs against [_preparedInfo]. + /// + /// Callers ([start] after a successful prepare, [retryConfirm] after its own + /// null-check) guarantee [_preparedInfo] is non-null. Classification of the + /// confirm outcome into a single [SendProcessState] happens in the try/catch; + /// emission is gated once after [finally] so a closed cubit never emits. + Future _confirmPrepared() async { + final info = _preparedInfo!; + if (_confirmInFlight) { + throw StateError('Cannot confirm: a confirm is already in flight'); + } + + _confirmInFlight = true; + late SendProcessState nextState; + // Set only on the direct success path (confirmTransfer resolves); used for + // the diagnostic log when the cubit closed before that success could emit. + String? directSuccessTxHash; + try { + emit(const SendProcessSigning()); + final txHash = await _transferService.confirmTransfer( + info, + confirmedRecipient: _recipient, + confirmedAmount: _amount, + ); + directSuccessTxHash = txHash; + nextState = SendProcessSuccess(txHash); + } on TransferAlreadyConfirmedException catch (e) { + // An earlier confirm for this id landed; treat as success. Prefer the + // server-supplied txHash when present; empty string when the 409 body + // carried none (the success sheet does not display the hash — empty is + // the intentional "no hash available" representation, not a fake hash). + final alreadyConfirmedTxHash = e.txHash; + if (alreadyConfirmedTxHash != null) { + nextState = SendProcessSuccess(alreadyConfirmedTxHash); + } else { + nextState = const SendProcessSuccess(''); + } + } on TransferConfirmMismatchException catch (e) { + nextState = SendProcessFailure( + SendProcessFailureReason.confirmMismatch, + message: e.toString(), + ); + } on TransferSignatureUnsupportedException { + nextState = const SendProcessFailure(SendProcessFailureReason.signatureUnsupported); + } on TransferGasFundingUnavailableException { + nextState = const SendProcessFailure(SendProcessFailureReason.gasFundingUnavailable); + } on SigningCancelledException { + nextState = const SendProcessFailure(SendProcessFailureReason.signatureCancelled); + } on BitboxNotConnectedException { + nextState = const SendProcessFailure(SendProcessFailureReason.signatureUnsupported); + } on RegistrationRequiredException catch (e) { + nextState = SendProcessFailure( + SendProcessFailureReason.registrationOrKycRequired, + message: e.message, + ); + } on KycLevelRequiredException catch (e) { + nextState = SendProcessFailure( + SendProcessFailureReason.registrationOrKycRequired, + message: e.message, + ); + } on ApiException catch (e) { + // Definitive client/server business failures are terminal. Other API + // status codes (e.g. 500) may be transient after a confirm that already + // has a known-good id — offer retry against the same intent. + nextState = SendProcessFailure( + _reasonForApi(e), + message: e.message, + canRetry: !_isDefinitiveApiFailure(e), + ); + } catch (e) { + // Transport/timeout/unclassified: id is known-good; allow retryConfirm. + nextState = SendProcessFailure( + SendProcessFailureReason.generic, + message: e.toString(), + canRetry: true, + ); + } finally { + _confirmInFlight = false; + } + + if (isClosed) { + if (directSuccessTxHash != null) { + developer.log( + 'SendProcessCubit: transfer succeeded (txHash=$directSuccessTxHash) after the cubit was closed — ' + 'result could not be emitted', + ); + } + return; + } + emit(nextState); + } + + /// 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]. A 403 (including + /// unmapped compliance codes) maps to [registrationOrKycRequired]. + static SendProcessFailureReason _reasonForApi(ApiException e) { + if (e.statusCode == 503) { + return SendProcessFailureReason.gasFundingUnavailable; + } + if (e.statusCode == 403) { + return SendProcessFailureReason.registrationOrKycRequired; + } + if (e.statusCode == 400 || e.statusCode == 404) { + return SendProcessFailureReason.invalidRequest; + } + return SendProcessFailureReason.generic; + } + + /// Definitive API failures that must not be retried via [retryConfirm]. + static bool _isDefinitiveApiFailure(ApiException e) { + final statusCode = e.statusCode; + return statusCode == 400 || + statusCode == 403 || + statusCode == 404 || + statusCode == 503; + } +} 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..7bcafda19 --- /dev/null +++ b/lib/screens/send/cubits/send_process/send_process_state.dart @@ -0,0 +1,82 @@ +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, + + /// The API requires the user to complete registration or a higher KYC level + /// before this transfer can proceed (403 REGISTRATION_REQUIRED / + /// KYC_LEVEL_REQUIRED). + registrationOrKycRequired, + + /// The prepare response's recipient/amount did not match what the user + /// confirmed on-screen. Fail-closed before signing; not retryable with the + /// same prepared intent (the server-echoed data itself is wrong). + confirmMismatch, + + /// 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; + + /// When true, the prepared transfer `id` is retained and the user may call + /// [SendProcessCubit.retryConfirm] to re-sign and re-PUT `/confirm` for the + /// same intent (no new prepare). Defaults to false — a UI affordance flag, + /// not a data-correctness fallback. + final bool canRetry; + + const SendProcessFailure(this.reason, {this.message, this.canRetry = false}); + + @override + List get props => [reason, message, canRetry]; +} 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..d98e3556b --- /dev/null +++ b/lib/screens/send/cubits/send_recipient/send_recipient_cubit.dart @@ -0,0 +1,96 @@ +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. EIP-681 +/// function-call URIs of the form `…/transfer?address=0x…` extract the recipient +/// from the single non-empty `address` query parameter (never from the token +/// contract before `/`); zero, multiple, or empty `address=` values — and any +/// other function-call form — are rejected as unrecognized/ambiguous. +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. Parsing and decoding exceptions are + /// caught and fail closed into [SendRecipientInvalid]. + void submit(String input) { + try { + final address = _extractAddress(input); + if (address == null) { + emit(SendRecipientInvalid(InvalidRecipientAddressException(input.trim()))); + return; + } + 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 optional `pay-` payment + /// marker, then extracts the recipient address. Simple form (`0x…[@chainId][?…]`, + /// no `/`): the substring before the first `@` or `?`. Function-call form + /// (`…/transfer?address=0x…`): exactly one non-empty `address` query value + /// when the function name is `transfer`; zero, multiple, or empty values — + /// and any other function-call form — return `null` (fail closed). + static String? _extractAddress(String input) { + var value = input.trim(); + if (value.toLowerCase().startsWith('ethereum:')) { + value = value.substring('ethereum:'.length); + } + if (value.toLowerCase().startsWith('pay-')) { + value = value.substring('pay-'.length); + } + + final slash = value.indexOf('/'); + if (slash != -1) { + // Function-call form: text after `/` up to `?` (or end) is the function name. + final afterSlash = value.substring(slash + 1); + final queryStart = afterSlash.indexOf('?'); + final functionName = queryStart == -1 ? afterSlash : afterSlash.substring(0, queryStart); + + if (functionName == 'transfer' && queryStart != -1) { + final query = afterSlash.substring(queryStart + 1); + final addresses = Uri(query: query).queryParametersAll['address']; + if (addresses != null && addresses.length == 1 && addresses.first.trim().isNotEmpty) { + return addresses.first.trim(); + } + } + // Unrecognized/ambiguous function-call form — do not guess an address. + return null; + } + + // Simple form: recipient is the substring before the first `@` or `?`. + final at = value.indexOf('@'); + final query = value.indexOf('?'); + var end = value.length; + if (at != -1) { + end = at; + } + if (query != -1 && query < end) { + end = query; + } + return value.substring(0, end).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..bfcb1acf6 --- /dev/null +++ b/lib/screens/send/send_amount_page.dart @@ -0,0 +1,152 @@ +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'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.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 ScrollableActionsLayout( + body: 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), + ), + ], + ), + actions: [ + 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..22c4ffe80 --- /dev/null +++ b/lib/screens/send/send_confirm_page.dart @@ -0,0 +1,114 @@ +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'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; + +/// Third step: review the recipient + amount before signing. Confirming starts +/// the on-chain process step. +class SendConfirmPage extends StatefulWidget { + final String recipient; + final int amount; + + const SendConfirmPage({super.key, required this.recipient, required this.amount}); + + @override + State createState() => _SendConfirmPageState(); +} + +class _SendConfirmPageState extends State { + /// Once true, permanently disables the confirm button on this route instance + /// so a second transfer cannot be re-triggered after push+pop of the process + /// page (terminal navigation pattern). + bool _navigating = false; + + @override + Widget build(BuildContext context) { + final recipient = widget.recipient; + final amount = widget.amount; + + return Scaffold( + appBar: AppBar(title: Text(S.of(context).sendConfirmTitle)), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: ScrollableActionsLayout( + centerBody: true, + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 24, + children: [ + 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, + ), + ], + ), + actions: [ + FilledButton( + onPressed: _navigating + ? null + : () { + if (_navigating) { + return; + } + setState(() => _navigating = true); + 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, + spacing: 16, + children: [ + Flexible( + child: Text( + label, + softWrap: true, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + ), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + softWrap: true, + 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..7820036c7 --- /dev/null +++ b/lib/screens/send/send_process_page.dart @@ -0,0 +1,224 @@ +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'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.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), + canRetry: state.canRetry, + ); + } + }, + builder: (context, state) { + final canPop = state is SendProcessSuccess; + return PopScope( + canPop: canPop, + child: 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, SendProcessFailure state) => switch (state.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.registrationOrKycRequired => + S.of(context).sendFailureRegistrationOrKycRequired, + SendProcessFailureReason.confirmMismatch => S.of(context).sendFailureConfirmMismatch, + SendProcessFailureReason.generic => S.of(context).sendFailureGeneric, + }; + + /// Shows the terminal result sheet. Returns after the sheet is dismissed. + /// + /// For success and non-retryable failures, Close pops the sheet then this + /// method pops the [SendProcessPage] route (today's behaviour). + /// + /// For retryable failures, Retry dismisses only the sheet and re-invokes + /// [SendProcessCubit.retryConfirm] without popping the page — the stored + /// prepared transfer `id` must stay alive for the same-intent retry. + Future _showResultSheet( + BuildContext context, { + required IconData icon, + required String title, + required String description, + bool canRetry = false, + }) async { + final shouldPopPage = await showModalBottomSheet( + context: context, + isDismissible: false, + builder: (sheetContext) => _SendProcessResultSheet( + icon: icon, + title: title, + description: description, + canRetry: canRetry, + ), + ); + if (!context.mounted) { + return; + } + if (shouldPopPage == false) { + // Retry was chosen — stay on this page and re-confirm the same id. + await context.read().retryConfirm(); + return; + } + // Close (or null) — leave the send-process route. + Navigator.of(context).pop(); + } +} + +/// Terminal result sheet content. Isolates the double-tap lock for Retry so the +/// parent view stays a [StatelessWidget]. +class _SendProcessResultSheet extends StatefulWidget { + final IconData icon; + final String title; + final String description; + final bool canRetry; + + const _SendProcessResultSheet({ + required this.icon, + required this.title, + required this.description, + required this.canRetry, + }); + + @override + State<_SendProcessResultSheet> createState() => _SendProcessResultSheetState(); +} + +class _SendProcessResultSheetState extends State<_SendProcessResultSheet> { + /// Once the user taps Retry, remove the button so a second tap cannot fire + /// another concurrent confirm (mirrors the double-tap-lock discipline used + /// elsewhere in this flow). + bool _retryConsumed = false; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.9, + ), + child: ScrollableActionsLayout( + shrinkWrap: true, + body: Column( + mainAxisSize: MainAxisSize.min, + spacing: 24, + children: [ + Icon(widget.icon, color: RealUnitColors.realUnitBlue, size: 64), + Text(widget.title, style: Theme.of(context).textTheme.headlineMedium), + Text( + widget.description, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + ], + ), + actions: [ + if (widget.canRetry && !_retryConsumed) + FilledButton( + onPressed: () { + // Fail-closed double-tap lock: consume before the pop so a + // second tap cannot schedule another concurrent confirm. + if (_retryConsumed) { + return; + } + setState(() { + _retryConsumed = true; + }); + // false → parent must NOT pop the SendProcessPage route. + Navigator.of(context).pop(false); + }, + child: Text(S.of(context).retry), + ), + FilledButton( + // true → parent pops the SendProcessPage route after the sheet. + onPressed: () => Navigator.of(context).pop(true), + child: Text(S.of(context).close), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/send/send_recipient_page.dart b/lib/screens/send/send_recipient_page.dart new file mode 100644 index 000000000..57a00aab6 --- /dev/null +++ b/lib/screens/send/send_recipient_page.dart @@ -0,0 +1,124 @@ +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'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.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: LayoutBuilder( + builder: (context, constraints) => Column( + children: [ + Expanded( + child: QrScannerView( + onDetect: (raw) => context.read().onCodeDetected(raw), + ), + ), + ConstrainedBox( + constraints: BoxConstraints(maxHeight: constraints.maxHeight), + child: ScrollableActionsLayout( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + body: 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(); + }, + ), + ), + ), + ], + ), + actions: [ + FilledButton( + onPressed: () => + context.read().submit(_controller.text), + child: Text(S.of(context).next), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/support/subpages/support_tickets_page.dart b/lib/screens/support/subpages/support_tickets_page.dart index b7a09056e..2a8e81691 100644 --- a/lib/screens/support/subpages/support_tickets_page.dart +++ b/lib/screens/support/subpages/support_tickets_page.dart @@ -52,6 +52,10 @@ class SupportTicketsView extends StatelessWidget { separatorBuilder: (_, _) => const SizedBox(height: 12), itemBuilder: (context, index) { final ticket = tickets.elementAt(index); + // `created` is parsed as UTC from the API; render it in + // the device's local time so the date is correct across + // the midnight boundary. + final created = ticket.created.toLocal(); return OutlinedTile( leading: Padding( padding: const .symmetric(vertical: 6.0), @@ -68,7 +72,7 @@ class SupportTicketsView extends StatelessWidget { ), title: ticket.name, subtitle: - '${ticket.created.day}.${ticket.created.month}.${ticket.created.year}', + '${created.day}.${created.month}.${created.year}', onTap: () => context.pushNamed( SupportRoutes.chat, pathParameters: {'uid': ticket.uid}, diff --git a/lib/screens/transaction_history/widgets/transaction_history_row.dart b/lib/screens/transaction_history/widgets/transaction_history_row.dart index 26517ab6e..da3382ea6 100644 --- a/lib/screens/transaction_history/widgets/transaction_history_row.dart +++ b/lib/screens/transaction_history/widgets/transaction_history_row.dart @@ -109,7 +109,7 @@ class TransactionHistoryRowView extends StatelessWidget { ), ), Text( - DateFormat('MMM dd, yyyy | H:mm').format(transaction.timestamp), + DateFormat('MMM dd, yyyy | H:mm').format(transaction.timestamp.toLocal()), style: const TextStyle( fontSize: 12, height: 16 / 12, diff --git a/lib/setup/di.dart b/lib/setup/di.dart index 3b8ee3632..77f3f36b2 100644 --- a/lib/setup/di.dart +++ b/lib/setup/di.dart @@ -31,9 +31,11 @@ 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_legal_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'; @@ -200,6 +202,9 @@ void setupServices() { getIt.registerFactory( () => RealUnitLegalService(getIt(), getIt()), ); + getIt.registerFactory( + () => RealUnitPayService(getIt(), getIt()), + ); getIt.registerFactory( () => RealUnitPdfService(getIt(), getIt()), ); @@ -209,6 +214,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/error_handling/error_handlers.dart b/lib/setup/error_handling/error_handlers.dart new file mode 100644 index 000000000..a38ae7448 --- /dev/null +++ b/lib/setup/error_handling/error_handlers.dart @@ -0,0 +1,58 @@ +import 'dart:developer' as developer; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:realunit_wallet/setup/error_handling/realunit_error_view.dart'; + +/// Wires up the three process-wide error surfaces. Called once from `main` +/// before any other bootstrap step, so errors thrown during setup are covered. +/// +/// - [FlutterError.onError] catches errors raised inside a Flutter callback +/// (build, layout, paint). It logs and then delegates to the previously +/// installed handler, which keeps the framework's own reporting intact. +/// - [ErrorWidget.builder] replaces the bare grey error box with an on-brand +/// surface; see [RealUnitErrorView]. +/// - [PlatformDispatcher.onError] catches everything with no Flutter callback +/// on the stack — unhandled `Future`, `Stream` and `Timer` errors in the root +/// isolate — which the two handlers above never see. +/// +/// Both handlers log and then hand the error on rather than absorbing it, so +/// nothing that reported an error before reports less of one now. +/// +/// Scope, deliberately narrow: [developer.log] writes to the VM service stream, +/// so these calls surface in the DevTools Logging view under the `WalletApp` +/// tag in debug and profile builds only — the VM service is absent from a +/// release build, where the call is compiled out. Returning `false` from +/// [PlatformDispatcher.onError] is what carries release builds: it keeps the +/// engine's own reporting running instead of suppressing it (returning `true` +/// would claim the error as handled while our log is a no-op, i.e. total +/// silence). So this makes async errors *reachable where a developer is already +/// attached*; it adds no release-mode visibility on its own. Getting evidence +/// off a customer's device needs a crash reporter or a persisted log sink — +/// this handler body is the hook such a sink plugs into. +/// +/// @no-integration-test: the engine-side fallback reporting that runs after +/// [PlatformDispatcher.onError] returns false is embedder behaviour and is not +/// observable from a Dart test; the handler contract itself is unit-tested. +void installErrorHandlers() { + final defaultOnError = FlutterError.onError; + FlutterError.onError = (details) { + developer.log( + 'uncaught Flutter error: ${details.exceptionAsString()}', + name: 'WalletApp', + error: details.exception, + stackTrace: details.stack, + ); + defaultOnError?.call(details); + }; + ErrorWidget.builder = (details) => RealUnitErrorView(details: details); + PlatformDispatcher.instance.onError = (error, stack) { + developer.log( + 'uncaught async error: $error', + name: 'WalletApp', + error: error, + stackTrace: stack, + ); + return false; + }; +} diff --git a/lib/setup/routing/boot_navigation.dart b/lib/setup/routing/boot_navigation.dart index bece67e06..b841f9e46 100644 --- a/lib/setup/routing/boot_navigation.dart +++ b/lib/setup/routing/boot_navigation.dart @@ -151,6 +151,47 @@ BootNavAction resolveBootNavigation({ return const BootNavGoNamed(AppRoutes.dashboard); } +/// Payment-deeplink payload captured by `appLinkSchemeRedirect` +/// (app_link_entry.dart) on a COLD start, or on a warm resume while the app is +/// still on a PIN/unlock gate, before the boot/unlock gate ladder has cleared. +/// Held here until [applyBootNavAction] replays it as an imperative `/pay` +/// push at the first post-unlock terminal landing — dashboard, restore, or +/// stay — i.e. strictly after the same PIN/unlock gate ladder a normal `/pay` +/// navigation passes, so the replay can never bypass it. A warm-resume +/// payment deeplink while already unlocked normally pushes straight away, +/// unless a re-lock is detected at the deferred execution-time check, in +/// which case it stashes here for post-unlock replay (same path as the +/// warm-locked branch). Pre-unlock gate waypoints (e.g. +/// [BootNavGoNamed] for PIN verify) never consume or replay the stash; every +/// post-unlock terminal landing (dashboard / restore / stay) replays and +/// consumes it, so the stash can never survive past the first of those. +/// +/// Last-write-wins on the stash — a newer deeplink deliberately supersedes an +/// older un-replayed one; no queueing. +String? _pendingPaymentDeeplink; + +/// Stashes [payload] for post-unlock replay via [applyBootNavAction]. +/// Last-write-wins: a newer deeplink deliberately supersedes an older +/// un-replayed one; no queueing. +void stashPendingPaymentDeeplink(String payload) { + _pendingPaymentDeeplink = payload; +} + +/// Returns the current stashed payload and clears it in the same call. +String? takePendingPaymentDeeplink() { + final payload = _pendingPaymentDeeplink; + _pendingPaymentDeeplink = null; + return payload; +} + +/// Clears any stashed payment deeplink without returning it. +void clearPendingPaymentDeeplink() { + _pendingPaymentDeeplink = null; +} + +/// Returns the current stashed payload without clearing it (read-only). +String? peekPendingPaymentDeeplink() => _pendingPaymentDeeplink; + /// Executes a [resolveBootNavigation] decision against [router]. Split out from /// `main.dart`'s `_navigate` so the routing side effects can be driven against a /// real [GoRouter] in a widget test (the decision itself is covered by the pure @@ -171,8 +212,24 @@ void applyBootNavAction( return; case BootNavGoNamed(:final routeName): // Reaching the dashboard is the final landing — drop any stale resume - // capture so a later benign emission can't bounce the user around. - if (routeName == AppRoutes.dashboard) onClearResume(); + // capture so a later benign emission can't bounce the user around, and + // replay a cold-start / locked-warm-resume payment deeplink now that the + // same gate ladder a normal /pay navigation passes has just been cleared + // (see takePendingPaymentDeeplink / stashPendingPaymentDeeplink). + if (routeName == AppRoutes.dashboard) { + onClearResume(); + final payload = takePendingPaymentDeeplink(); + if (payload != null) { + router.goNamed(routeName); + unawaited(router.pushNamed(AppRoutes.pay, extra: payload)); + return; + } + } + // Non-dashboard named navigation (PIN/setup/onboarding gates, …) is an + // intermediate step of the same boot ladder: the stash must survive + // through to the eventual dashboard landing so cold-start and + // locked-warm-resume payment deeplinks can still replay. Do NOT clear + // the pending-payment stash here. router.goNamed(routeName); return; case BootNavRestore(:final location): @@ -181,7 +238,11 @@ void applyBootNavAction( // real entry shape — dashboard as base, flow pushed on top — so the // flow's pop-based exits (Close buttons, AppBar auto-back) keep working; // a bare `go` would strand the restored route as the only match with - // nothing underneath to pop back to. + // nothing underneath to pop back to. onClearResume drops the spent + // capture so a later emission can't re-restore. Then, if a payment + // deeplink was stashed while locked, replay it as an imperative /pay + // push on top of the restored location (same post-unlock terminal + // consumption as the dashboard branch). onClearResume(); if (Uri.parse(location).path == '/dashboard') { router.go(location); @@ -189,10 +250,22 @@ void applyBootNavAction( router.goNamed(AppRoutes.dashboard); unawaited(router.push(location)); } + final payload = takePendingPaymentDeeplink(); + if (payload != null) { + unawaited(router.pushNamed(AppRoutes.pay, extra: payload)); + } return; case BootNavStay(): - // Already on a valid non-gate route — discard any stale capture. + // Already on a valid non-gate route — discard any stale resume capture. + // This is a post-unlock terminal landing: if a payment deeplink was + // stashed while locked, replay it as an imperative /pay push on top of + // the current location (the only navigation this branch performs when + // a stash exists). onClearResume(); + final payload = takePendingPaymentDeeplink(); + if (payload != null) { + unawaited(router.pushNamed(AppRoutes.pay, extra: payload)); + } return; } } diff --git a/lib/setup/routing/router_config.dart b/lib/setup/routing/router_config.dart index 88073f096..72c9d050f 100644 --- a/lib/setup/routing/router_config.dart +++ b/lib/setup/routing/router_config.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:go_router/go_router.dart'; import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/sell_payment_info.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; import 'package:realunit_wallet/screens/buy/buy_page.dart'; import 'package:realunit_wallet/screens/buy/buy_payment_details_page.dart'; @@ -13,13 +14,14 @@ 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'; 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'; @@ -65,6 +67,7 @@ final GoRouter routerConfig = GoRouter( redirect: (context, state) => appLinkSchemeRedirect( state, effectiveLocation(routerConfig.routerDelegate.currentConfiguration), + routerConfig, ), onException: appLinkOnException, routes: [ @@ -174,6 +177,20 @@ final GoRouter routerConfig = GoRouter( builder: (_, state) => SellBitboxPage(paymentInfo: state.extra as SellPaymentInfo), ), + GoRoute( + name: AppRoutes.pay, + path: '/pay', + builder: (_, state) => PayScanPage( + initialPayload: state.extra is String ? state.extra as String : null, + ), + ), + + GoRoute( + name: AppRoutes.send, + path: '/send', + builder: (_, _) => const SendRecipientPage(), + ), + GoRoute( name: LegalRoutes.disclaimer, path: '/legalDisclaimer', diff --git a/lib/setup/routing/routes/app_link_entry.dart b/lib/setup/routing/routes/app_link_entry.dart index 4a846fb80..277f19859 100644 --- a/lib/setup/routing/routes/app_link_entry.dart +++ b/lib/setup/routing/routes/app_link_entry.dart @@ -1,5 +1,11 @@ +import 'dart:async'; + import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/setup/routing/boot_navigation.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; /// Custom URL scheme the app is opened with. Registered in /// `ios/Runner/Info.plist` (`CFBundleURLSchemes`) and @@ -14,6 +20,44 @@ const String appLinkUrl = 'realunit-wallet://open'; /// normal entry, identical to `GoRouter.initialLocation`. const String appLinkColdStartLocation = '/home'; +/// Detects and extracts a DFX OpenCryptoPay payment payload out of a +/// `realunit-wallet:…` deeplink. +/// +/// The DFX `/pl` frontend builds the deeplink generically as +/// `{deepLink}lightning:{LNURL}` with the backend `deepLink='realunit-wallet:'` +/// (no `//`), so the app receives the single-colon opaque form +/// `realunit-wallet:lightning:`. A `//`-prefixed variant +/// (`realunit-wallet://lightning:`) never reaches this function in +/// production: go_router derives `state.uri` via `Uri.parse` of the route-info, +/// which throws on that form before [appLinkSchemeRedirect] / +/// [extractPaymentDeeplinkPayload] is ever called — there is nothing to strip. +/// +/// Works on the raw URI STRING, not `Uri` component accessors: this is an +/// opaque URI, not a hierarchical one, so `.host`/`.path` are not a reliable +/// general parsing strategy. +/// +/// Returns the payload to feed into `PayScanCubit.onCodeDetected` VERBATIM +/// (e.g. `lightning:LNURL1...`, a bare `LNURL1...`, or a bare `https://...` +/// lnurlp URL), or null when [rawUri] is not a recognized payment deeplink — +/// including the canonical path-less open and any other path-carrying scheme +/// URL, which stay on the existing no-op / rewrite path in +/// [appLinkSchemeRedirect]. No validation beyond this structural sniff: a +/// malformed match is still returned and left for `LnurlDecoder` (reached via +/// `onCodeDetected`) to reject — this function must not swallow or +/// pre-validate (no silent fallback). +String? extractPaymentDeeplinkPayload(String rawUri) { + const prefix = '$appLinkScheme:'; + if (!rawUri.startsWith(prefix)) return null; + final remainder = rawUri.substring(prefix.length); + if (remainder.isEmpty) return null; + + final isLightning = remainder.startsWith('lightning:'); + final isBareLnurl = remainder.toUpperCase().startsWith('LNURL'); + final isHttpLnurlp = remainder.startsWith('http://') || remainder.startsWith('https://'); + if (!isLightning && !isBareLnurl && !isHttpLnurlp) return null; + return remainder; +} + /// Top-level go_router redirect for custom-scheme opens. /// /// A `realunit-wallet://…` open (canonical `realunit-wallet://open`) only brings @@ -45,16 +89,110 @@ const String appLinkColdStartLocation = '/home'; /// and [appLinkOnException] keeps the configuration — the same true no-op as /// the canonical open, with no rebuild at all. /// +/// Payment-deeplink carve-out: when the scheme URL carries a DFX OpenCryptoPay +/// payload (see [extractPaymentDeeplinkPayload]), the same security properties +/// still hold. Locked vs unlocked is classified by the authoritative pair +/// `isPinVerified && isPinSetup` (read only when already in-app / booted, so +/// the DI lookup is safe), not by location membership in [isGateLocation] and +/// not by `isPinVerified` alone. `PinAuthCubit.initialize()` sets +/// `isPinVerified: !isPinSetup`, so before any PIN is configured +/// `isPinVerified` is vacuously true — on `/setupPin` that auto-pass must not +/// be treated as "session unlocked" or a warm-resume payment deeplink would +/// deferred-push `/pay` and bypass the mandatory PIN-setup gate. +/// +/// Warm resume, unlocked (`isInAppRoute` and `isPinVerified && isPinSetup`, +/// including a step-up gate like `/pinGate` reached from an already-unlocked +/// seed-view / change-PIN settings flow): the redirect itself still returns +/// `null` — a true no-op for the match list / back-stack / `extra` — and the +/// actual navigation is an imperative `pushNamed` deferred to +/// `addPostFrameCallback`, so it lands on top of whatever is currently shown +/// (including on top of a step-up gate) without replacing it. Step-up gates +/// complete independently via `pushReplacementNamed` and are not part of the +/// boot/unlock ladder, so a stash while sitting on one would never be consumed +/// — deferred push is the only path that actually resolves a deeplink arriving +/// there. The deferred callback re-checks the same pair +/// `isPinVerified && isPinSetup` at execution time: an independent app-resume +/// re-lock (`AppLifecycleState.resumed` → `PinAuthCubit.onAppResumed()`) can +/// land between decision and push. If no longer unlocked by that pair, the +/// payload is stashed via [stashPendingPaymentDeeplink] instead — same +/// post-unlock replay path as the warm-locked branch. +/// +/// Warm resume, genuinely locked or not-yet-set-up (`isInAppRoute` and +/// `!(isPinVerified && isPinSetup)`, e.g. sitting on `/verifyPin` with +/// `isPinVerified == false`, or on `/setupPin` with `isPinSetup == false` even +/// when `isPinVerified` is the vacuous auto-pass): the payload is stashed via +/// [stashPendingPaymentDeeplink] and the redirect returns `null` so the gate +/// stays put — never push `/pay` over the lock screen or the PIN-setup gate. +/// The stash is replayed only once the PIN/unlock gate ladder lands on the +/// dashboard ([applyBootNavAction]'s dashboard / restore / stay branches), +/// never before — same post-unlock replay path as a cold start. +/// +/// Cold start (`!isInAppRoute`): the PIN gate and boot ladder have not run yet, +/// and getIt may not be ready — [PinAuthCubit] is not read here. The payload +/// is stashed via [stashPendingPaymentDeeplink] and the redirect hands off to +/// [appLinkColdStartLocation] so the normal PIN/unlock gate ladder runs first; +/// [applyBootNavAction] replays the payload as an imperative `/pay` push only +/// once that ladder lands on the dashboard. +/// /// Non-scheme (in-app) navigation is left untouched (`null`). // // @no-integration-test: OS-level custom-scheme delivery and foregrounding can // only be exercised on a real device, and no integration_test/ harness exists // in this repo yet. The redirect is covered by widget tests in // app_link_entry_test.dart. -String? appLinkSchemeRedirect(GoRouterState state, String currentLocation) { +String? appLinkSchemeRedirect(GoRouterState state, String currentLocation, GoRouter router) { if (state.uri.scheme != appLinkScheme) return null; final current = Uri.parse(currentLocation); final isInAppRoute = current.scheme.isEmpty && current.path.startsWith('/'); + + final paymentPayload = extractPaymentDeeplinkPayload(state.uri.toString()); + if (paymentPayload != null) { + if (isInAppRoute) { + if (getIt().state.isPinVerified && getIt().state.isPinSetup) { + // Warm resume, unlocked (including a step-up gate like /pinGate reached + // from an already-unlocked seed-view / change-PIN flow): defer the push + // to addPostFrameCallback (calling into the router synchronously from + // inside its own redirect is reentrant/unsafe) and re-check the pair + // isPinVerified && isPinSetup at execution time — a location snapshot + // is not proof against an independent AppLifecycleState.resumed re-lock + // landing between this decision and the deferred frame (TOCTOU). If + // still unlocked by that pair, push /pay on top of whatever is currently + // shown, including on top of a step-up gate; that gate's own flow + // (seed-view / change-PIN) completes independently via + // pushReplacementNamed and is not part of the boot/unlock ladder, so a + // stash here would never be consumed — deferred-push is the only path + // that actually resolves a deeplink arriving while on a step-up gate. + WidgetsBinding.instance.addPostFrameCallback((_) { + final pinState = getIt().state; + if (!(pinState.isPinVerified && pinState.isPinSetup)) { + stashPendingPaymentDeeplink(paymentPayload); + return; + } + unawaited(router.pushNamed(AppRoutes.pay, extra: paymentPayload)); + }); + return null; + } + // Warm resume, locked or not-yet-set-up (!(isPinVerified && isPinSetup) — + // on /verifyPin with isPinVerified false, or on /setupPin with + // isPinSetup false even when isPinVerified is the vacuous auto-pass from + // PinAuthCubit.initialize()). Stash the payload and stay on the gate — + // never push /pay over the lock screen or the PIN-setup gate. + // applyBootNavAction replays the stash only after the boot/unlock gate + // ladder lands on the dashboard (same post-unlock replay path as a + // cold-start stash). + stashPendingPaymentDeeplink(paymentPayload); + return null; + } + // Cold start: the PIN gate and boot ladder have not run yet, and getIt may + // not be ready — do not read PinAuthCubit here. Stash the payload; + // applyBootNavAction (boot_navigation.dart) replays it as an imperative + // /pay push strictly after resolveBootNavigation lands on the dashboard — + // i.e. after the same PIN/unlock gate ladder a normal /pay navigation + // passes, never before. + stashPendingPaymentDeeplink(paymentPayload); + return appLinkColdStartLocation; + } + if (!isInAppRoute) return appLinkColdStartLocation; final hasPath = state.uri.path.isNotEmpty && state.uri.path != '/'; return hasPath ? appLinkUrl : null; diff --git a/lib/setup/routing/routes/app_routes.dart b/lib/setup/routing/routes/app_routes.dart index c8b54cf71..d0ebf58ff 100644 --- a/lib/setup/routing/routes/app_routes.dart +++ b/lib/setup/routing/routes/app_routes.dart @@ -6,6 +6,8 @@ abstract final class AppRoutes { static const buyPaymentDetails = 'buyPaymentDetails'; static const sell = 'sell'; static const sellBitbox = 'sellBitbox'; + static const pay = 'pay'; + static const send = 'send'; static const kyc = 'kyc'; static const receive = 'receive'; static const bitboxAddressRecovery = 'bitboxAddressRecovery'; diff --git a/lib/widgets/scanner/qr_scanner_view.dart b/lib/widgets/scanner/qr_scanner_view.dart new file mode 100644 index 000000000..6d5e9c384 --- /dev/null +++ b/lib/widgets/scanner/qr_scanner_view.dart @@ -0,0 +1,59 @@ +// @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/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:realunit_wallet/styles/colors.dart'; + +/// Compact icon-only error placeholder used when [QrScannerView.errorBuilder] +/// is null. Replaces mobile_scanner 7.x's own (taller, textScale-scaling) +/// default to avoid overflow in bounded layouts like send_recipient_page.dart's +/// Expanded. +Widget _defaultErrorBuilder(BuildContext context, MobileScannerException error) { + return ColoredBox( + color: RealUnitColors.basic.black, + child: Center( + child: Icon( + Icons.error_outline, + size: 48, + color: RealUnitColors.status.red600, + ), + ), + ); +} + +/// 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; + + /// Optional error UI builder forwarded to [MobileScanner.errorBuilder]. + /// When null, this compact icon-only placeholder is used instead of + /// mobile_scanner 7.x's own (taller, textScale-scaling) default, to avoid + /// overflow in bounded layouts like send_recipient_page.dart's Expanded. + final Widget Function(BuildContext, MobileScannerException)? errorBuilder; + + const QrScannerView({ + super.key, + required this.onDetect, + this.errorBuilder, + }); + + @override + Widget build(BuildContext context) { + return MobileScanner( + onDetect: (capture) { + final raw = capture.barcodes.firstOrNull?.rawValue; + if (raw != null) onDetect(raw); + }, + errorBuilder: errorBuilder ?? _defaultErrorBuilder, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 795c516be..a6d39b085 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -78,7 +78,7 @@ packages: description: path: "." ref: "v0.0.10" - resolved-ref: "cd99ce656410e8df6c6585076d6ee0205a67b34c" + resolved-ref: cd99ce656410e8df6c6585076d6ee0205a67b34c url: "https://github.com/DFXswiss/bitbox_flutter.git" source: git version: "0.0.1" @@ -655,6 +655,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + globbing: + dependency: transitive + description: + name: globbing + sha256: "4f89cfaf6fa74c9c1740a96259da06bd45411ede56744e28017cc534a12b6e2d" + url: "https://pub.dev" + source: hosted + version: "1.0.0" go_router: dependency: "direct main" description: @@ -791,6 +799,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + injector: + dependency: transitive + description: + name: injector + sha256: ed389bed5b48a699d5b9561c985023d0d5cc88dd5ff2237aadcce5a5ab433e4e + url: "https://pub.dev" + source: hosted + version: "3.0.0" intl: dependency: "direct main" description: @@ -951,6 +967,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: ce3f059ebd6dbfab7292bba0e893e354b46730636820d3c9ef69005ce2d55bce + url: "https://pub.dev" + source: hosted + version: "7.4.0" mocktail: dependency: "direct dev" description: @@ -1175,6 +1199,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" + properties: + dependency: transitive + description: + name: properties + sha256: "333f427dd4ed07bdbe8c75b9ff864a1e70b5d7a8426a2e8bdd457b65ae5ac598" + url: "https://pub.dev" + source: hosted + version: "2.1.1" provider: dependency: transitive description: @@ -1231,6 +1271,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + sentry_dart_plugin: + dependency: "direct dev" + description: + name: sentry_dart_plugin + sha256: da9c1d0b3c87a251bfc36301f16af090a88c2d59128fe9a6f908f5ac20340c97 + url: "https://pub.dev" + source: hosted + version: "3.4.0" shared_preferences: dependency: "direct main" description: @@ -1404,6 +1452,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + system_info2: + dependency: transitive + description: + name: system_info2 + sha256: b937736ecfa63c45b10dde1ceb6bb30e5c0c340e14c441df024150679d65ac43 + url: "https://pub.dev" + source: hosted + version: "4.1.0" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 13998da87..0a22ecbc0 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: ^7.4.0 no_screenshot: ^1.1.0 open_file: ^3.5.11 path: ^1.9.0 @@ -99,6 +100,7 @@ dev_dependencies: # Used by test/packages/config/legal_documents_config_test.dart to swap in a # recording fake for url_launcher without touching the platform channel. plugin_platform_interface: ^2.1.8 + sentry_dart_plugin: 3.4.0 url_launcher_platform_interface: ^2.3.2 dependency_overrides: @@ -110,6 +112,17 @@ dependency_overrides: url: https://github.com/cake-tech/web3dart.git ref: aa3f932dbf54eda651b7bd01ad00204acf998bd0 +sentry: + upload_debug_symbols: true + upload_source_maps: false + upload_sources: false + project: realunitchapp + org: sentry + url: https://sentry.dfxserve.com/ + wait_for_processing: true + log_level: error + commits: false + hooks: user_defines: sqlite3: diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png index b183d7dd2..6d13463d7 100644 Binary files a/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png and b/test/goldens/screens/dashboard/goldens/macos/dashboard_hidden_amounts.png differ diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png index 146496f50..d90b7b99b 100644 Binary files a/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png and b/test/goldens/screens/dashboard/goldens/macos/dashboard_recent_transactions.png differ 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 4aed8b45b..bbb828a45 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/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..b28bf3dd9 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_ready_with_merchant.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready_with_merchant.png new file mode 100644 index 000000000..8e33108d8 Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready_with_merchant.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..0062faa95 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..34f2d8099 --- /dev/null +++ b/test/goldens/screens/pay/pay_quote_golden_test.dart @@ -0,0 +1,108 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_quote_page.dart'; + +import '../../../helper/helper.dart'; + +class _MockPayQuoteCubit extends MockCubit implements PayQuoteCubit {} + +void main() { + late _MockPayQuoteCubit quoteCubit; + + setUp(() { + quoteCubit = _MockPayQuoteCubit(); + when(() => quoteCubit.state).thenReturn(const PayQuoteLoading()); + }); + + // PayQuotePage resolves its cubit from getIt and calls load(); the golden + // renders PayQuoteView directly with a mocked cubit so every state is + // deterministic without the service/DI graph. + group('$PayQuoteView', () { + goldenTest( + 'loading state', + fileName: 'pay_quote_page_loading', + constraints: phoneConstraints, + // The CupertinoActivityIndicator animates forever, so pumpAndSettle + // would time out; pumpOnce captures the first frame. + pumpBeforeTest: pumpOnce, + builder: () => wrapForGolden( + BlocProvider.value( + value: quoteCubit, + child: const PayQuoteView(), + ), + ), + ); + + // Real Sepolia OCP capture (DFXswiss/api #3819): a CHF 2.00 payment link + // whose Ethereum method settles 2.0 ZCHF. The screen renders the amounts + // straight from the quote — the app never computes them. + goldenTest( + 'ready quote with CHF amount and ZCHF needed', + fileName: 'pay_quote_page_ready', + constraints: phoneConstraints, + builder: () { + when(() => quoteCubit.state).thenReturn( + const PayQuoteReady( + paymentLinkId: 'pl_realunit_ocp_sepolia', + quoteId: 'plq_realunit_ocp_sepolia', + fiatAsset: 'CHF', + fiatAmount: 2, + zchfAmount: 2.0, + ), + ); + return wrapForGolden( + BlocProvider.value( + value: quoteCubit, + child: const PayQuoteView(), + ), + ); + }, + ); + + goldenTest( + 'ready quote with merchant and REALU swap details', + fileName: 'pay_quote_page_ready_with_merchant', + constraints: phoneConstraints, + builder: () { + when(() => quoteCubit.state).thenReturn( + const PayQuoteReady( + paymentLinkId: 'pl_realunit_ocp_sepolia', + quoteId: 'plq_realunit_ocp_sepolia', + fiatAsset: 'CHF', + fiatAmount: 2, + zchfAmount: 2.0, + merchantName: 'Café Zürich', + merchantCity: 'Zürich', + realuAmount: 5, + realuEstimatedZchf: 1.98, + realuFeesTotal: 0.02, + ), + ); + 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/pin/goldens/macos/verify_pin_page_locked.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_locked.png index f69027875..3bd525d9e 100644 Binary files a/test/goldens/screens/pin/goldens/macos/verify_pin_page_locked.png and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_locked.png differ diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_locked_app_lock.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_locked_app_lock.png new file mode 100644 index 000000000..628f19270 Binary files /dev/null and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_locked_app_lock.png differ diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_unverifiable.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_unverifiable.png index 725b69901..04502f733 100644 Binary files a/test/goldens/screens/pin/goldens/macos/verify_pin_page_unverifiable.png and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_unverifiable.png differ diff --git a/test/goldens/screens/pin/verify_pin_golden_test.dart b/test/goldens/screens/pin/verify_pin_golden_test.dart index adb2655c0..2170c1ea5 100644 --- a/test/goldens/screens/pin/verify_pin_golden_test.dart +++ b/test/goldens/screens/pin/verify_pin_golden_test.dart @@ -211,8 +211,10 @@ void main() { }, ); + // Gate flow (no `bottom`): the button-free lockout copy, since there is no + // Forgot-PIN button to point at. goldenTest( - 'permanently locked disables the pad and shows the lockout message', + 'permanently locked in a gate flow disables the pad and shows the button-free message', fileName: 'verify_pin_page_locked', constraints: const BoxConstraints.tightFor(width: 390, height: 844), builder: () { @@ -228,6 +230,28 @@ void main() { }, ); + // App-lock entry point (`bottom` present): the copy points at the + // Forgot-PIN button, which is rendered below the pad. + goldenTest( + 'permanently locked in the app-lock flow offers recovery via the forgot-pin action', + fileName: 'verify_pin_page_locked_app_lock', + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + final cubit = _MockVerifyPinCubit(); + when(() => cubit.state).thenReturn(const VerifyPinLocked(failedAttempts: 10)); + when(() => cubit.checkBiometricAvailability()).thenAnswer((_) async {}); + return wrapForGolden( + BlocProvider.value( + value: cubit, + child: VerifyPinView( + onAuthenticated: () {}, + bottom: const _ForgotPinButton(), + ), + ), + ); + }, + ); + goldenTest( 'unverifiable app lock offers recovery via the forgot-pin action', fileName: 'verify_pin_page_unverifiable_app_lock', 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..9aa47c72e 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..77f74b07f 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..98ce6e876 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..850d2b974 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/goldens/screens/transaction_history/goldens/macos/transaction_history_page_list.png b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_page_list.png index 17aa4140c..727e916b9 100644 Binary files a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_page_list.png and b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_page_list.png differ diff --git a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_failure.png b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_failure.png index 99398c48a..30277a55e 100644 Binary files a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_failure.png and b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_failure.png differ diff --git a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_loading.png b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_loading.png index 4ca646463..369aae906 100644 Binary files a/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_loading.png and b/test/goldens/screens/transaction_history/goldens/macos/transaction_history_row_receipt_loading.png differ diff --git a/test/goldens/screens/transaction_history/transaction_history_states_golden_test.dart b/test/goldens/screens/transaction_history/transaction_history_states_golden_test.dart index faccdc1ee..e5cd51d65 100644 --- a/test/goldens/screens/transaction_history/transaction_history_states_golden_test.dart +++ b/test/goldens/screens/transaction_history/transaction_history_states_golden_test.dart @@ -30,10 +30,11 @@ import '../../../helper/helper.dart'; // multi-receipt spinner, the receipt-failure SnackBar and the date picker. // // Determinism note — dates: `TransactionHistoryRow` formats via -// `DateFormat('MMM dd, yyyy | H:mm').format(transaction.timestamp)` -// (`transaction_history_row.dart:112`) — no `toLocal()`, no `now()`, no -// relative time. With fixed UTC instants the rendered text is host- and -// timezone-independent. `TransactionHistoryView` itself reads `clock.now()` +// `DateFormat('MMM dd, yyyy | H:mm').format(transaction.timestamp.toLocal())` +// (`transaction_history_row.dart:112`). The fixed UTC instants are rendered in +// the runner's local time, so these baselines are timezone-dependent — they are +// generated and validated on the same Europe/Zurich self-hosted runner (like +// the chat-bubble goldens). `TransactionHistoryView` itself reads `clock.now()` // for the date-picker bounds (`transaction_history_page.dart:36`), so its // builders are pinned with `withClock`, matching the base file. diff --git a/test/helper/golden_plugin_stubs.dart b/test/helper/golden_plugin_stubs.dart index be77be50e..d9141c945 100644 --- a/test/helper/golden_plugin_stubs.dart +++ b/test/helper/golden_plugin_stubs.dart @@ -26,3 +26,52 @@ void unstubNoScreenshotChannel() { null, ); } + +/// 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) {}), + ); + // mobile_scanner 7.x additionally subscribes to a device-orientation event + // stream; stub it as a no-op so `listen`/`cancel` don't throw MissingPluginException. + messenger.setMockStreamHandler( + const EventChannel('dev.steenbakker.mobile_scanner/scanner/deviceOrientation'), + MockStreamHandler.inline(onListen: (arguments, sink) {}), + ); +} diff --git a/test/helper/responsive_surface_catalog.dart b/test/helper/responsive_surface_catalog.dart index a81eb8439..f45b7dfd3 100644 --- a/test/helper/responsive_surface_catalog.dart +++ b/test/helper/responsive_surface_catalog.dart @@ -180,12 +180,43 @@ const kResponsiveSurfaceCatalog = [ matrixTestPath: 'test/screens/buy/buy_responsive_matrix_test.dart', productionPath: 'lib/screens/buy/buy_page.dart', ), + ResponsiveSurface( + id: 'pay_quote_page', + description: 'Pay quote page (confirm CTA)', + matrixTestPath: 'test/screens/pay/pay_quote_responsive_matrix_test.dart', + productionPath: 'lib/screens/pay/pay_quote_page.dart', + ), ResponsiveSurface( id: 'sell_page', description: 'Sell page (primary CTA)', matrixTestPath: 'test/screens/sell/sell_responsive_matrix_test.dart', productionPath: 'lib/screens/sell/sell_page.dart', ), + ResponsiveSurface( + id: 'send_amount_page', + description: 'Send amount page (next CTA)', + matrixTestPath: 'test/screens/send/send_amount_responsive_matrix_test.dart', + productionPath: 'lib/screens/send/send_amount_page.dart', + ), + ResponsiveSurface( + id: 'send_confirm_page', + description: 'Send confirmation page (confirm CTA)', + matrixTestPath: 'test/screens/send/send_confirm_responsive_matrix_test.dart', + productionPath: 'lib/screens/send/send_confirm_page.dart', + ), + ResponsiveSurface( + id: 'send_process_result_sheet', + description: 'Send process terminal result sheet (shrinkWrap mode)', + matrixTestPath: + 'test/screens/send/send_process_result_sheet_responsive_matrix_test.dart', + productionPath: 'lib/screens/send/send_process_page.dart', + ), + ResponsiveSurface( + id: 'send_recipient_page', + description: 'Send recipient manual-entry block (next CTA)', + matrixTestPath: 'test/screens/send/send_recipient_responsive_matrix_test.dart', + productionPath: 'lib/screens/send/send_recipient_page.dart', + ), ResponsiveSurface( id: 'setup_pin_page', description: 'Setup PIN page (sticky numeric keypad as actions)', @@ -228,7 +259,7 @@ const kResponsiveSurfaceCatalog = [ matrixTestPath: 'test/screens/pin/pin_sheets_responsive_matrix_test.dart', productionPath: 'lib/screens/pin/widgets/enable_biometric_bottom_sheet.dart', ), - // Migration covers 29 surfaces total (bitbox_connect_sheet + 28 above). No + // Migration covers 34 surfaces total (bitbox_connect_sheet + 33 above). No // further known candidates remain from the prior sweep. welcome_page was // reviewed and found safe (scrolls end-to-end, no separate sticky CTA) — not // a migration candidate. Not exhaustive — review responsibility for every diff --git a/test/packages/service/dfx/eip1559_unsigned_tx_decoder_test.dart b/test/packages/service/dfx/eip1559_unsigned_tx_decoder_test.dart new file mode 100644 index 000000000..904bba20a --- /dev/null +++ b/test/packages/service/dfx/eip1559_unsigned_tx_decoder_test.dart @@ -0,0 +1,291 @@ +import 'dart:typed_data'; + +import 'package:convert/convert.dart' as convert; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/service/dfx/eip1559_unsigned_tx_decoder.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart'; + +/// Independently verified (Python reference RLP round-trip) EIP-1559 type-2 unsigned +/// tx: chainId=11155111, to=0x1111…ac01, value=0, ERC20 transfer to 0x2222…bc02 for +/// amount=5000000000000000000. +const _validUnsignedTx = + '0x02f87183aa36a7018459682f008504a817c800830186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c0'; + +const _validCalldataHex = + 'a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000'; + +void main() { + group('Eip1559UnsignedTxDecoder.decode', () { + test('decodes a valid EIP-1559 ERC20-transfer unsigned tx', () { + final tx = Eip1559UnsignedTxDecoder.decode(_validUnsignedTx); + + expect(tx.chainId, BigInt.from(11155111)); + expect(tx.maxPriorityFeePerGas, BigInt.from(1500000000)); + expect(tx.maxFeePerGas, BigInt.from(20000000000)); + expect(tx.gasLimit, BigInt.from(100000)); + expect(tx.to, '0x111111111111111111111111111111111111ac01'); + expect(tx.value, BigInt.zero); + expect(tx.data.length, 68); + expect(convert.hex.encode(tx.data), _validCalldataHex); + }); + + test('rejects long-form RLP length-of-length > 4 with typed exception (not RangeError)', () { + // type=0x02, then long-STRING prefix 0xbf (lengthOfLength = 0xbf - 0xb7 = 8) + // followed by 8 length-bytes '7ffffffffffffffe' (0x7ffffffffffffffe = 9223372036854775806) + // so start2+length overflows a signed 64-bit int and wraps negative; without the + // length-of-length cap, _requireBytes lets it through and data.sublist throws a raw + // RangeError; must stay typed as PayUnsignedTxMismatchException. + expect( + () => Eip1559UnsignedTxDecoder.decode('0x02bf7ffffffffffffffe'), + throwsA(isA()), + ); + }); + + test('rejects a non-type-2 transaction', () { + expect( + () => Eip1559UnsignedTxDecoder.decode( + '0x01f87183aa36a7018459682f008504a817c800830186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c0', + ), + throwsA(isA()), + ); + }); + + test('rejects trailing bytes after a complete RLP encoding', () { + expect( + () => Eip1559UnsignedTxDecoder.decode( + '0x02f87183aa36a7018459682f008504a817c800830186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c0ff', + ), + throwsA(isA()), + ); + }); + + test('rejects a truncated encoding', () { + expect( + () => Eip1559UnsignedTxDecoder.decode( + '0x02f87183aa36a7018459682f008504a817c800830186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc0200000000000000000000000000000000000000000000000045639182', + ), + throwsA(isA()), + ); + }); + + test('rejects a tx whose RLP root has the wrong field count (accessList stripped)', () { + // Same tx as _validUnsignedTx but with the trailing accessList field (empty list `c0`) + // removed and the outer list-length prefix corrected from 0x71 (113 bytes) to 0x70 + // (112 bytes) so the RLP still decodes cleanly with zero trailing bytes — root then has + // 8 fields instead of 9. Verified byte-for-byte against a reference RLP decoder. + expect( + () => Eip1559UnsignedTxDecoder.decode( + '0x02f87083aa36a7018459682f008504a817c800830186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000', + ), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + 'unsigned tx has 8 RLP fields, expected 9', + ), + ), + ); + }); + + test('rejects invalid hex', () { + expect( + () => Eip1559UnsignedTxDecoder.decode('0x02zzzz'), + throwsA(isA()), + ); + }); + + test('rejects an empty string', () { + expect( + () => Eip1559UnsignedTxDecoder.decode(''), + throwsA(isA()), + ); + }); + + test('rejects a non-empty access list', () { + // Same as _validUnsignedTx but trailing empty accessList `c0` replaced with + // `c180` (RLP list of one empty string) and the outer list-length prefix + // bumped from 0x71 (113) to 0x72 (114) for the extra payload byte. Verified + // by counting payload bytes after `f872` = 114. + expect( + () => Eip1559UnsignedTxDecoder.decode( + '0x02f87283aa36a7018459682f008504a817c800830186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c180', + ), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + 'unsigned tx "accessList" must be an empty RLP list', + ), + ), + ); + }); + + test('rejects a quantity field with a non-canonical leading zero byte', () { + // Same as _validUnsignedTx but gasLimit `830186a0` (canonical 100000) replaced + // with `84000186a0` (same magnitude, leading 0x00 — non-canonical RLP integer) + // and outer list-length 0x71→0x72 for the extra byte. Verified: only the + // gasLimit encoding and length prefix differ from _validUnsignedTx. + expect( + () => Eip1559UnsignedTxDecoder.decode( + '0x02f87283aa36a7018459682f008504a817c80084000186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c0', + ), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + 'unsigned tx "gasLimit" has a non-canonical leading zero byte', + ), + ), + ); + }); + + // Canonical bare-byte nonce `01` is already accepted by + // 'decodes a valid EIP-1559 ERC20-transfer unsigned tx' above. + test('rejects a non-canonical single-byte short string (nonce encoded as 8101 instead of bare 01)', + () { + // Same as _validUnsignedTx but the nonce field (RLP field index 1, right after chainId + // `83aa36a7`) is re-encoded from the canonical bare byte `01` to the non-canonical length-1 + // short string `8101` (same decoded value, 1), and the outer list-length prefix is bumped + // from 0x71 (113 bytes) to 0x72 (114 bytes) for the extra encoding byte. Independently + // verified byte-for-byte against a reference RLP decoder (only the nonce encoding and the + // outer list-length prefix differ from _validUnsignedTx; every other field decodes to the + // identical value). + expect( + () => Eip1559UnsignedTxDecoder.decode( + '0x02f87283aa36a781018459682f008504a817c800830186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c0', + ), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + 'RLP: non-canonical single-byte short string (must be the bare byte, not a length-1 string)', + ), + ), + ); + }); + + test('rejects a quantity field that is an RLP list instead of a byte string', () { + // RLP: type=0x02, long-form list (f8 65 = 101-byte body), fields: + // chainId= (invalid: must be a byte string), nonce=1, + // maxPriorityFeePerGas=1, maxFeePerGas=1, gasLimit=100000 (830186a0), + // to=20 zero-ish bytes (94 + 20x11), value=0 (80), data=68-byte ERC20 + // transfer calldata (b844...), accessList=. Independently + // constructed (not derived from _validUnsignedTx by truncation) and + // verified byte-for-byte against a reference RLP encoder/decoder so that + // every check before `_requireCanonicalInteger(root[chainId], 'chainId')` + // passes, and that call is the first one reached — hitting exactly the + // "field is not a Uint8List" branch for the chainId field. + expect( + () => Eip1559UnsignedTxDecoder.decode( + '0x02f865c0010101830186a094111111111111111111111111111111111111111180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c0', + ), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + 'unsigned tx "chainId" is not a byte string', + ), + ), + ); + }); + + test('rejects a non-minimal long-form string length encoding', () { + // type=0x02, then long-form string prefix 0xb8 (lengthOfLength=1) with + // length byte 0x01 (≤55) and one payload byte — must use short-form 0x81 + // instead. Hits the long-form string branch before root-type checks. + expect( + () => Eip1559UnsignedTxDecoder.decode('0x02b80101'), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + 'RLP: non-minimal long-form string length encoding', + ), + ), + ); + }); + + test('rejects a non-minimal long-form list length encoding', () { + // type=0x02, then long-form list prefix 0xf8 (lengthOfLength=1) with + // length byte 0x01 (≤55) and one payload byte `80` — must use short-form + // 0xc1 instead. Hits the long-form list branch before field-count checks. + expect( + () => Eip1559UnsignedTxDecoder.decode('0x02f80180'), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + 'RLP: non-minimal long-form list length encoding', + ), + ), + ); + }); + + test('rejects a length-of-length encoding with a leading zero byte', () { + // type=0x02, long-form string prefix 0xb9 (lengthOfLength=2) with length + // bytes `00 40` (=64) — a minimal length-of-length never needs a leading + // zero; must use a single length byte instead. Verified: lengthOfLength=2 + // and bytes[0]==0 hit the new _bytesToLength guard before the length≤55 + // check (which would also reject 64… wait, 64>55, so only the leading-zero + // guard rejects this fixture). + expect( + () => Eip1559UnsignedTxDecoder.decode( + '0x02b90040${'11' * 64}', + ), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + 'RLP: non-minimal length-of-length encoding (leading zero byte)', + ), + ), + ); + }); + }); + + group('Erc20TransferCalldataDecoder.decode', () { + test('decodes a valid transfer(address,uint256) call', () { + final data = Uint8List.fromList(convert.hex.decode(_validCalldataHex)); + final transfer = Erc20TransferCalldataDecoder.decode(data); + + expect(transfer.recipient, '0x222222222222222222222222222222222222bc02'); + expect(transfer.amountWei, BigInt.parse('5000000000000000000')); + }); + + test('rejects a wrong function selector', () { + final data = Uint8List.fromList( + convert.hex.decode( + 'aaaaaaaa000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000', + ), + ); + expect( + () => Erc20TransferCalldataDecoder.decode(data), + throwsA(isA()), + ); + }); + + test('rejects wrong calldata length', () { + final data = Uint8List.fromList( + convert.hex.decode( + 'a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f400', + ), + ); + expect( + () => Erc20TransferCalldataDecoder.decode(data), + throwsA(isA()), + ); + }); + + test('rejects a non-zero-padded recipient address slot', () { + final data = Uint8List.fromList( + convert.hex.decode( + 'a9059cbb010000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000', + ), + ); + expect( + () => Erc20TransferCalldataDecoder.decode(data), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/packages/service/dfx/exceptions/exception_surface_test.dart b/test/packages/service/dfx/exceptions/exception_surface_test.dart index 80c34544f..55387f3cb 100644 --- a/test/packages/service/dfx/exceptions/exception_surface_test.dart +++ b/test/packages/service/dfx/exceptions/exception_surface_test.dart @@ -3,7 +3,9 @@ import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.da import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_address_unavailable_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/sell_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/registration_rejected_exception.dart'; import 'package:realunit_wallet/packages/storage/secure_storage.dart'; import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; @@ -32,6 +34,14 @@ void main() { ), const SeedDecryptionException('test'), const AlreadyConfirmedException(code: 'TEST', message: 'test'), + const InvalidPaymentLinkException('test'), + const PaySignatureUnsupportedException(), + const PayUnsignedTxMismatchException('test'), + const InvalidRecipientAddressException('test'), + const TransferSignatureUnsupportedException(), + const TransferGasFundingUnavailableException(), + const TransferConfirmMismatchException(), + const TransferAlreadyConfirmedException(code: 'TEST', message: 'test'), ]; 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..1a58083a1 --- /dev/null +++ b/test/packages/service/dfx/models/payment/pay/pay_dtos_test.dart @@ -0,0 +1,493 @@ +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); + expect(dto.fees?.total, 1.5); + }); + + 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'); + expect(dto.fees, isNull); + }); + }); + + 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); + expect(info.feesTotal, isNull); + }); + + test('SwapPaymentInfo.fromDto carries fees.total as feesTotal', () { + 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': {'total': 3.25}, + 'minVolume': 1, + 'maxVolume': 1000, + 'minVolumeTarget': 95, + 'maxVolumeTarget': 95000, + 'ethBalance': 1.0, + 'requiredGasEth': 0.001, + 'isValid': true, + }); + + final info = SwapPaymentInfo.fromDto(dto); + + expect(info.feesTotal, 3.25); + }); + + 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('falls back to unknown for an empty status without matching the unknown sentinel', () { + // Regression test: `unknown` is internally represented as `unknown('')`. Before the fix, + // an empty backend value matched that sentinel directly in the known-value loop and bypassed + // the developer.log(...) drift-visibility call. The observable return value is `unknown` + // either way; this pins the mapping so a future regression that makes '' match something + // else (or crash) is caught. The developer.log call itself has no mockable surface in Dart + // (same limitation documented in test/screens/sell/widgets/sell_bank_account_field_test.dart). + expect( + RealUnitOcpPayStatusDto.fromJson({'status': ''}).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, isTrue); + }); + }); + + 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); + // Additive rawAmount captures the JSON value's toString before double parse. + expect(dto.transferAmounts.first.assets.first.rawAmount, '42.7'); + }); + + test('rejects a missing transferAmounts list', () { + expect( + () => LnurlpPaymentDto.fromJson({ + 'requestedAmount': {'asset': 'CHF', 'amount': 1.0}, + 'quote': {'id': 'q', 'expiration': '2026-06-03T12:00:00.000Z'}, + }), + throwsA(isA()), + ); + }); + + test('rejects a missing assets list within a transferAmounts entry', () { + expect( + () => LnurlpPaymentDto.fromJson({ + 'requestedAmount': {'asset': 'CHF', 'amount': 1.0}, + 'quote': {'id': 'q', 'expiration': '2026-06-03T12:00:00.000Z'}, + 'transferAmounts': [ + {'method': 'Ethereum'}, + ], + }), + throwsA(isA()), + ); + }); + + 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); + expect(dto.transferAmounts.first.assets.first.rawAmount, isNull); + }); + + test('preserves a string amount as rawAmount without double drift', () { + final dto = LnurlpPaymentDto.fromJson({ + 'requestedAmount': {'asset': 'CHF', 'amount': '10.10'}, + 'quote': {'id': 'q', 'expiration': '2026-06-03T12:00:00.000Z'}, + 'transferAmounts': [ + { + 'method': 'Ethereum', + 'assets': [ + {'asset': 'ZCHF', 'amount': '42.70000000000001'}, + ], + }, + ], + }); + + expect(dto.requestedAmount.amount, 10.10); + expect(dto.transferAmounts.first.assets.first.rawAmount, '42.70000000000001'); + expect(dto.transferAmounts.first.assets.first.amount, isNotNull); + }); + + test('rejects NaN in requestedAmount.amount', () { + expect( + () => LnurlpPaymentDto.fromJson({ + 'requestedAmount': {'asset': 'CHF', 'amount': 'NaN'}, + 'quote': {'id': 'q', 'expiration': '2026-06-03T12:00:00.000Z'}, + 'transferAmounts': [ + { + 'method': 'Ethereum', + 'assets': [ + {'asset': 'ZCHF', 'amount': 1.0}, + ], + }, + ], + }), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('not a finite number'), + ), + ), + ); + }); + + test('rejects Infinity in transfer asset amount', () { + expect( + () => LnurlpPaymentDto.fromJson({ + 'requestedAmount': {'asset': 'CHF', 'amount': 1.0}, + 'quote': {'id': 'q', 'expiration': '2026-06-03T12:00:00.000Z'}, + 'transferAmounts': [ + { + 'method': 'Ethereum', + 'assets': [ + {'asset': 'ZCHF', 'amount': 'Infinity'}, + ], + }, + ], + }), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('not a finite number'), + ), + ), + ); + }); + + test('rejects -Infinity in requestedAmount.amount', () { + expect( + () => LnurlpPaymentDto.fromJson({ + 'requestedAmount': {'asset': 'CHF', 'amount': '-Infinity'}, + 'quote': {'id': 'q', 'expiration': '2026-06-03T12:00:00.000Z'}, + }), + throwsA(isA()), + ); + }); + + test('rejects a negative transfer asset amount', () { + expect( + () => LnurlpPaymentDto.fromJson({ + 'requestedAmount': {'asset': 'CHF', 'amount': 1.0}, + 'quote': {'id': 'q', 'expiration': '2026-06-03T12:00:00.000Z'}, + 'transferAmounts': [ + { + 'method': 'Ethereum', + 'assets': [ + {'asset': 'ZCHF', 'amount': -1}, + ], + }, + ], + }), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('must not be negative'), + ), + ), + ); + }); + + test('rejects a negative requestedAmount.amount', () { + expect( + () => LnurlpPaymentDto.fromJson({ + 'requestedAmount': {'asset': 'CHF', 'amount': -0.01}, + 'quote': {'id': 'q', 'expiration': '2026-06-03T12:00:00.000Z'}, + 'transferAmounts': [ + { + 'method': 'Ethereum', + 'assets': [ + {'asset': 'ZCHF', 'amount': 1.0}, + ], + }, + ], + }), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('must not be negative'), + ), + ), + ); + }); + + 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. Only name+city are + // mapped; other nested address fields are intentionally left unmapped. + final dto = LnurlpPaymentDto.fromJson({ + 'recipient': { + 'name': 'Acme GmbH', + 'address': {'street': 'Bahnhofstrasse', 'houseNumber': '1', 'city': 'Zürich'}, + }, + '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); + expect(dto.recipient?.name, 'Acme GmbH'); + expect(dto.recipient?.city, 'Zürich'); + }); + }); +} 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..961fd12ec --- /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('RealUnitTransferEip7702DataDto', () { + test('fromJson parses the recipient (transfer) shape', () { + final data = RealUnitTransferEip7702DataDto.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 = RealUnitTransferEip7702DataDto.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..969965031 --- /dev/null +++ b/test/packages/service/dfx/real_unit_pay_service_test.dart @@ -0,0 +1,341 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:web3dart/web3dart.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/config/network_mode.dart'; +import 'package:realunit_wallet/packages/repository/cache_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_request_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/packages/service/session_cache.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; + +class _MockAppStore extends Mock implements AppStore {} + +class _MockWallet extends Mock implements AWallet {} + +class _MockAccount extends Mock implements AWalletAccount {} + +class _MockCacheRepository extends Mock implements CacheRepository {} + +class _MockWalletService extends Mock implements WalletService {} + +class _StubCreds extends Fake implements CredentialsWithKnownAddress { + @override + EthereumAddress get address => + EthereumAddress.fromHex('0x0000000000000000000000000000000000000001'); +} + +Map _swapInfoJson() => { + 'id': 99, + 'uid': 'MOCK-UID', + 'routeId': 7, + 'timestamp': '2026-06-03T00:00:00.000Z', + 'amount': 10, + 'estimatedAmount': 960, + 'targetAsset': 'ZCHF', + 'fees': {'dfx': 1, 'network': 0.5, 'total': 1.5}, + 'minVolume': 1, + 'maxVolume': 1000, + 'minVolumeTarget': 95, + 'maxVolumeTarget': 95000, + 'ethBalance': 1.0, + 'requiredGasEth': 0.001, + 'isValid': true, +}; + +void main() { + late _MockAppStore appStore; + late _MockWallet wallet; + late _MockAccount account; + late _MockWalletService walletService; + late SessionCache session; + + setUp(() { + appStore = _MockAppStore(); + wallet = _MockWallet(); + account = _MockAccount(); + walletService = _MockWalletService(); + session = SessionCache(_MockCacheRepository()); + session.setAuthToken('jwt-1'); + + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.sessionCache).thenReturn(session); + when(() => appStore.wallet).thenReturn(wallet); + when(() => wallet.currentAccount).thenReturn(account); + when(() => account.primaryAddress).thenReturn(_StubCreds()); + }); + + RealUnitPayService build(http.Client client) { + when(() => appStore.httpClient).thenReturn(client); + return RealUnitPayService(appStore, walletService); + } + + group('getPaymentDetails', () { + test('GETs the public lnurlp endpoint (no auth header) and parses it', () async { + Uri? sentUri; + Map? headers; + final client = MockClient((request) async { + sentUri = request.url; + headers = request.headers; + return http.Response( + jsonEncode({ + 'requestedAmount': {'asset': 'CHF', 'amount': 42.5}, + 'quote': {'id': 'quote_xyz', 'expiration': '2026-06-03T12:00:00.000Z'}, + 'transferAmounts': [ + { + 'method': 'Ethereum', + 'assets': [ + {'asset': 'ZCHF', 'amount': 42.7}, + ], + }, + ], + }), + 200, + ); + }); + + final dto = await build(client).getPaymentDetails('pl_abc'); + + expect(sentUri!.path, '/v1/lnurlp/pl_abc'); + expect(headers!.containsKey('Authorization'), isFalse); + expect(dto.quote.id, 'quote_xyz'); + expect(dto.transferAmounts.first.assets.first.amount, 42.7); + }); + + test('non-200 → ApiException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 404, 'message': 'gone'}), 404), + ); + expect( + () => build(client).getPaymentDetails('pl_abc'), + throwsA(isA()), + ); + }); + }); + + group('getSwapPaymentInfo', () { + test('PUTs /swap with targetAmount and parses the quote', () async { + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response(jsonEncode(_swapInfoJson()), 200); + }); + + final info = await build( + client, + ).getSwapPaymentInfo(const RealUnitSwapDto.fromTargetAmount(95.5)); + + expect(sentUri!.path, '/v1/realunit/swap'); + expect(body!['targetAmount'], 95.5); + expect(body!.containsKey('amount'), isFalse); + expect(info.id, 99); + expect(info.estimatedAmount, 960); + expect(info.isValid, isTrue); + }); + + test('non-200 → ApiException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'bad'}), 400), + ); + expect( + () => build(client).getSwapPaymentInfo(const RealUnitSwapDto.fromTargetAmount(95.5)), + throwsA(isA()), + ); + }); + }); + + group('createSwapUnsignedTransaction', () { + test('200 → parses swap hex', () async { + Uri? sentUri; + final client = MockClient((request) async { + sentUri = request.url; + return http.Response(jsonEncode({'swap': '0xswap'}), 200); + }); + + final dto = await build(client).createSwapUnsignedTransaction(42); + + expect(sentUri!.path, '/v1/realunit/swap/42/unsigned-transaction'); + expect(dto.swap, '0xswap'); + }); + + test('non-200 → ApiException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'no eth'}), 400), + ); + expect( + () => build(client).createSwapUnsignedTransaction(42), + throwsA(isA()), + ); + }); + }); + + group('broadcastSwapTransaction', () { + test('201 → returns txHash', () async { + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'txHash': '0xabc'}), 201); + }); + + final txHash = await build(client).broadcastSwapTransaction( + 42, + const BroadcastTransactionRequestDto(unsignedTx: '0xtx', r: '0xr', s: '0xs', v: 27), + ); + + expect(sentUri!.path, '/v1/realunit/swap/42/broadcast'); + expect(body!['unsignedTx'], '0xtx'); + expect(body!['v'], 27); + expect(txHash, '0xabc'); + }); + + test('non-200 → ApiException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'bad sig'}), 400), + ); + expect( + () => build(client).broadcastSwapTransaction( + 42, + const BroadcastTransactionRequestDto(unsignedTx: '0xtx', r: '0xr', s: '0xs', v: 27), + ), + throwsA(isA()), + ); + }); + }); + + group('createPayUnsignedTransaction', () { + // Values from the real Sepolia OCP testnet run (DFXswiss/api #3819): + // a CHF 2.00 link settling 2.0 ZCHF (2000000000000000000 wei) to the DFX + // deposit recipient on chain 11155111. + test('PUTs /pay/unsigned-transaction with the payment refs', () async { + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode({ + 'unsignedTx': + '0x02f87483aa36a782019b8502284a84ae8502284a84ae830186a094d3117681ca462268048f57d106d312ba0b1215ea80b844a9059cbb000000000000000000000000fb2a9731cda8b3fca015723ef40f310c1e48366b0000000000000000000000000000000000000000000000001bc16d674ec80000c0', + 'tokenAddress': '0xD3117681cA462268048f57D106d312Ba0b1215eA', + 'recipient': '0xfB2a9731cdA8b3FCa015723EF40f310C1E48366b', + 'amountWei': '2000000000000000000', + 'chainId': 11155111, + }), + 200, + ); + }); + + final dto = await build(client).createPayUnsignedTransaction( + const RealUnitOcpPayDto(paymentLinkId: 'pl_realunit_ocp_sepolia', quoteId: 'q1'), + ); + + expect(sentUri!.path, '/v1/realunit/pay/unsigned-transaction'); + expect(body!['paymentLinkId'], 'pl_realunit_ocp_sepolia'); + expect(body!['quoteId'], 'q1'); + expect(dto.recipient, '0xfB2a9731cdA8b3FCa015723EF40f310C1E48366b'); + expect(dto.tokenAddress, '0xD3117681cA462268048f57D106d312Ba0b1215eA'); + expect(dto.amountWei, '2000000000000000000'); + expect(dto.chainId, 11155111); + }); + + test('non-200 → ApiException', () async { + final client = MockClient( + (_) async => + http.Response(jsonEncode({'statusCode': 400, 'message': 'unsupported method'}), 400), + ); + expect( + () => build(client).createPayUnsignedTransaction( + const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q1'), + ), + throwsA(isA()), + ); + }); + }); + + group('submitPay', () { + test('PUTs /pay/submit and returns txId', () async { + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'txId': '0xTxId'}), 200); + }); + + final txId = await build(client).submitPay( + const RealUnitOcpPaySubmitDto( + unsignedTx: '0xtx', + r: '0xr', + s: '0xs', + v: 27, + paymentLinkId: 'pl_abc', + quoteId: 'q1', + ), + ); + + expect(sentUri!.path, '/v1/realunit/pay/submit'); + expect(body!['paymentLinkId'], 'pl_abc'); + expect(txId, '0xTxId'); + }); + + test('non-200 → ApiException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'fail'}), 400), + ); + expect( + () => build(client).submitPay( + const RealUnitOcpPaySubmitDto( + unsignedTx: '0xtx', + r: '0xr', + s: '0xs', + v: 27, + paymentLinkId: 'pl_abc', + quoteId: 'q1', + ), + ), + throwsA(isA()), + ); + }); + }); + + group('getPayStatus', () { + test('GETs /pay/:id/status and parses the status', () async { + Uri? sentUri; + final client = MockClient((request) async { + sentUri = request.url; + return http.Response(jsonEncode({'status': 'Completed'}), 200); + }); + + final dto = await build(client).getPayStatus('pl_abc'); + + expect(sentUri!.path, '/v1/realunit/pay/pl_abc/status'); + expect(dto.status, OcpPaymentStatus.completed); + }); + + test('non-200 → ApiException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'statusCode': 404, 'message': 'none'}), 404), + ); + expect( + () => build(client).getPayStatus('pl_abc'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/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..475bffe31 --- /dev/null +++ b/test/packages/service/dfx/real_unit_transfer_service_test.dart @@ -0,0 +1,462 @@ +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:web3dart/web3dart.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'; + +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'; + +const _confirmedRecipient = '0xRecipient'; +const _confirmedAmount = 5; + +/// 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, String recipient = _confirmedRecipient}) => { + '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': recipient, +}; + +RealUnitTransferPaymentInfoDto _info({ + String toAddress = _confirmedRecipient, + int amount = _confirmedAmount, + String eip7702Recipient = _confirmedRecipient, +}) => RealUnitTransferPaymentInfoDto.fromJson({ + 'id': 42, + 'uid': 'RTabc', + 'toAddress': toAddress, + 'amount': amount, + 'tokenAddress': '0xRealu', + 'chainId': 1, + 'eip7702': _eip7702Json(recipient: eip7702Recipient), +}); + +/// Happy-path confirm with matching user-confirmed recipient/amount. +Future _confirm( + RealUnitTransferService service, + RealUnitTransferPaymentInfoDto info, { + String confirmedRecipient = _confirmedRecipient, + int confirmedAmount = _confirmedAmount, +}) { + return service.confirmTransfer( + info, + confirmedRecipient: confirmedRecipient, + confirmedAmount: confirmedAmount, + ); +} + +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 _confirm(build(client), _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 _confirm(build(client), _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( + () => _confirm(build(client), _info()), + throwsA(isA()), + ); + }); + + test('503 on confirm → TransferGasFundingUnavailableException', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'message': 'unavailable'}), 503), + ); + + expect( + () => _confirm(build(client), _info()), + throwsA(isA()), + ); + }); + + test('4xx on confirm → ApiException', () async { + final client = MockClient( + (_) async => + http.Response(jsonEncode({'statusCode': 409, 'code': 'X', 'message': 'no'}), 409), + ); + + expect( + () => _confirm(build(client), _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': _confirmedRecipient, + 'amount': _confirmedAmount, + '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(() => _confirm(build(client), 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')); + }); + }); + + // User-confirmed recipient/amount must match the prepare response before + // any signature is produced (blind-sign guard). + group('user-confirmed recipient/amount guard (throw before any PUT)', () { + Future expectMismatch( + RealUnitTransferPaymentInfoDto info, { + String confirmedRecipient = _confirmedRecipient, + int confirmedAmount = _confirmedAmount, + }) async { + var called = false; + final client = MockClient((_) async { + called = true; + return http.Response('{}', 200); + }); + await expectLater( + () => _confirm( + build(client), + info, + confirmedRecipient: confirmedRecipient, + confirmedAmount: confirmedAmount, + ), + throwsA(isA()), + ); + expect(called, isFalse, reason: 'no PUT must happen when user-confirm guard rejects'); + } + + test('toAddress mismatch → TransferConfirmMismatchException', () async { + await expectMismatch(_info(toAddress: '0xOtherRecipient')); + }); + + test('eip7702.recipient mismatch → TransferConfirmMismatchException', () async { + await expectMismatch(_info(eip7702Recipient: '0xOtherRecipient')); + }); + + test('amount mismatch → TransferConfirmMismatchException', () async { + await expectMismatch(_info(amount: 99)); + }); + + test('case-insensitive recipient match is accepted (no throw)', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'txHash': '0xok'}), 200), + ); + final txHash = await _confirm( + build(client), + _info(toAddress: '0xrecipient', eip7702Recipient: '0xRECIPIENT'), + confirmedRecipient: '0xReCiPiEnT', + ); + expect(txHash, '0xok'); + }); + }); + }); + + group('confirm 409 mapping', () { + test('409 "already confirmed" → TransferAlreadyConfirmedException', () async { + final client = MockClient( + (_) async => http.Response( + jsonEncode({ + 'statusCode': 409, + 'message': 'Transaction request is already confirmed', + 'error': 'Conflict', + 'txHash': '0xfrom409', + }), + 409, + ), + ); + + await expectLater( + _confirm(build(client), _info()), + throwsA( + isA() + .having((e) => e.txHash, 'txHash', '0xfrom409') + .having( + (e) => e.message.toLowerCase().contains('already confirmed'), + 'message mentions already confirmed', + isTrue, + ), + ), + ); + }); + + test('any other 409 conflict stays a plain ApiException', () async { + final client = MockClient( + (_) async => http.Response( + jsonEncode({ + 'statusCode': 409, + 'message': 'Transaction request is in an invalid state', + 'error': 'Conflict', + }), + 409, + ), + ); + + await expectLater( + _confirm(build(client), _info()), + throwsA( + predicate((e) => e is ApiException && e is! TransferAlreadyConfirmedException), + ), + ); + }); + }); +} diff --git a/test/packages/utils/plain_decimal_test.dart b/test/packages/utils/plain_decimal_test.dart new file mode 100644 index 000000000..08ca20001 --- /dev/null +++ b/test/packages/utils/plain_decimal_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/utils/plain_decimal.dart'; + +void main() { + group('comparePlainDecimalStrings', () { + test('orders plain decimals exactly (no double conversion)', () { + expect(comparePlainDecimalStrings('1', '2'), lessThan(0)); + expect(comparePlainDecimalStrings('2', '1'), greaterThan(0)); + expect(comparePlainDecimalStrings('1.0', '1.00'), 0); + expect(comparePlainDecimalStrings('1.50', '1.5'), 0); + expect(comparePlainDecimalStrings('10.10', '10.1'), 0); + // Boundary that binary doubles can get wrong at enough fractional digits. + expect(comparePlainDecimalStrings('0.1', '0.10'), 0); + expect(comparePlainDecimalStrings('1000.01', '1000.010'), 0); + expect(comparePlainDecimalStrings('42.70000000000001', '42.7'), greaterThan(0)); + expect(comparePlainDecimalStrings('960', '1000'), lessThan(0)); + expect(comparePlainDecimalStrings('1000', '960'), greaterThan(0)); + }); + + test('handles leading zeros and optional sign', () { + expect(comparePlainDecimalStrings('01.5', '1.5'), 0); + expect(comparePlainDecimalStrings('+1.5', '1.5'), 0); + expect(comparePlainDecimalStrings('-1', '1'), lessThan(0)); + expect(comparePlainDecimalStrings('-2', '-1'), lessThan(0)); + expect(comparePlainDecimalStrings('-0', '0'), 0); + expect(comparePlainDecimalStrings('.5', '0.5'), 0); + expect(comparePlainDecimalStrings('5.', '5'), 0); + }); + + test('rejects scientific notation', () { + expect( + () => comparePlainDecimalStrings('1e3', '1000'), + throwsA(isA()), + ); + expect( + () => comparePlainDecimalStrings('1.2E-3', '0.0012'), + throwsA(isA()), + ); + }); + + test('rejects malformed input fail-closed', () { + expect( + () => comparePlainDecimalStrings('', '1'), + throwsA(isA()), + ); + expect( + () => comparePlainDecimalStrings('1.2.3', '1'), + throwsA(isA()), + ); + expect( + () => comparePlainDecimalStrings('abc', '1'), + throwsA(isA()), + ); + expect( + () => comparePlainDecimalStrings('-', '1'), + throwsA(isA()), + ); + expect( + () => comparePlainDecimalStrings('.', '1'), + throwsA(isA()), + ); + expect( + () => comparePlainDecimalStrings('1.2a', '1'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/packages/wallet/eip712_signer_bitbox_test.dart b/test/packages/wallet/eip712_signer_bitbox_test.dart index 438ccc787..006e85a60 100644 --- a/test/packages/wallet/eip712_signer_bitbox_test.dart +++ b/test/packages/wallet/eip712_signer_bitbox_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:typed_data'; import 'package:bitbox_flutter/bitbox_manager.dart'; @@ -23,9 +24,9 @@ void main() { BitboxCredentials connected() => BitboxCredentials('0x000000000000000000000000000000000000dead')..setBitbox(manager); - Future signRegistration() => Eip712Signer.signRegistration( + Future signRegistration({int chainId = 1}) => Eip712Signer.signRegistration( credentials: connected(), - chainId: 1, + chainId: chainId, email: 'jk@dfx.swiss', name: 'Joshua', type: 'human', @@ -48,6 +49,40 @@ void main() { expect(await signRegistration(), '0xcafebabe'); }); + // The BitBox02 firmware rejects typed data whose EIP712Domain has no + // chainId ("typed data has no chain ID" on the device) — hardware-wallet + // registrations must sign the chainId-extended domain. The software-wallet + // path keeps the legacy domain (pinned by the golden signature in + // eip712_signer_test.dart). + // Signs with a non-default chainId so a hardcoded value cannot pass, and asserts the + // EIP712Domain member list exactly: the domain typehash covers the names, their types + // and their order, so any of those drifting changes the digest and the API recovers a + // foreign address. Mirrors the chainId-wiring pin in + // test/integration/eip7702_delegation_bitbox_test.dart. + for (final chainId in const [1, 11155111]) { + test('signs with the chainId-extended EIP-712 domain (chainId $chainId)', () async { + when( + () => manager.signETHTypedMessage(any(), any(), any()), + ).thenAnswer((_) async => Uint8List.fromList([0x01])); + + await signRegistration(chainId: chainId); + + // called(1): a second sign would mean a second on-device confirmation prompt. + final captured = (verify( + () => manager.signETHTypedMessage(captureAny(), any(), captureAny()), + )..called(1)).captured; + final typedData = jsonDecode(utf8.decode(captured[1] as Uint8List)) as Map; + + expect(captured[0], chainId); + expect(typedData['domain']['chainId'], chainId); + expect(typedData['types']['EIP712Domain'], [ + {'name': 'name', 'type': 'string'}, + {'name': 'version', 'type': 'string'}, + {'name': 'chainId', 'type': 'uint256'}, + ]); + }); + } + test('throws SigningCancelledException on empty signature', () async { when( () => manager.signETHTypedMessage(any(), any(), any()), 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/home/home_bloc_test.dart b/test/screens/home/home_bloc_test.dart index 7f2ef30a6..b4e734976 100644 --- a/test/screens/home/home_bloc_test.dart +++ b/test/screens/home/home_bloc_test.dart @@ -9,6 +9,7 @@ import 'package:realunit_wallet/packages/service/transaction_history_service.dar import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/setup/routing/boot_navigation.dart'; class _MockWalletService extends Mock implements WalletService {} @@ -401,6 +402,21 @@ void main() { // again. expect(bloc.state.softwareTermsAccepted, isTrue); }); + + test('clears a stashed payment deeplink so it cannot replay into a re-onboarded wallet', () async { + addTearDown(clearPendingPaymentDeeplink); + stashPendingPaymentDeeplink('lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD'); + + final bloc = build(); + await bloc.stream.firstWhere((s) => true); + + bloc.add(const DeleteCurrentWalletEvent()); + await bloc.stream.firstWhere( + (s) => s.isLoadingWallet == false && s.hasWallet == false, + ); + + expect(peekPendingPaymentDeeplink(), isNull); + }); }); group('CompleteOnboardingEvent', () { 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..d75bfb0c1 --- /dev/null +++ b/test/screens/pay/pay_process_cubit_test.dart @@ -0,0 +1,1288 @@ +import 'dart:async'; +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:web3dart/crypto.dart'; +import 'package:web3dart/web3dart.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 '../../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, +}) { + // rawAmount mirrors production fromJson (always set when amount is present) + // so the settlement guard can prove exact plain-decimal coverage fail-closed + // instead of treating a missing raw string as "cannot prove coverage". + 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, + rawAmount: zchf.toString(), + ), + ], + ), + ], + ); +} + +// A real EIP-1559 (type 2) unsigned tx RLP-encoding an ERC20 transfer(address,uint256) call to +// `recipient` for `amountWei`, `to` = tokenAddress, on `chainId` — matches the DTO's own security +// metadata so PayProcessCubit._validatePayUnsignedTx accepts it. Independently verified +// byte-for-byte against a reference RLP encoder/decoder. +const _unsignedPay = RealUnitOcpPayUnsignedTransactionDto( + unsignedTx: + '0x02f87183aa36a7018459682f008504a817c800830186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c0', + tokenAddress: '0x111111111111111111111111111111111111ac01', + recipient: '0x222222222222222222222222222222222222bc02', + amountWei: '5000000000000000000', + chainId: 11155111, +); + +// tx.to (0x333...dead) does not match tokenAddress (0x111...ac01) — tokenAddress mismatch. +const _unsignedPayWrongToken = RealUnitOcpPayUnsignedTransactionDto( + unsignedTx: + '0x02f87183aa36a7018459682f008504a817c800830186a094333333333333333333333333333333333333dead80b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c0', + tokenAddress: '0x111111111111111111111111111111111111ac01', + recipient: '0x222222222222222222222222222222222222bc02', + amountWei: '5000000000000000000', + chainId: 11155111, +); + +// calldata recipient (0x444...dead) does not match recipient (0x222...bc02) — recipient mismatch. +const _unsignedPayWrongRecipient = RealUnitOcpPayUnsignedTransactionDto( + unsignedTx: + '0x02f87183aa36a7018459682f008504a817c800830186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000444444444444444444444444444444444444dead0000000000000000000000000000000000000000000000004563918244f40000c0', + tokenAddress: '0x111111111111111111111111111111111111ac01', + recipient: '0x222222222222222222222222222222222222bc02', + amountWei: '5000000000000000000', + chainId: 11155111, +); + +// calldata amount is amountWei + 1 (5000000000000000001) — amount mismatch. +const _unsignedPayWrongAmount = RealUnitOcpPayUnsignedTransactionDto( + unsignedTx: + '0x02f87183aa36a7018459682f008504a817c800830186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40001c0', + tokenAddress: '0x111111111111111111111111111111111111ac01', + recipient: '0x222222222222222222222222222222222222bc02', + amountWei: '5000000000000000000', + chainId: 11155111, +); + +// tx.chainId is 999 — chainId mismatch (DTO still claims 11155111). +const _unsignedPayWrongChainId = RealUnitOcpPayUnsignedTransactionDto( + unsignedTx: + '0x02f8708203e7018459682f008504a817c800830186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c0', + tokenAddress: '0x111111111111111111111111111111111111ac01', + recipient: '0x222222222222222222222222222222222222bc02', + amountWei: '5000000000000000000', + chainId: 11155111, +); + +// Same to/recipient/amount/chainId as _unsignedPay (all still valid), but gasLimit is raised to +// 300000 — exceeds the 200_000 local gasLimit cap alone (maxFeePerGas unchanged @ 20 gwei, so +// total fee stays 0.006 ETH, well under the total-fee cap) — isolates the gasLimit check. +const _unsignedPayGasLimitExceedsCap = RealUnitOcpPayUnsignedTransactionDto( + unsignedTx: + '0x02f87183aa36a7018459682f008504a817c800830493e094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c0', + tokenAddress: '0x111111111111111111111111111111111111ac01', + recipient: '0x222222222222222222222222222222222222bc02', + amountWei: '5000000000000000000', + chainId: 11155111, +); + +// Same to/recipient/amount/chainId as _unsignedPay, gasLimit unchanged at 100000 (under the +// 200_000 cap), but maxFeePerGas is raised to 600 gwei — total fee becomes 0.06 ETH, over the +// 0.05 ETH total-fee cap alone — isolates the total-fee check. +const _unsignedPayMaxFeeExceedsCap = RealUnitOcpPayUnsignedTransactionDto( + unsignedTx: + '0x02f87183aa36a7018459682f00858bb2c97000830186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c0', + tokenAddress: '0x111111111111111111111111111111111111ac01', + recipient: '0x222222222222222222222222222222222222bc02', + amountWei: '5000000000000000000', + chainId: 11155111, +); + +// Same to/recipient/amount/chainId as _unsignedPay, but the native ETH `value` field is 1 wei +// instead of 0 — an ERC20 transfer must carry zero value. Verified byte-for-byte against a +// reference RLP encoder/decoder (only the single value byte 0x80→0x01 differs from _unsignedPay). +const _unsignedPayNonZeroValue = RealUnitOcpPayUnsignedTransactionDto( + unsignedTx: + '0x02f87183aa36a7018459682f008504a817c800830186a094111111111111111111111111111111111111ac0101b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c0', + tokenAddress: '0x111111111111111111111111111111111111ac01', + recipient: '0x222222222222222222222222222222222222bc02', + amountWei: '5000000000000000000', + chainId: 11155111, +); + +// Same unsignedTx/tokenAddress/recipient/chainId as _unsignedPay (all still valid and +// self-consistent), but the DTO's amountWei is not a parseable integer. +const _unsignedPayInvalidAmountWei = RealUnitOcpPayUnsignedTransactionDto( + unsignedTx: + '0x02f87183aa36a7018459682f008504a817c800830186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c0', + tokenAddress: '0x111111111111111111111111111111111111ac01', + recipient: '0x222222222222222222222222222222222222bc02', + amountWei: 'not-a-number', + chainId: 11155111, +); + +// Same unsignedTx/recipient/amountWei/chainId as _unsignedPay, but the DTO's tokenAddress is +// not a valid 20-byte hex address. +const _unsignedPayInvalidTokenAddress = RealUnitOcpPayUnsignedTransactionDto( + unsignedTx: + '0x02f87183aa36a7018459682f008504a817c800830186a094111111111111111111111111111111111111ac0180b844a9059cbb000000000000000000000000222222222222222222222222222222222222bc020000000000000000000000000000000000000000000000004563918244f40000c0', + tokenAddress: 'not-a-valid-address', + recipient: '0x222222222222222222222222222222222222bc02', + 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(); + + // testnet → apiConfig.asset.chainId = 11155111, matching every unsigned-tx fixture in this file + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet)); + when(() => appStore.primaryAddress).thenReturn('0xwallet'); + when(() => appStore.wallet).thenReturn(wallet); + when(() => wallet.walletType).thenReturn(WalletType.software); + when(() => wallet.currentAccount).thenReturn(account); + when(() => account.primaryAddress).thenReturn(FakeBitboxCredentials(signDelay: Duration.zero)); + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); + when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); + }); + + PayProcessCubit build({double zchfNeeded = 42.7}) => PayProcessCubit( + payService: payService, + faucetService: faucet, + blockchainService: blockchain, + walletService: walletService, + appStore: appStore, + paymentLinkId: 'pl_abc', + zchfNeeded: zchfNeeded, + ); + + void wireHappyPath() { + when(() => payService.getSwapPaymentInfo(any())).thenAnswer((_) async => _swap()); + when(() => payService.createSwapUnsignedTransaction(any())).thenAnswer( + (_) async => const RealUnitSwapUnsignedTransactionDto(swap: '0x02f8aa'), + ); + when( + () => payService.broadcastSwapTransaction(any(), any()), + ).thenAnswer((_) async => '0xswaptx'); + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => _details(expiration: DateTime.now().add(const Duration(minutes: 5))), + ); + when( + () => payService.createPayUnsignedTransaction(any()), + ).thenAnswer((_) async => _unsignedPay); + when(() => payService.submitPay(any())).thenAnswer((_) async => '0xpaytx'); + } + + // The sign step uses `Future.delayed(Duration.zero)` (FakeBitboxCredentials), + // which is a zero-duration *timer* under fakeAsync — `flushMicrotasks` alone + // does not fire it. Elapsing zero repeatedly drains the whole await chain + // (each mock `thenAnswer` future + every zero-delay sign timer) until the + // cubit settles. + void drain(FakeAsync async) { + for (var i = 0; i < 40; i++) { + async.flushMicrotasks(); + async.elapse(Duration.zero); + } + } + + test('debug wallet → signatureUnsupported before any network call', () async { + when(() => wallet.walletType).thenReturn(WalletType.debug); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as PayProcessFailure; + expect(state.reason, PayProcessFailureReason.signatureUnsupported); + verifyNever(() => payService.getSwapPaymentInfo(any())); + await cubit.close(); + }); + + test('invalid swap quote → insufficientZchf', () async { + when(() => payService.getSwapPaymentInfo(any())).thenAnswer((_) async => _swap(isValid: false)); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as PayProcessFailure; + expect(state.reason, PayProcessFailureReason.insufficientZchf); + await cubit.close(); + }); + + test('swap sizes the target with a slippage buffer over the ZCHF needed', () async { + wireHappyPath(); + when(() => payService.getPayStatus('pl_abc')).thenAnswer( + (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed), + ); + RealUnitSwapDto? sentDto; + when(() => payService.getSwapPaymentInfo(any())).thenAnswer((invocation) async { + sentDto = invocation.positionalArguments.first as RealUnitSwapDto; + return _swap(); + }); + + final cubit = build(zchfNeeded: 100); + await cubit.start(); + + // After start() resolves the chain the pay tx has been submitted. + expect(cubit.state, isA()); + // 100 * 1.03 swap headroom buffer (covers ordinary CHF→ZCHF / swap-rate + // drift between scan and settle). + expect(sentDto!.targetAmount, closeTo(103, 0.0001)); + expect(sentDto!.amount, isNull); + await cubit.close(); + }); + + test('happy path: swap → refresh quote → pay → polled Completed → success', () async { + fakeAsync((async) { + wireHappyPath(); + when(() => payService.getPayStatus('pl_abc')).thenAnswer( + (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed), + ); + + final cubit = build(); + cubit.start(); + drain(async); + + // Pay submitted → polling status. + expect(cubit.state, isA()); + + // First status poll @ 3s returns Completed → success. + async.elapse(const Duration(seconds: 3)); + drain(async); + expect(cubit.state, isA()); + + cubit.close(); + async.flushTimers(); + }); + }); + + test('re-fetched quote sends a fresh quoteId into the pay step', () async { + wireHappyPath(); + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => + _details(expiration: DateTime.now().add(const Duration(minutes: 5)), quoteId: 'q_fresh2'), + ); + RealUnitOcpPayDto? payDto; + when(() => payService.createPayUnsignedTransaction(any())).thenAnswer((invocation) async { + payDto = invocation.positionalArguments.first as RealUnitOcpPayDto; + return _unsignedPay; + }); + + final cubit = build(); + final settled = cubit.stream.firstWhere((s) => s is PayProcessAwaitingSettlement); + await cubit.start(); + await settled; + + expect(payDto!.quoteId, 'q_fresh2'); + await cubit.close(); + }); + + test('quote expired between swap and pay → pay-only retry (no re-scan)', () async { + wireHappyPath(); + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => _details(expiration: DateTime.now().subtract(const Duration(minutes: 1))), + ); + + final cubit = build(); + final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); + await cubit.start(); + final state = await retry as PayProcessPayRetry; + + // Genuine expiry surfaces as a retryable state — NOT a terminal failure — + // because the swap already ran. The pay leg is never submitted here. + expect(state.reason, PayRetryReason.quoteExpired); + verifyNever(() => payService.createPayUnsignedTransaction(any())); + await cubit.close(); + }); + + test('pay submit failure after swap → retry (transient), not terminal', () async { + wireHappyPath(); + when(() => payService.submitPay(any())).thenThrow(Exception('settlement rejected')); + + final cubit = build(); + final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); + await cubit.start(); + final state = await retry as PayProcessPayRetry; + + // The swap is done; a failed pay must NOT force a re-swap. + expect(state.reason, PayRetryReason.transient); + expect(cubit.state, isNot(isA())); + await cubit.close(); + }); + + test('unsigned pay tx "to" does not match DTO tokenAddress → unsignedTxMismatch, never signed', + () async { + wireHappyPath(); + when(() => payService.createPayUnsignedTransaction(any())) + .thenAnswer((_) async => _unsignedPayWrongToken); + + 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.unsignedTxMismatch); + verifyNever(() => payService.submitPay(any())); + await cubit.close(); + }); + + test('unsigned pay tx calldata recipient does not match DTO recipient → unsignedTxMismatch, never signed', + () async { + wireHappyPath(); + when(() => payService.createPayUnsignedTransaction(any())) + .thenAnswer((_) async => _unsignedPayWrongRecipient); + + 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.unsignedTxMismatch); + verifyNever(() => payService.submitPay(any())); + await cubit.close(); + }); + + test('unsigned pay tx calldata amount does not match DTO amountWei → unsignedTxMismatch, never signed', + () async { + wireHappyPath(); + when(() => payService.createPayUnsignedTransaction(any())) + .thenAnswer((_) async => _unsignedPayWrongAmount); + + 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.unsignedTxMismatch); + verifyNever(() => payService.submitPay(any())); + await cubit.close(); + }); + + test('unsigned pay tx chainId does not match DTO chainId → unsignedTxMismatch, never signed', + () async { + wireHappyPath(); + when(() => payService.createPayUnsignedTransaction(any())) + .thenAnswer((_) async => _unsignedPayWrongChainId); + + 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.unsignedTxMismatch); + verifyNever(() => payService.submitPay(any())); + await cubit.close(); + }); + + test( + 'unsigned pay tx+DTO self-consistent but chainId mismatches local apiConfig → unsignedTxMismatch, never signed', + () async { + // _unsignedPay is self-consistent at chainId 11155111 (DTO + RLP both agree), so the + // DTO-vs-tx check would pass. Override apiConfig to mainnet (local chainId=1) to prove + // the independent local-chainId check rejects it. + wireHappyPath(); + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + + 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.unsignedTxMismatch); + verifyNever(() => payService.submitPay(any())); + await cubit.close(); + }, + ); + + test('unsigned pay tx gasLimit exceeds local cap → unsignedTxMismatch, never signed', () async { + wireHappyPath(); + when(() => payService.createPayUnsignedTransaction(any())) + .thenAnswer((_) async => _unsignedPayGasLimitExceedsCap); + + 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.unsignedTxMismatch); + verifyNever(() => payService.submitPay(any())); + await cubit.close(); + }); + + test('unsigned pay tx max total fee exceeds local cap → unsignedTxMismatch, never signed', + () async { + wireHappyPath(); + when(() => payService.createPayUnsignedTransaction(any())) + .thenAnswer((_) async => _unsignedPayMaxFeeExceedsCap); + + 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.unsignedTxMismatch); + verifyNever(() => payService.submitPay(any())); + await cubit.close(); + }); + + test('unsigned pay tx sends non-zero native value → unsignedTxMismatch, never signed', () async { + wireHappyPath(); + when(() => payService.createPayUnsignedTransaction(any())) + .thenAnswer((_) async => _unsignedPayNonZeroValue); + + 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.unsignedTxMismatch); + verifyNever(() => payService.submitPay(any())); + await cubit.close(); + }); + + test('unsigned pay tx DTO amountWei is not a valid integer → unsignedTxMismatch, never signed', + () async { + wireHappyPath(); + when(() => payService.createPayUnsignedTransaction(any())) + .thenAnswer((_) async => _unsignedPayInvalidAmountWei); + + 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.unsignedTxMismatch); + expect(state.message, contains('amountWei is not a valid integer')); + verifyNever(() => payService.submitPay(any())); + await cubit.close(); + }); + + test( + 'unsigned pay tx DTO tokenAddress is not a valid 20-byte address → unsignedTxMismatch, never signed', + () async { + wireHappyPath(); + when(() => payService.createPayUnsignedTransaction(any())) + .thenAnswer((_) async => _unsignedPayInvalidTokenAddress); + + 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.unsignedTxMismatch); + expect(state.message, contains('tokenAddress is not a valid 20-byte address')); + verifyNever(() => payService.submitPay(any())); + 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('status polling exceeds max attempts on a persistently non-terminal status → transient retry', + () { + fakeAsync((async) { + wireHappyPath(); + var pollCalls = 0; + when(() => payService.getPayStatus('pl_abc')).thenAnswer((_) async { + pollCalls++; + return const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.pending); + }); + + final cubit = build(); + cubit.start(); + drain(async); + expect(cubit.state, isA()); + + // 40 polls @ 3s, each still Pending (never terminal) — the 40th hits the max-attempts + // cap and gives up rather than polling forever. Iterations 1-39 exercise the + // "still polling" branch; iteration 40 exercises the "give up" branch. + for (var i = 0; i < 40; i++) { + async.elapse(const Duration(seconds: 3)); + drain(async); + } + + final state = cubit.state as PayProcessPayRetry; + expect(state.reason, PayRetryReason.transient); + expect(state.message, 'status polling exceeded max attempts'); + expect(pollCalls, 40); + + // Polling has genuinely stopped — elapsing further must not trigger another call. + async.elapse(const Duration(seconds: 3)); + drain(async); + expect(pollCalls, 40); + + cubit.close(); + async.flushTimers(); + }); + }); + + test('status polling exceeds max attempts on persistent errors → transient retry', () { + fakeAsync((async) { + wireHappyPath(); + var pollCalls = 0; + when(() => payService.getPayStatus('pl_abc')).thenAnswer((_) async { + pollCalls++; + throw Exception('rpc 503'); + }); + + final cubit = build(); + cubit.start(); + drain(async); + expect(cubit.state, isA()); + + // 40 polls @ 3s, each throwing — the 40th hits the max-attempts cap via the catch path. + for (var i = 0; i < 40; i++) { + async.elapse(const Duration(seconds: 3)); + drain(async); + } + + final state = cubit.state as PayProcessPayRetry; + expect(state.reason, PayRetryReason.transient); + expect(state.message, 'status polling exceeded max attempts'); + expect(pollCalls, 40); + + 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('eth polling ignores a transient balance-check error then proceeds once funded', () { + 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++; + if (balanceCall == 1) throw Exception('rpc 503'); + return 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 — balance check throws; must not get stuck, keeps polling. + async.elapse(const Duration(seconds: 5)); + drain(async); + expect(cubit.state, isA()); + + // 2nd eth poll @ 10s — funded → swap proceeds to settlement polling. + async.elapse(const Duration(seconds: 5)); + drain(async); + expect(cubit.state, isA()); + + cubit.close(); + async.flushTimers(); + }); + }); + + test('eth polling exceeds max attempts while balance stays short → insufficientEth', () { + 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 balanceCalls = 0; + when(() => blockchain.getEthBalance(any())).thenAnswer((_) async { + balanceCalls++; + return 0.0; // never crosses requiredGasEth + }); + + final cubit = build(); + cubit.start(); + drain(async); + expect(cubit.state, isA()); + + // 24 polls @ 5s, each still short — the 24th hits the max-attempts cap. + for (var i = 0; i < 24; i++) { + async.elapse(const Duration(seconds: 5)); + drain(async); + } + + final state = cubit.state as PayProcessFailure; + expect(state.reason, PayProcessFailureReason.insufficientEth); + expect(cubit.debugSwapInFlight, isFalse); + expect(state.message, 'eth balance polling exceeded max attempts'); + expect(balanceCalls, 24); + + // Polling has genuinely stopped — elapsing further must not trigger another call. + async.elapse(const Duration(seconds: 5)); + drain(async); + expect(balanceCalls, 24); + // Swap must never have been attempted after the cap. + verifyNever(() => payService.createSwapUnsignedTransaction(any())); + + cubit.close(); + async.flushTimers(); + }); + }); + + test('closing the cubit mid eth-poll-tick still releases the guard via finally', () { + 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)); + final balanceCompleter = Completer(); + when(() => blockchain.getEthBalance(any())).thenAnswer((_) => balanceCompleter.future); + + final cubit = build(); + cubit.start(); + drain(async); + expect(cubit.state, isA()); + + // Trigger the first eth-poll tick; it awaits getEthBalance, which never completes yet. + async.elapse(const Duration(seconds: 5)); + drain(async); + expect(cubit.debugSwapInFlight, isTrue); + + // Close the cubit while the tick is still in-flight, then let the pending balance future + // resolve — the tick resumes into `if (isClosed) return;`, which must still release the + // guard via `finally`. + cubit.close(); + balanceCompleter.complete(0.0); + drain(async); + + expect(cubit.debugSwapInFlight, isFalse); + async.flushTimers(); + }); + }); + + test('hung getEthBalance is unwound by per-request timeout and still hits max attempts', () { + 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 balanceCalls = 0; + when(() => blockchain.getEthBalance(any())).thenAnswer((_) { + balanceCalls++; + // Never completes — only `.timeout(_ethPollTimeout)` (10s) unwedges the tick. + return Completer().future; + }); + + final cubit = build(); + cubit.start(); + drain(async); + expect(cubit.state, isA()); + + // Cadence under fakeAsync: tick @5s starts attempt 1; its 10s timeout and + // the next periodic may interleave such that a free tick is only every + // ~15s (periodic can fire while still in-flight, then timeout frees). + // 24 attempts ⇒ up to ~360s virtual time. Step in 5s until failure. + for (var i = 0; i < 100 && cubit.state is! PayProcessFailure; i++) { + async.elapse(const Duration(seconds: 5)); + drain(async); + } + + final state = cubit.state as PayProcessFailure; + expect(state.reason, PayProcessFailureReason.insufficientEth); + expect(state.message, 'eth balance polling exceeded max attempts'); + expect(balanceCalls, 24); + + cubit.close(); + async.flushTimers(); + }); + }); + + test('faucet request failure → insufficientEth', () async { + when( + () => payService.getSwapPaymentInfo(any()), + ).thenAnswer((_) async => _swap(ethBalance: 0, requiredGasEth: 0.001)); + when(() => faucet.requestFaucet()).thenThrow(Exception('faucet down')); + + final cubit = build(); + final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + await cubit.start(); + final state = await failed as PayProcessFailure; + + expect(state.reason, PayProcessFailureReason.insufficientEth); + await cubit.close(); + }); + + test('UnsupportedError while signing → signatureUnsupported', () async { + wireHappyPath(); + // Wallet reports software type (passes the start() gate) but the credentials + // throw UnsupportedError on sign — exercises the in-sign defensive guard. + when(() => account.primaryAddress).thenReturn(_UnsupportedCreds()); + + final cubit = build(); + final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + await cubit.start(); + final state = await failed as PayProcessFailure; + + expect(state.reason, PayProcessFailureReason.signatureUnsupported); + await cubit.close(); + }); + + test('swap quote fetch failure → generic', () async { + when(() => payService.getSwapPaymentInfo(any())).thenThrow(Exception('api 500')); + + final cubit = build(); + final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + await cubit.start(); + final state = await failed as PayProcessFailure; + + expect(state.reason, PayProcessFailureReason.generic); + await cubit.close(); + }); + + test('BitBox disconnect during the swap sign → bitboxRequired', () async { + wireHappyPath(); + when(() => account.primaryAddress).thenReturn( + FakeBitboxCredentials(behavior: FakeBitboxBehavior.disconnect, signDelay: Duration.zero), + ); + + final cubit = build(); + final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + await cubit.start(); + final state = await failed as PayProcessFailure; + + expect(state.reason, PayProcessFailureReason.bitboxRequired); + await cubit.close(); + }); + + test('generic sign failure during the swap → generic', () async { + wireHappyPath(); + when(() => account.primaryAddress).thenReturn( + FakeBitboxCredentials(behavior: FakeBitboxBehavior.malformed, signDelay: Duration.zero), + ); + + final cubit = build(); + final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure); + await cubit.start(); + final state = await failed as PayProcessFailure; + + expect(state.reason, PayProcessFailureReason.generic); + await cubit.close(); + }); + + test('transient quote re-fetch failure after swap → retry (not re-scan)', () async { + wireHappyPath(); + when(() => payService.getPaymentDetails('pl_abc')).thenThrow(Exception('lnurlp 500')); + + final cubit = build(); + final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); + await cubit.start(); + final state = await retry as PayProcessPayRetry; + + // A transient fetch error is NOT a genuine expiry — it routes to the + // pay-only retry, never to a re-scan → re-swap. + expect(state.reason, PayRetryReason.transient); + await cubit.close(); + }); + + test('BitBox disconnect during the pay sign (after swap) → pay-only retry', () async { + wireHappyPath(); + // First sign (swap) succeeds; the second sign (pay) reports a dropped BLE + // link. Because the swap already happened, this is a retryable pay-leg + // failure rather than a terminal one. + final creds = _CountingSignCreds( + throwOnCall: 2, + error: const BitboxNotConnectedException(), + ); + when(() => account.primaryAddress).thenReturn(creds); + + final cubit = build(); + final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); + await cubit.start(); + final state = await retry as PayProcessPayRetry; + + expect(creds.calls, 2); + expect(state.reason, PayRetryReason.transient); + await cubit.close(); + }); + + test('insufficient ZCHF after swap (fresh amount > acquired) → typed retry', () async { + wireHappyPath(); + // Swap acquires estimatedAmount=960 ZCHF, but the fresh quote now demands + // 1000 ZCHF — more than was swapped. Surface the typed, retryable state. + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => _details( + expiration: DateTime.now().add(const Duration(minutes: 5)), + zchf: 1000, + ), + ); + + final cubit = build(); + final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry); + await cubit.start(); + final state = await retry as PayProcessPayRetry; + + expect(state.reason, PayRetryReason.insufficientZchf); + // The pay leg is never attempted — the swapped ZCHF stays in the wallet. + verifyNever(() => payService.createPayUnsignedTransaction(any())); + await cubit.close(); + }); + + test('insufficient ZCHF with rawAmount uses exact plain-decimal comparison', () async { + wireHappyPath(); + // Swap acquires estimatedAmount=960. Fresh quote carries rawAmount '1000' + // (exact string path) — must hit the plain-decimal branch, not only double `>`. + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => LnurlpPaymentDto( + requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 42.5), + quote: LnurlpQuoteDto( + id: 'quote_fresh', + expiration: DateTime.now().add(const Duration(minutes: 5)), + ), + transferAmounts: [ + const LnurlpTransferAmountDto( + method: 'Ethereum', + assets: [ + LnurlpTransferAssetDto( + asset: 'ZCHF', + amount: 1000, + rawAmount: '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); + verifyNever(() => payService.createPayUnsignedTransaction(any())); + await cubit.close(); + }); + + test('malformed rawAmount fail-closed retries (never treats as OK)', () async { + wireHappyPath(); + // rawAmount is scientific notation → FormatException on plain-decimal parse → + // fail closed (cannot prove exact coverage) rather than falling back to a + // rounding-prone double comparison. Outcome here still retries; amount 1000 + // > acquired 960 would also have been true under the old double path, so + // this is a regression pin that the path still retries after the fix. + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => LnurlpPaymentDto( + requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 42.5), + quote: LnurlpQuoteDto( + id: 'quote_fresh', + expiration: DateTime.now().add(const Duration(minutes: 5)), + ), + transferAmounts: [ + const LnurlpTransferAmountDto( + method: 'Ethereum', + assets: [ + LnurlpTransferAssetDto( + asset: 'ZCHF', + amount: 1000, + rawAmount: '1e3', + ), + ], + ), + ], + ), + ); + + 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); + verifyNever(() => payService.createPayUnsignedTransaction(any())); + await cubit.close(); + }); + + test('non-plain-decimal fresh amount that would look covered by double comparison still retries ' + '(fail-closed)', () async { + wireHappyPath(); + // Swap acquires estimatedAmount=960. Fresh settlement amount is 900 (a plain double LESS than + // 960 — the old double `>` fallback would say "not exceeding", i.e. wrongly "covered"), but + // rawAmount is scientific notation ('9e2') so no exact plain-decimal comparison is possible. + // The fix must NOT fall back to the double comparison here — it must fail closed and retry, + // proving the conservative `true` return, not merely coincide with what double comparison + // would already have said. + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => LnurlpPaymentDto( + requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 42.5), + quote: LnurlpQuoteDto( + id: 'quote_fresh', + expiration: DateTime.now().add(const Duration(minutes: 5)), + ), + transferAmounts: [ + const LnurlpTransferAmountDto( + method: 'Ethereum', + assets: [ + LnurlpTransferAssetDto( + asset: 'ZCHF', + amount: 900, + rawAmount: '9e2', + ), + ], + ), + ], + ), + ); + + 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); + verifyNever(() => payService.createPayUnsignedTransaction(any())); + await cubit.close(); + }); + + test('missing rawAmount fails closed even when the double amount would look covered', () async { + wireHappyPath(); + // amount 900 < acquired 960 — double comparison would say "covered". rawAmount is null so + // exact plain-decimal coverage cannot be proven; fail closed and retry. + when(() => payService.getPaymentDetails('pl_abc')).thenAnswer( + (_) async => LnurlpPaymentDto( + requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 42.5), + quote: LnurlpQuoteDto( + id: 'quote_fresh', + expiration: DateTime.now().add(const Duration(minutes: 5)), + ), + transferAmounts: [ + const LnurlpTransferAmountDto( + method: 'Ethereum', + assets: [ + LnurlpTransferAssetDto( + asset: 'ZCHF', + amount: 900, + ), + ], + ), + ], + ), + ); + + 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); + 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..7c0fb6d66 --- /dev/null +++ b/test/screens/pay/pay_process_page_test.dart @@ -0,0 +1,274 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_process_page.dart'; + +import '../../helper/helper.dart'; + +class _MockPayProcessCubit extends MockCubit implements PayProcessCubit {} + +class _MockPayService extends Mock implements RealUnitPayService {} + +class _MockFaucetService extends Mock implements DfxFaucetService {} + +class _MockBlockchainService extends Mock implements DfxBlockchainApiService {} + +class _MockWalletService extends Mock implements WalletService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +class _MockWallet extends Mock implements SoftwareWallet {} + +void main() { + late _MockPayProcessCubit processCubit; + + setUpAll(() { + final getIt = GetIt.instance; + // PayProcessPage resolves a full service graph from getIt and calls + // start(). A debug wallet makes start() settle immediately + // (signatureUnsupported) without touching the chain. + final payService = _MockPayService(); + getIt.registerSingleton(payService); + getIt.registerSingleton(_MockFaucetService()); + getIt.registerSingleton(_MockBlockchainService()); + getIt.registerSingleton(_MockWalletService()); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + final wallet = _MockWallet(); + when(() => wallet.walletType).thenReturn(WalletType.debug); + when(() => appStore.wallet).thenReturn(wallet); + when(() => appStore.apiConfig).thenReturn(apiConfig); + getIt.registerSingleton(appStore); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + processCubit = _MockPayProcessCubit(); + when(() => processCubit.state).thenReturn(const PayProcessInitial()); + when(() => processCubit.retryPay()).thenAnswer((_) async {}); + }); + + Widget buildSubject() => BlocProvider.value( + value: processCubit, + child: const PayProcessView(), + ); + + group('$PayProcessPage', () { + testWidgets('builds its own cubit and renders $PayProcessView', (tester) async { + await tester.pumpApp(const PayProcessPage(paymentLinkId: 'pl_abc', zchfNeeded: 42.7)); + // start() runs and emits a failure on the debug wallet; pump a frame to + // let the cubit settle (the sheet animation is not awaited here). + await tester.pump(); + + expect(find.byType(PayProcessView), findsOne); + }); + }); + + group('$PayProcessView progress labels', () { + Future expectLabel(WidgetTester tester, PayProcessState state, String label) async { + when(() => processCubit.state).thenReturn(state); + await tester.pumpApp(buildSubject()); + + expect(find.byType(CupertinoActivityIndicator), findsOne); + expect(find.text(label), findsOne); + } + + testWidgets('initial shows preparing-swap', (tester) async { + await expectLabel(tester, const PayProcessInitial(), S.current.payPreparingSwap); + }); + + testWidgets('preparing-swap label', (tester) async { + await expectLabel(tester, const PayProcessPreparingSwap(), S.current.payPreparingSwap); + }); + + testWidgets('waiting-for-eth label', (tester) async { + await expectLabel(tester, const PayProcessWaitingForEth(), S.current.payWaitingForEth); + }); + + testWidgets('swapping label', (tester) async { + await expectLabel(tester, const PayProcessSwapping(), S.current.paySwapping); + }); + + testWidgets('refreshing-quote label', (tester) async { + await expectLabel(tester, const PayProcessRefreshingQuote(), S.current.payRefreshingQuote); + }); + + testWidgets('paying label', (tester) async { + await expectLabel(tester, const PayProcessPaying(), S.current.payPaying); + }); + + testWidgets('awaiting-settlement label', (tester) async { + await expectLabel( + tester, + const PayProcessAwaitingSettlement('0xtx'), + S.current.payAwaitingSettlement, + ); + }); + + testWidgets('success label', (tester) async { + await expectLabel(tester, const PayProcessSuccess(), S.current.paySuccess); + }); + + testWidgets('pay-retry label', (tester) async { + await expectLabel( + tester, + const PayProcessPayRetry(PayRetryReason.quoteExpired), + S.current.payRetryTitle, + ); + }); + + testWidgets('failure label', (tester) async { + await expectLabel( + tester, + const PayProcessFailure(PayProcessFailureReason.generic), + S.current.payFailureTitle, + ); + }); + }); + + // The result/retry sheets are modal bottom sheets shown from the listener. + // The PayProcessView keeps a CupertinoActivityIndicator animating behind the + // sheet, so pumpAndSettle never settles; pump fixed frames to open the sheet. + // A phone-sized surface keeps the taller retry sheet from overflowing the + // default 800x600 test viewport (mirrors the logout-sheet test convention). + Future pumpWithState(WidgetTester tester, PayProcessState terminal) async { + tester.view.physicalSize = const Size(1200, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + whenListen( + processCubit, + Stream.fromIterable([terminal]), + initialState: const PayProcessSwapping(), + ); + await tester.pumpApp(buildSubject()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + } + + group('$PayProcessView result sheet', () { + testWidgets('success emits a success sheet with title + description', (tester) async { + await pumpWithState(tester, const PayProcessSuccess()); + + expect(find.text(S.current.paySuccessDescription), findsOne); + expect(find.byIcon(Icons.check_circle_rounded), findsOne); + expect(find.text(S.current.close), findsOne); + + // Tapping close pops the sheet and then pops the page. + await tester.tap(find.text(S.current.close)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byIcon(Icons.check_circle_rounded), findsNothing); + }); + + testWidgets('insufficient-zchf failure emits a failure sheet', (tester) async { + await pumpWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.insufficientZchf), + ); + + // payFailureTitle also renders as the progress-label behind the sheet, + // so it appears twice; the reason message is the sheet-unique assertion. + expect(find.text(S.current.payFailureTitle), findsWidgets); + expect(find.text(S.current.payFailureInsufficientZchf), findsOne); + expect(find.byIcon(Icons.error_rounded), findsOne); + }); + + testWidgets('insufficient-eth failure message', (tester) async { + await pumpWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.insufficientEth), + ); + + expect(find.text(S.current.payFailureInsufficientEth), findsOne); + }); + + testWidgets('signature-unsupported failure message', (tester) async { + await pumpWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.signatureUnsupported), + ); + + expect(find.text(S.current.payFailureSignatureUnsupported), findsOne); + }); + + testWidgets('bitbox-required failure message', (tester) async { + await pumpWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.bitboxRequired), + ); + + expect(find.text(S.current.payFailureBitboxRequired), findsOne); + }); + + testWidgets('generic failure message', (tester) async { + await pumpWithState( + tester, + const PayProcessFailure(PayProcessFailureReason.generic), + ); + + expect(find.text(S.current.payFailureGeneric), findsOne); + }); + }); + + group('$PayProcessView retry sheet', () { + testWidgets('pay-retry emits a retry sheet whose primary action calls retryPay', ( + tester, + ) async { + await pumpWithState(tester, const PayProcessPayRetry(PayRetryReason.quoteExpired)); + + expect(find.text(S.current.payRetryQuoteExpired), findsOne); + expect(find.byIcon(Icons.replay_rounded), findsOne); + + await tester.tap(find.text(S.current.payRetryButton)); + await tester.pump(); + + verify(() => processCubit.retryPay()).called(1); + }); + + testWidgets('retry sheet close action dismisses without retrying', (tester) async { + await pumpWithState(tester, const PayProcessPayRetry(PayRetryReason.transient)); + + expect(find.text(S.current.payRetryTransient), findsOne); + + await tester.tap(find.text(S.current.close)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + verifyNever(() => processCubit.retryPay()); + expect(find.text(S.current.payRetryTransient), findsNothing); + }); + + testWidgets('insufficient-zchf retry reason shows its message', (tester) async { + await pumpWithState(tester, const PayProcessPayRetry(PayRetryReason.insufficientZchf)); + + expect(find.text(S.current.payRetryInsufficientZchf), findsOne); + }); + + testWidgets('unsigned-tx-mismatch retry reason shows its message', (tester) async { + await pumpWithState(tester, const PayProcessPayRetry(PayRetryReason.unsignedTxMismatch)); + + expect(find.text(S.current.payRetryUnsignedTxMismatch), 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..bf4789f4c --- /dev/null +++ b/test/screens/pay/pay_quote_cubit_test.dart @@ -0,0 +1,155 @@ +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/models/payment/pay/dto/real_unit_swap_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart'; + +class _MockPayService extends Mock implements RealUnitPayService {} + +// Real Sepolia OCP capture (DFXswiss/api #3819): a CHF 2.00 payment link whose +// Ethereum method settles 2.0 ZCHF. The cubit reads these amounts verbatim from +// the public lnurlp quote — it never computes them. +LnurlpPaymentDto _details({ + required DateTime expiration, + bool withEthZchf = true, + double zchf = 2.0, + LnurlpRecipientDto? recipient, +}) { + return LnurlpPaymentDto( + requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 2), + quote: LnurlpQuoteDto(id: 'plq_realunit_ocp_sepolia', expiration: expiration), + transferAmounts: [ + if (withEthZchf) + LnurlpTransferAmountDto( + method: 'Ethereum', + assets: [LnurlpTransferAssetDto(asset: 'ZCHF', amount: zchf)], + ) + else + const LnurlpTransferAmountDto( + method: 'Bitcoin', + assets: [LnurlpTransferAssetDto(asset: 'BTC', amount: 0.0005)], + ), + ], + recipient: recipient, + ); +} + +SwapPaymentInfo _swap({ + double amount = 5, + double estimatedAmount = 1.98, + double? feesTotal = 0.02, +}) { + return SwapPaymentInfo( + id: 99, + amount: amount, + estimatedAmount: estimatedAmount, + targetAsset: 'ZCHF', + ethBalance: 1.0, + requiredGasEth: 0.001, + isValid: true, + feesTotal: feesTotal, + ); +} + +void main() { + late _MockPayService payService; + + setUpAll(() { + registerFallbackValue(const RealUnitSwapDto.fromTargetAmount(1)); + }); + + setUp(() { + payService = _MockPayService(); + }); + + PayQuoteCubit build() => PayQuoteCubit(payService, 'pl_realunit_ocp_sepolia'); + + blocTest( + 'a fresh quote with an Ethereum/ZCHF method emits PayQuoteReady', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenAnswer( + (_) async => _details(expiration: DateTime.now().add(const Duration(minutes: 5))), + ); + when(() => payService.getSwapPaymentInfo(any())).thenAnswer((_) async => _swap()); + }, + act: (cubit) => cubit.load(), + expect: () => [isA(), isA()], + verify: (cubit) { + final state = cubit.state as PayQuoteReady; + expect(state.quoteId, 'plq_realunit_ocp_sepolia'); + expect(state.fiatAsset, 'CHF'); + expect(state.fiatAmount, 2); + expect(state.zchfAmount, 2.0); + expect(state.realuAmount, 5); + expect(state.realuEstimatedZchf, 1.98); + expect(state.realuFeesTotal, 0.02); + expect(state.merchantName, isNull); + expect(state.merchantCity, isNull); + }, + ); + + blocTest( + 'a fresh quote with a recipient surfaces merchant name and city', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenAnswer( + (_) async => _details( + expiration: DateTime.now().add(const Duration(minutes: 5)), + recipient: const LnurlpRecipientDto(name: 'Café Zürich', city: 'Zürich'), + ), + ); + when(() => payService.getSwapPaymentInfo(any())).thenAnswer((_) async => _swap()); + }, + act: (cubit) => cubit.load(), + expect: () => [isA(), isA()], + verify: (cubit) { + final state = cubit.state as PayQuoteReady; + expect(state.merchantName, 'Café Zürich'); + expect(state.merchantCity, 'Zürich'); + }, + ); + + blocTest( + 'an expired quote emits PayQuoteExpired', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenAnswer( + (_) async => _details(expiration: DateTime.now().subtract(const Duration(minutes: 1))), + ); + }, + act: (cubit) => cubit.load(), + expect: () => [isA(), isA()], + ); + + blocTest( + 'a link without an Ethereum/ZCHF method emits PayQuoteUnavailable', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenAnswer( + (_) async => _details( + expiration: DateTime.now().add(const Duration(minutes: 5)), + withEthZchf: false, + ), + ); + }, + act: (cubit) => cubit.load(), + expect: () => [isA(), isA()], + ); + + blocTest( + 'a service error emits PayQuoteError', + build: build, + setUp: () { + when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenThrow( + const ApiException(code: 'X', message: 'boom'), + ); + }, + act: (cubit) => cubit.load(), + expect: () => [isA(), isA()], + ); +} diff --git a/test/screens/pay/pay_quote_page_test.dart b/test/screens/pay/pay_quote_page_test.dart new file mode 100644 index 000000000..e6496b5ea --- /dev/null +++ b/test/screens/pay/pay_quote_page_test.dart @@ -0,0 +1,187 @@ +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/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_process_page.dart'; +import 'package:realunit_wallet/screens/pay/pay_quote_page.dart'; + +import '../../helper/helper.dart'; + +class _MockPayQuoteCubit extends MockCubit implements PayQuoteCubit {} + +class _MockPayService extends Mock implements RealUnitPayService {} + +class _MockFaucetService extends Mock implements DfxFaucetService {} + +class _MockBlockchainService extends Mock implements DfxBlockchainApiService {} + +class _MockWalletService extends Mock implements WalletService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +class _MockWallet extends Mock implements SoftwareWallet {} + +void main() { + late _MockPayQuoteCubit quoteCubit; + + // Real Sepolia OCP capture (DFXswiss/api #3819): CHF 2.00 → 2.0 ZCHF on the + // Ethereum method. + const ready = PayQuoteReady( + paymentLinkId: 'pl_realunit_ocp_sepolia', + quoteId: 'plq_realunit_ocp_sepolia', + fiatAsset: 'CHF', + fiatAmount: 2, + zchfAmount: 2.0, + ); + + setUpAll(() { + final getIt = GetIt.instance; + + // PayQuotePage resolves the pay service from getIt and calls load(); the + // load throws a typed error here so the pushed route builds deterministically + // (rendering PayQuoteError) without a live backend. + final payService = _MockPayService(); + when(() => payService.getPaymentDetails(any())).thenThrow( + const ApiException(code: 'TEST', message: 'no backend in widget test'), + ); + getIt.registerSingleton(payService); + + // The confirm button pushes PayProcessPage, which resolves a full service + // graph from getIt and calls start(). A debug wallet makes start() settle + // immediately (signatureUnsupported) without touching the chain. + getIt.registerSingleton(_MockFaucetService()); + getIt.registerSingleton(_MockBlockchainService()); + getIt.registerSingleton(_MockWalletService()); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + final wallet = _MockWallet(); + when(() => wallet.walletType).thenReturn(WalletType.debug); + when(() => appStore.wallet).thenReturn(wallet); + when(() => appStore.apiConfig).thenReturn(apiConfig); + getIt.registerSingleton(appStore); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + quoteCubit = _MockPayQuoteCubit(); + when(() => quoteCubit.state).thenReturn(const PayQuoteLoading()); + }); + + Widget buildSubject() => BlocProvider.value( + value: quoteCubit, + child: const PayQuoteView(), + ); + + group('$PayQuotePage', () { + testWidgets('builds its own cubit and renders $PayQuoteView', (tester) async { + await tester.pumpApp(const PayQuotePage(paymentLinkId: 'pl_abc')); + + expect(find.byType(PayQuoteView), findsOne); + }); + }); + + group('$PayQuoteView', () { + testWidgets('loading state shows a $CupertinoActivityIndicator', (tester) async { + when(() => quoteCubit.state).thenReturn(const PayQuoteLoading()); + await tester.pumpApp(buildSubject()); + + expect(find.byType(CupertinoActivityIndicator), findsOne); + }); + + testWidgets('ready state shows the CHF amount, ZCHF needed and confirm button', (tester) async { + when(() => quoteCubit.state).thenReturn(ready); + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payQuoteSummary('2.00', 'CHF')), findsOne); + expect(find.text('2.00 CHF'), findsOne); + expect(find.text('2.00 ZCHF'), findsOne); + expect(find.text(S.current.payConfirmButton), findsOne); + }); + + testWidgets('ready state shows merchant and REALU swap details when present', (tester) async { + when(() => quoteCubit.state).thenReturn( + const PayQuoteReady( + paymentLinkId: 'pl_realunit_ocp_sepolia', + quoteId: 'plq_realunit_ocp_sepolia', + fiatAsset: 'CHF', + fiatAmount: 2, + zchfAmount: 2.0, + merchantName: 'Café Zürich', + merchantCity: 'Zürich', + realuAmount: 5, + realuEstimatedZchf: 1.98, + realuFeesTotal: 0.02, + ), + ); + await tester.pumpApp(buildSubject()); + + expect(find.text('Café Zürich, Zürich'), findsOne); + expect(find.text('5 REALU'), findsOne); + expect(find.text('1.98 ZCHF'), findsOne); + expect(find.text('0.02 REALU'), findsOne); + }); + + testWidgets('confirm button navigates to the process step', (tester) async { + when(() => quoteCubit.state).thenReturn(ready); + await tester.pumpApp(buildSubject()); + + await tester.tap(find.text(S.current.payConfirmButton)); + // The process page renders a CupertinoActivityIndicator that animates + // forever, so pumpAndSettle would time out; pump fixed frames to drive + // the push transition instead. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byType(PayProcessView), findsOne); + }); + + testWidgets('expired state shows the re-scan message', (tester) async { + when(() => quoteCubit.state).thenReturn(const PayQuoteExpired()); + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payFailureQuoteExpired), findsOne); + }); + + testWidgets('unavailable state shows the unavailable message', (tester) async { + when(() => quoteCubit.state).thenReturn(const PayQuoteUnavailable()); + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payQuoteUnavailable), findsOne); + }); + + testWidgets('error state shows the generic failure message', (tester) async { + when(() => quoteCubit.state).thenReturn(const PayQuoteError('boom')); + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payFailureGeneric), findsOne); + }); + + testWidgets('error state retry button re-invokes load()', (tester) async { + when(() => quoteCubit.state).thenReturn(const PayQuoteError('boom')); + when(() => quoteCubit.load()).thenAnswer((_) async {}); + await tester.pumpApp(buildSubject()); + + await tester.tap(find.byKey(const ValueKey('payQuoteRetryButton'))); + await tester.pump(); + + verify(() => quoteCubit.load()).called(1); + }); + }); +} diff --git a/test/screens/pay/pay_quote_responsive_matrix_test.dart b/test/screens/pay/pay_quote_responsive_matrix_test.dart new file mode 100644 index 000000000..ceda13aeb --- /dev/null +++ b/test/screens/pay/pay_quote_responsive_matrix_test.dart @@ -0,0 +1,124 @@ +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: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_quote_page.dart'; +import 'package:realunit_wallet/styles/themes.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 {} + +Future _pumpScreen(WidgetTester tester, MatrixCell cell, Widget child) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async => await tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: child, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + const ready = PayQuoteReady( + paymentLinkId: 'pl_realunit_ocp_sepolia', + quoteId: 'plq_realunit_ocp_sepolia', + fiatAsset: 'CHF', + fiatAmount: 2, + zchfAmount: 2.0, + ); + + late _MockPayQuoteCubit quoteCubit; + + setUpAll(() { + final getIt = GetIt.instance; + getIt.registerSingleton(_MockPayService()); + getIt.registerSingleton(_MockFaucetService()); + getIt.registerSingleton(_MockBlockchainService()); + getIt.registerSingleton(_MockWalletService()); + + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + final wallet = _MockWallet(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + 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(ready); + }); + + group('PayQuoteView responsive matrix (full device x textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + final subject = BlocProvider.value( + value: quoteCubit, + child: const PayQuoteView(), + ); + + await expectNoLayoutOverflow( + tester, + () => _pumpScreen(tester, cell, subject), + reason: 'PayQuoteView overflow / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.widgetWithText(FilledButton, S.current.payConfirmButton), + within: find.byType(PayQuoteView), + reason: 'PayQuoteView / ${cell.label}: Confirm CTA not tappable', + ); + }); + }); + } + }); +} 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..404af7bff --- /dev/null +++ b/test/screens/pay/pay_scan_page_test.dart @@ -0,0 +1,243 @@ +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:mobile_scanner/mobile_scanner.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/lnurl_decoder.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart'; +import 'package:realunit_wallet/screens/pay/pay_quote_page.dart'; +import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; + +import '../../helper/helper.dart'; + +class _MockPayScanCubit extends MockCubit implements PayScanCubit {} + +class _MockPayService extends Mock implements RealUnitPayService {} + +void main() { + late _MockPayScanCubit scanCubit; + + setUpAll(() { + // pay_scan_page.dart carries the `@no-integration-test` note: the live + // camera is exercised only on a real device. The stub keeps the headless + // preview deterministic and free of MissingPluginException. + stubMobileScannerChannel(); + + // The decoded-link navigation pushes PayQuotePage, which resolves the pay + // service from getIt and triggers a load(); register a mock whose quote + // fetch throws so the pushed route builds deterministically (rendering the + // PayQuoteError message) without touching a live backend. + final payService = _MockPayService(); + when(() => payService.getPaymentDetails(any())).thenThrow( + const ApiException(code: 'TEST', message: 'no backend in widget test'), + ); + GetIt.instance.registerSingleton(payService); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + scanCubit = _MockPayScanCubit(); + when(() => scanCubit.state).thenReturn(const PayScanScanning()); + when(() => scanCubit.reset()).thenReturn(null); + }); + + Widget buildSubject() => BlocProvider.value( + value: scanCubit, + child: const PayScanView(), + ); + + group('$PayScanPage', () { + testWidgets('builds its own cubit and renders $PayScanView', (tester) async { + await tester.pumpApp(const PayScanPage()); + + expect(find.byType(PayScanView), findsOne); + }); + }); + + group('$PayScanView', () { + testWidgets('renders the scan title and the scanner preview', (tester) async { + await tester.pumpApp(buildSubject()); + + expect(find.text(S.current.payScanTitle), findsOne); + expect(find.byType(MobileScanner), findsOne); + }); + + testWidgets('onDetect forwards a scanned raw value to the cubit', (tester) async { + when(() => scanCubit.onCodeDetected(any())).thenReturn(null); + + await tester.pumpApp(buildSubject()); + + final scanner = tester.widget(find.byType(MobileScanner)); + // A capture with no barcodes is ignored (rawValue is null) … + scanner.onDetect!(const BarcodeCapture()); + // … while a barcode with a raw value is forwarded to the cubit. + scanner.onDetect!( + const BarcodeCapture(barcodes: [Barcode(rawValue: 'lnurl_raw')]), + ); + + verify(() => scanCubit.onCodeDetected('lnurl_raw')).called(1); + }); + + testWidgets('an invalid scan shows a snackbar and resets the cubit', (tester) async { + whenListen( + scanCubit, + Stream.fromIterable([const PayScanInvalid('bad code')]), + initialState: const PayScanScanning(), + ); + + await tester.pumpApp(buildSubject()); + await tester.pump(); + + expect(find.byType(SnackBar), findsOne); + expect(find.text(S.current.payScanInvalid), findsOne); + verify(() => scanCubit.reset()).called(1); + }); + + testWidgets('a decoded link navigates to the quote step and resets the cubit', (tester) async { + final link = DecodedPaymentLink( + id: 'pl_abc123', + lnurlpUrl: Uri.parse('https://api.dfx.swiss/v1/lnurlp/pl_abc123'), + ); + whenListen( + scanCubit, + Stream.fromIterable([PayScanDecoded(link)]), + initialState: const PayScanScanning(), + ); + + await tester.pumpApp(buildSubject()); + await tester.pumpAndSettle(); + + // The quote step is pushed and rendered; the cubit is reset so returning + // to the scanner re-arms detection. + expect(find.byType(PayQuoteView), findsOne); + verify(() => scanCubit.reset()).called(1); + }); + + testWidgets( + 'errorBuilder shows the camera-unavailable message for non-permission-denied errors', + (tester) async { + await tester.pumpApp(buildSubject()); + + final scanner = tester.widget(find.byType(MobileScanner)); + final context = tester.element(find.byType(MobileScanner)); + final nonPermissionCode = MobileScannerErrorCode.values.firstWhere( + (code) => code != MobileScannerErrorCode.permissionDenied, + ); + final errorWidget = scanner.errorBuilder!( + context, + MobileScannerException(errorCode: nonPermissionCode), + ); + + await tester.pumpWidget(MaterialApp(home: errorWidget)); + + expect(find.text(S.current.payScanCameraUnavailable), findsOne); + expect(find.text(S.current.payScanCameraPermissionDenied), findsNothing); + }, + ); + + testWidgets( + 'an initialPayload feeds the cubit exactly once and skips the live camera', + (tester) async { + when(() => scanCubit.onCodeDetected(any())).thenReturn(null); + + await tester.pumpApp( + BlocProvider.value( + value: scanCubit, + child: const PayScanView( + initialPayload: 'lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD', + ), + ), + ); + // CupertinoActivityIndicator animates indefinitely, so pumpAndSettle + // would hang. One extra pump is enough for the deferred post-frame + // onCodeDetected callback to fire. + await tester.pump(); + + verify( + () => scanCubit.onCodeDetected( + 'lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD', + ), + ).called(1); + expect(find.byType(MobileScanner), findsNothing); + }, + ); + + testWidgets( + 'an initialPayload with a successful decode dismisses the spinner and navigates to the quote step', + (tester) async { + when(() => scanCubit.onCodeDetected(any())).thenReturn(null); + 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( + BlocProvider.value( + value: scanCubit, + child: const PayScanView( + initialPayload: 'lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD', + ), + ), + ); + await tester.pumpAndSettle(); + + // Spinner dismissed, quote step pushed and rendered; the cubit is reset. + expect(find.byType(CupertinoActivityIndicator), findsNothing); + expect(find.byType(PayQuoteView), findsOne); + verify(() => scanCubit.reset()).called(1); + }, + ); + + testWidgets( + 'an invalid initialPayload dismisses the spinner and shows the live camera', + (tester) async { + whenListen( + scanCubit, + Stream.fromIterable([ + const PayScanInvalid('bad code'), + ]), + initialState: const PayScanScanning(), + ); + + await tester.pumpApp( + BlocProvider.value( + value: scanCubit, + child: const PayScanView( + initialPayload: 'lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD', + ), + ), + ); + // CupertinoActivityIndicator animates indefinitely, so pumpAndSettle + // would hang. One extra pump is enough for the listener (invalid → + // setState dismissing the spinner) and snackbar to run. + await tester.pump(); + + expect(find.byType(SnackBar), findsOne); + expect(find.byType(MobileScanner), findsOneWidget); + }, + ); + + testWidgets( + 'no initialPayload never calls onCodeDetected and shows the live camera', + (tester) async { + await tester.pumpApp(buildSubject()); + await tester.pumpAndSettle(); + + verifyNever(() => scanCubit.onCodeDetected(any())); + expect(find.byType(MobileScanner), findsOneWidget); + }, + ); + }); +} diff --git a/test/screens/pin/pin_auth_cubit_test.dart b/test/screens/pin/pin_auth_cubit_test.dart index d6f52e388..43b8a8583 100644 --- a/test/screens/pin/pin_auth_cubit_test.dart +++ b/test/screens/pin/pin_auth_cubit_test.dart @@ -5,6 +5,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/storage/secure_storage.dart'; import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; import 'package:realunit_wallet/screens/pin/constants/pin_constants.dart'; +import 'package:realunit_wallet/setup/routing/boot_navigation.dart'; class _MockSecureStorage extends Mock implements SecureStorage {} @@ -226,6 +227,22 @@ void main() { await cubit.reset(); expect(cubit.peekResumeLocation(), isNull); }); + + test( + 'reset clears a pending payment deeplink stash (no replay into a reset/re-onboarded wallet)', + () async { + when(() => storage.deletePinHash()).thenAnswer((_) async {}); + when(() => storage.deleteBiometricEnabled()).thenAnswer((_) async {}); + when(() => storage.resetPinLockout()).thenAnswer((_) async {}); + addTearDown(clearPendingPaymentDeeplink); + + stashPendingPaymentDeeplink('lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD'); + final cubit = build(); + await cubit.reset(); + + expect(peekPendingPaymentDeeplink(), isNull); + }, + ); }); group('reset', () { diff --git a/test/screens/pin/verify_pin_page_test.dart b/test/screens/pin/verify_pin_page_test.dart index ec591b45f..789d6deac 100644 --- a/test/screens/pin/verify_pin_page_test.dart +++ b/test/screens/pin/verify_pin_page_test.dart @@ -131,7 +131,7 @@ void main() { expect(tester.widget(find.byType(NumberPad)).inputEnabled, isFalse); }); - testWidgets('shows error message and locks permanently when pin is reaches threshold', ( + testWidgets('permanently locked in a gate flow shows the button-free lockout message', ( tester, ) async { when(() => verifyPinCubit.state).thenReturn(const VerifyPinLocked(failedAttempts: 9)); @@ -140,10 +140,32 @@ void main() { buildSubject(VerifyPinView(onAuthenticated: onAuthenticated)), ); - expect(find.text(S.current.pinVerifyLocked), findsOne); + // No `bottom` (no Forgot-PIN button) → the gate-variant text is shown, + // not the one pointing at a button that isn't there. + expect(find.text(S.current.pinVerifyLockedGate), findsOne); + expect(find.text(S.current.pinVerifyLocked), findsNothing); expect(tester.widget(find.byType(NumberPad)).inputEnabled, isFalse); }); + testWidgets('permanently locked in the app-lock flow references the forgot-PIN reset', ( + tester, + ) async { + when(() => verifyPinCubit.state).thenReturn(const VerifyPinLocked(failedAttempts: 9)); + + await tester.pumpApp( + buildSubject( + VerifyPinView( + onAuthenticated: onAuthenticated, + bottom: const SizedBox(), + ), + ), + ); + + // A `bottom` widget (the Forgot-PIN button lives here) → the text that + // points at it is valid. + expect(find.text(S.current.pinVerifyLocked), findsOne); + }); + testWidgets('shows a loading indicator and hides the number pad while verifying', ( tester, ) async { 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..7bc8137dd --- /dev/null +++ b/test/screens/send/cubits/send_process/send_process_state_test.dart @@ -0,0 +1,161 @@ +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 + canRetry', () { + // Equal when reason, message, and canRetry 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', + ), + ), + ), + ); + + // Unequal when canRetry differs (same reason + message). + expect( + const SendProcessFailure( + SendProcessFailureReason.generic, + message: 'm', + canRetry: true, + ), + isNot( + equals( + const SendProcessFailure( + SendProcessFailureReason.generic, + message: 'm', + ), + ), + ), + ); + expect( + const SendProcessFailure( + SendProcessFailureReason.generic, + message: 'm', + canRetry: true, + ), + const SendProcessFailure( + SendProcessFailureReason.generic, + message: 'm', + canRetry: true, + ), + ); + + // The default (no `message`, canRetry false) 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', false], + ); + expect( + const SendProcessFailure(SendProcessFailureReason.signatureCancelled).props, + [SendProcessFailureReason.signatureCancelled, null, false], + ); + expect( + const SendProcessFailure( + SendProcessFailureReason.generic, + message: 'x', + canRetry: true, + ).props, + [SendProcessFailureReason.generic, 'x', true], + ); + expect( + const SendProcessFailure(SendProcessFailureReason.confirmMismatch).props, + [SendProcessFailureReason.confirmMismatch, null, false], + ); + }); + }); +} 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..72cf02f0b --- /dev/null +++ b/test/screens/send/cubits/send_process_cubit_test.dart @@ -0,0 +1,886 @@ +import 'dart:async'; + +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/buy_exceptions.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({int id = 42}) => RealUnitTransferPaymentInfoDto.fromJson({ + 'id': id, + '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, + ); + + /// Stubs `confirmTransfer` with the required named user-confirmed params. + void stubConfirm([Object? answerOrThrow]) { + Future invocation() => service.confirmTransfer( + any(), + confirmedRecipient: any(named: 'confirmedRecipient'), + confirmedAmount: any(named: 'confirmedAmount'), + ); + if (answerOrThrow is Exception) { + when(invocation).thenThrow(answerOrThrow); + } else { + when(invocation).thenAnswer((_) async => (answerOrThrow as String?) ?? '0xdeadbeef'); + } + } + + void verifyConfirmCalled({int times = 1}) { + verify( + () => service.confirmTransfer( + any(), + confirmedRecipient: any(named: 'confirmedRecipient'), + confirmedAmount: any(named: 'confirmedAmount'), + ), + ).called(times); + } + + void wireHappyPath() { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + stubConfirm(); + } + + 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'); + verify( + () => service.confirmTransfer( + any(), + confirmedRecipient: '0xRecipient', + confirmedAmount: 5, + ), + ).called(1); + 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()); + stubConfirm(const TransferSignatureUnsupportedException()); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.signatureUnsupported); + expect(state.canRetry, isFalse); + 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('prepare-phase TransferSignatureUnsupportedException → signatureUnsupported', () async { + when( + () => service.prepareTransfer(any()), + ).thenThrow(const TransferSignatureUnsupportedException()); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.signatureUnsupported); + expect(state.canRetry, isFalse); + verifyNever( + () => service.confirmTransfer( + any(), + confirmedRecipient: any(named: 'confirmedRecipient'), + confirmedAmount: any(named: 'confirmedAmount'), + ), + ); + await cubit.close(); + }); + + test('prepare-phase SigningCancelledException → signatureCancelled', () async { + when(() => service.prepareTransfer(any())).thenThrow(const SigningCancelledException()); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.signatureCancelled); + expect(state.canRetry, isFalse); + verifyNever( + () => service.confirmTransfer( + any(), + confirmedRecipient: any(named: 'confirmedRecipient'), + confirmedAmount: any(named: 'confirmedAmount'), + ), + ); + await cubit.close(); + }); + + test('prepare-phase BitboxNotConnectedException → signatureUnsupported', () async { + when(() => service.prepareTransfer(any())).thenThrow(const BitboxNotConnectedException()); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.signatureUnsupported); + expect(state.canRetry, isFalse); + verifyNever( + () => service.confirmTransfer( + any(), + confirmedRecipient: any(named: 'confirmedRecipient'), + confirmedAmount: any(named: 'confirmedAmount'), + ), + ); + await cubit.close(); + }); + + test('signing cancelled → signatureCancelled', () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + stubConfirm(const SigningCancelledException()); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.signatureCancelled); + expect(state.canRetry, isFalse); + await cubit.close(); + }); + + test('bitbox not connected (defensive) → signatureUnsupported', () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + stubConfirm(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'); + expect(state.canRetry, isFalse); + 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(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.generic); + // Prepare-phase failures are never retryable (no transfer id stored). + expect(state.canRetry, isFalse); + await cubit.close(); + }); + + test('RegistrationRequiredException → registrationOrKycRequired with message', () async { + when(() => service.prepareTransfer(any())).thenThrow( + const RegistrationRequiredException( + code: 'REGISTRATION_REQUIRED', + message: 'Please register first', + ), + ); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.registrationOrKycRequired); + expect(state.message, 'Please register first'); + await cubit.close(); + }); + + test('KycLevelRequiredException → registrationOrKycRequired with message', () async { + when(() => service.prepareTransfer(any())).thenThrow( + const KycLevelRequiredException( + code: 'KYC_LEVEL_REQUIRED', + message: 'KYC level 2 required', + requiredLevel: 2, + currentLevel: 1, + ), + ); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.registrationOrKycRequired); + expect(state.message, 'KYC level 2 required'); + await cubit.close(); + }); + + test('API 403 → registrationOrKycRequired', () async { + when(() => service.prepareTransfer(any())).thenThrow( + const ApiException(statusCode: 403, code: 'X', message: 'forbidden'), + ); + + final cubit = build(); + await cubit.start(); + + expect( + (cubit.state as SendProcessFailure).reason, + SendProcessFailureReason.registrationOrKycRequired, + ); + await cubit.close(); + }); + + test('closing the cubit before prepareTransfer resolves does not throw and does not emit', () async { + final completer = Completer(); + when(() => service.prepareTransfer(any())).thenAnswer((_) => completer.future); + + final cubit = build(); + final future = cubit.start(); + await cubit.close(); + completer.complete(_info()); + await future; // must not throw StateError, and must not attempt to emit after close + }); + + test('closing the cubit before prepareTransfer rejects does not throw and does not emit', () async { + final completer = Completer(); + when(() => service.prepareTransfer(any())).thenAnswer((_) => completer.future); + + final cubit = build(); + final emitted = []; + final sub = cubit.stream.listen(emitted.add); + final future = cubit.start(); + await cubit.close(); + completer.completeError(Exception('boom')); + await future; // must not throw StateError, and must not attempt to emit after close + await Future.delayed(Duration.zero); + await sub.cancel(); + + expect(emitted.map((s) => s.runtimeType).toList(), [SendProcessPreparing]); + }); + + test('closing the cubit before confirmTransfer resolves does not throw and does not emit', () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + final completer = Completer(); + when( + () => service.confirmTransfer( + any(), + confirmedRecipient: any(named: 'confirmedRecipient'), + confirmedAmount: any(named: 'confirmedAmount'), + ), + ).thenAnswer((_) => completer.future); + + final cubit = build(); + final future = cubit.start(); + await cubit.close(); + completer.complete('0xdeadbeef'); + await future; // must not throw StateError, and must not attempt to emit after close + }); + + test( + 'closing the cubit while confirmTransfer is in flight → does not emit regardless of outcome', + () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + final completer = Completer(); + when( + () => service.confirmTransfer( + any(), + confirmedRecipient: any(named: 'confirmedRecipient'), + confirmedAmount: any(named: 'confirmedAmount'), + ), + ).thenAnswer((_) => completer.future); + + final cubit = build(); + final emitted = []; + final sub = cubit.stream.listen(emitted.add); + final future = cubit.start(); + // Let prepare resolve and start() reach the in-flight confirm await + // (SendProcessSigning) before closing. + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + expect(cubit.state, isA()); + + final countAtClose = emitted.length; + await cubit.close(); + // Representative reject path — centralized isClosed guard must suppress + // emit for every catch branch, not only success. + completer.completeError(Exception('socket hung up')); + await future; // must not throw StateError + // Let any residual microtask that might have emitted flush. + await Future.delayed(Duration.zero); + await sub.cancel(); + + // No state after close (Signing was already emitted before close). + expect(emitted.length, countAtClose); + expect(emitted.map((s) => s.runtimeType).toList(), [ + SendProcessPreparing, + SendProcessSigning, + ]); + }, + ); + + test( + 'closing the cubit while confirmTransfer is in flight, then it resolves successfully → does not emit but logs the missed success', + () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + final completer = Completer(); + when( + () => service.confirmTransfer( + any(), + confirmedRecipient: any(named: 'confirmedRecipient'), + confirmedAmount: any(named: 'confirmedAmount'), + ), + ).thenAnswer((_) => completer.future); + + final cubit = build(); + final emitted = []; + final sub = cubit.stream.listen(emitted.add); + final future = cubit.start(); + // Let prepare resolve and start() reach the in-flight confirm await + // (SendProcessSigning) before closing. + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + expect(cubit.state, isA()); + + final countAtClose = emitted.length; + await cubit.close(); + // Direct success path after close — isClosed guard must suppress emit + // and take the developer.log branch for the missed txHash. + completer.complete('0xdeadbeef'); + await future; // must not throw StateError + // Let any residual microtask that might have emitted flush. + await Future.delayed(Duration.zero); + await sub.cancel(); + + // No state after close (Signing was already emitted before close). + expect(emitted.length, countAtClose); + expect(emitted.map((s) => s.runtimeType).toList(), [ + SendProcessPreparing, + SendProcessSigning, + ]); + }, + ); + + test('unexpected error on prepare → generic (non-retryable)', () async { + when(() => service.prepareTransfer(any())).thenThrow(Exception('weird')); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.generic); + expect(state.canRetry, isFalse); + await cubit.close(); + }); + + test( + 'unclassified/transport error on confirm → retryable failure; prepare called once', + () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + stubConfirm(Exception('socket hung up')); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.generic); + expect(state.canRetry, isTrue); + verify(() => service.prepareTransfer(any())).called(1); + verifyConfirmCalled(); + await cubit.close(); + }, + ); + + test( + 'retryConfirm re-invokes confirmTransfer with the same stored info/id (no new prepare)', + () async { + final prepared = _info(id: 42); + when(() => service.prepareTransfer(any())).thenAnswer((_) async => prepared); + + var confirmCalls = 0; + RealUnitTransferPaymentInfoDto? confirmedInfo; + when( + () => service.confirmTransfer( + any(), + confirmedRecipient: any(named: 'confirmedRecipient'), + confirmedAmount: any(named: 'confirmedAmount'), + ), + ).thenAnswer((invocation) async { + confirmCalls++; + confirmedInfo = invocation.positionalArguments.first as RealUnitTransferPaymentInfoDto; + if (confirmCalls == 1) { + throw Exception('transport lost'); + } + return '0xretryhash'; + }); + + final cubit = build(); + await cubit.start(); + + expect((cubit.state as SendProcessFailure).canRetry, isTrue); + + await cubit.retryConfirm(); + + expect(cubit.state, isA()); + expect((cubit.state as SendProcessSuccess).txHash, '0xretryhash'); + expect(confirmCalls, 2); + expect(confirmedInfo!.id, 42); + // prepareTransfer must never have been called again on retry. + verify(() => service.prepareTransfer(any())).called(1); + await cubit.close(); + }, + ); + + test('409 already confirmed on start() → SendProcessSuccess', () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + stubConfirm( + const TransferAlreadyConfirmedException( + statusCode: 409, + code: 'CONFLICT', + message: 'Transaction request is already confirmed', + txHash: '0xalready', + ), + ); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessSuccess; + expect(state.txHash, '0xalready'); + await cubit.close(); + }); + + test('409 already confirmed on retryConfirm() → SendProcessSuccess', () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + + var confirmCalls = 0; + when( + () => service.confirmTransfer( + any(), + confirmedRecipient: any(named: 'confirmedRecipient'), + confirmedAmount: any(named: 'confirmedAmount'), + ), + ).thenAnswer((_) async { + confirmCalls++; + if (confirmCalls == 1) { + throw Exception('timeout after server accepted'); + } + throw const TransferAlreadyConfirmedException( + statusCode: 409, + code: 'CONFLICT', + message: 'Transaction request is already confirmed', + ); + }); + + final cubit = build(); + await cubit.start(); + expect((cubit.state as SendProcessFailure).canRetry, isTrue); + + await cubit.retryConfirm(); + + // No txHash on the 409 body → empty string (success sheet does not show it). + expect(cubit.state, isA()); + expect((cubit.state as SendProcessSuccess).txHash, ''); + await cubit.close(); + }); + + test('retryConfirm with nothing prepared → throws StateError', () async { + final cubit = build(); + + expect(() => cubit.retryConfirm(), throwsA(isA())); + await cubit.close(); + }); + + test('retryConfirm after a non-retryable failure → throws StateError', () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + stubConfirm(const TransferSignatureUnsupportedException()); + + final cubit = build(); + await cubit.start(); + + expect((cubit.state as SendProcessFailure).canRetry, isFalse); + expect(() => cubit.retryConfirm(), throwsA(isA())); + await cubit.close(); + }); + + test('retryConfirm while a confirm is already in flight → throws StateError', () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + final completer = Completer(); + when( + () => service.confirmTransfer( + any(), + confirmedRecipient: any(named: 'confirmedRecipient'), + confirmedAmount: any(named: 'confirmedAmount'), + ), + ).thenAnswer((_) => completer.future); + + final cubit = build(); + final startFuture = cubit.start(); + // Let prepare resolve and start() reach the in-flight confirm await (SendProcessSigning). + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(cubit.state, isA()); + expect(() => cubit.retryConfirm(), throwsA(isA())); + + completer.complete('0xdone'); + await startFuture; + await cubit.close(); + }); + + test( + 'concurrent start while confirm is already in flight → throws StateError', + () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + final completer = Completer(); + when( + () => service.confirmTransfer( + any(), + confirmedRecipient: any(named: 'confirmedRecipient'), + confirmedAmount: any(named: 'confirmedAmount'), + ), + ).thenAnswer((_) => completer.future); + + final cubit = build(); + final firstStart = cubit.start(); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + expect(cubit.state, isA()); + + // Second start re-prepares then hits _confirmPrepared's in-flight guard. + await expectLater(cubit.start(), throwsA(isA())); + + completer.complete('0xdone'); + await firstStart; + await cubit.close(); + }, + ); + + test( + 'TransferConfirmMismatchException → confirmMismatch, non-retryable', + () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + stubConfirm(const TransferConfirmMismatchException('toAddress mismatch')); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.confirmMismatch); + expect(state.canRetry, isFalse); + expect(() => cubit.retryConfirm(), throwsA(isA())); + await cubit.close(); + }, + ); + + test( + 'confirm-phase TransferGasFundingUnavailableException → gasFundingUnavailable', + () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + stubConfirm(const TransferGasFundingUnavailableException()); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.gasFundingUnavailable); + expect(state.canRetry, isFalse); + await cubit.close(); + }, + ); + + test( + 'confirm-phase RegistrationRequiredException → registrationOrKycRequired', + () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + stubConfirm( + const RegistrationRequiredException( + code: 'REGISTRATION_REQUIRED', + message: 'Please register first', + ), + ); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.registrationOrKycRequired); + expect(state.message, 'Please register first'); + expect(state.canRetry, isFalse); + await cubit.close(); + }, + ); + + test( + 'confirm-phase KycLevelRequiredException → registrationOrKycRequired', + () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + stubConfirm( + const KycLevelRequiredException( + code: 'KYC_LEVEL_REQUIRED', + message: 'KYC level 2 required', + requiredLevel: 2, + currentLevel: 1, + ), + ); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.registrationOrKycRequired); + expect(state.message, 'KYC level 2 required'); + expect(state.canRetry, isFalse); + await cubit.close(); + }, + ); + + test( + 'confirm-phase API 500 → generic (retryable via _isDefinitiveApiFailure)', + () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + stubConfirm(const ApiException(statusCode: 500, code: 'X', message: 'boom')); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.generic); + expect(state.message, 'boom'); + expect(state.canRetry, isTrue); + await cubit.close(); + }, + ); + + test( + 'confirm-phase API 400 → invalidRequest (non-retryable definitive failure)', + () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + stubConfirm( + const ApiException(statusCode: 400, code: 'X', message: 'bad request'), + ); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.invalidRequest); + expect(state.message, 'bad request'); + expect(state.canRetry, isFalse); + await cubit.close(); + }, + ); + + test( + 'confirm-phase API 403 → registrationOrKycRequired (non-retryable)', + () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + stubConfirm(const ApiException(statusCode: 403, code: 'X', message: 'forbidden')); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.registrationOrKycRequired); + expect(state.canRetry, isFalse); + await cubit.close(); + }, + ); + + test( + 'confirm-phase API 404 → invalidRequest (non-retryable)', + () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + stubConfirm(const ApiException(statusCode: 404, code: 'X', message: 'not found')); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.invalidRequest); + expect(state.canRetry, isFalse); + await cubit.close(); + }, + ); + + test( + 'confirm-phase API 503 → gasFundingUnavailable (non-retryable)', + () async { + when(() => service.prepareTransfer(any())).thenAnswer((_) async => _info()); + stubConfirm( + const ApiException(statusCode: 503, code: 'X', message: 'unavailable'), + ); + + final cubit = build(); + await cubit.start(); + + final state = cubit.state as SendProcessFailure; + expect(state.reason, SendProcessFailureReason.gasFundingUnavailable); + expect(state.canRetry, isFalse); + 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..8bd3ae82d --- /dev/null +++ b/test/screens/send/cubits/send_recipient_cubit_test.dart @@ -0,0 +1,144 @@ +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( + 'strips ethereum: pay- EIP-681 payment-request marker before the address', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit('ethereum:pay-$lowercase@1?value=100'), + expect: () => [const SendRecipientValid(checksummed)], + ); + + blocTest( + 'strips a query suffix when no @chainId is present (simple form)', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit('ethereum:$lowercase?value=0'), + expect: () => [const SendRecipientValid(checksummed)], + ); + + blocTest( + 'EIP-681 ERC-20 transfer URI extracts recipient from address= query param', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit( + 'ethereum:0x1111111111111111111111111111111111111111/transfer' + '?address=$lowercase&uint256=1000000000000000000', + ), + expect: () => [const SendRecipientValid(checksummed)], + ); + + blocTest( + 'EIP-681 function-call with unrecognized function name fails closed', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit('ethereum:$lowercase/approve?spender=0xSpender&uint256=1'), + expect: () => [isA()], + ); + + blocTest( + 'EIP-681 function-call without a query string fails closed', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit('ethereum:$lowercase/transfer'), + expect: () => [isA()], + ); + + blocTest( + 'EIP-681 /transfer URI without address query param fails closed', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit('ethereum:$lowercase/transfer?uint256=1'), + expect: () => [isA()], + ); + + blocTest( + 'EIP-681 /transfer URI with empty address= query param fails closed', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit('ethereum:$lowercase/transfer?address=&uint256=1'), + expect: () => [isA()], + ); + + blocTest( + 'EIP-681 /transfer URI with duplicate address= query keys fails closed', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit( + 'ethereum:0x1111111111111111111111111111111111111111/transfer' + '?address=$lowercase&address=0x2222222222222222222222222222222222222222', + ), + expect: () => [isA()], + ); + + blocTest( + 'EIP-681 /transfer URI with malformed percent-encoding emits invalid (no throw)', + build: SendRecipientCubit.new, + act: (cubit) => cubit.submit( + 'ethereum:0x1111111111111111111111111111111111111111/transfer?address=%ZZ', + ), + expect: () => [isA()], + ); + + 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_amount_responsive_matrix_test.dart b/test/screens/send/send_amount_responsive_matrix_test.dart new file mode 100644 index 000000000..79fc30835 --- /dev/null +++ b/test/screens/send/send_amount_responsive_matrix_test.dart @@ -0,0 +1,93 @@ +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/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/send_amount_page.dart'; +import 'package:realunit_wallet/styles/themes.dart'; + +import '../../helper/helper.dart'; + +class _MockSellBalanceCubit extends MockCubit implements SellBalanceCubit {} + +Future _pumpScreen( + WidgetTester tester, + MatrixCell cell, + Widget child, +) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: child, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + group('SendAmountView responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + final balance = Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: '0xwallet', + balance: BigInt.from(42), + asset: realUnitAsset, + ); + final balanceCubit = _MockSellBalanceCubit(); + when(() => balanceCubit.state).thenReturn(balance); + whenListen( + balanceCubit, + const Stream.empty(), + initialState: balance, + ); + final amountCubit = SendAmountCubit(availableShares: balance.balance)..amountChanged('5'); + addTearDown(amountCubit.close); + + final subject = MultiBlocProvider( + providers: [ + BlocProvider.value(value: balanceCubit), + BlocProvider.value(value: amountCubit), + ], + child: const SendAmountView(recipient: '0xRecipient'), + ); + + await expectNoLayoutOverflow( + tester, + () => _pumpScreen(tester, cell, subject), + reason: 'SendAmountView overflow / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.widgetWithText(FilledButton, S.current.next), + within: find.byType(SendAmountView), + reason: 'SendAmountView / ${cell.label}: Next CTA not tappable', + ); + }); + }); + } + }); +} 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..4f37bb555 --- /dev/null +++ b/test/screens/send/send_confirm_page_test.dart @@ -0,0 +1,85 @@ +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); + }); + + testWidgets('double-tap before rebuild pushes only one process page', (tester) async { + await tester.pumpApp( + const SendConfirmPage(recipient: '0xRecipient', amount: 5), + ); + + // Two tap-up callbacks before any rebuild (WidgetTester.tap does not pump). + await tester.tap(find.text(S.current.sendConfirmButton)); + await tester.tap(find.text(S.current.sendConfirmButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byType(SendProcessView), findsOneWidget); + }); + }); +} diff --git a/test/screens/send/send_confirm_responsive_matrix_test.dart b/test/screens/send/send_confirm_responsive_matrix_test.dart new file mode 100644 index 000000000..0bf4bae44 --- /dev/null +++ b/test/screens/send/send_confirm_responsive_matrix_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.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/send_confirm_page.dart'; +import 'package:realunit_wallet/styles/themes.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 {} + +Future _pumpScreen( + WidgetTester tester, + MatrixCell cell, + Widget child, +) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: child, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + setUpAll(() { + final getIt = GetIt.instance; + getIt.registerSingleton(_MockTransferService()); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + final wallet = _MockWallet(); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + 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 responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + const subject = SendConfirmPage( + recipient: '0x9F5713dEAcb8e9CaB6c2D3FaE1aFc2715F8D2D71', + amount: 123456, + ); + + await expectNoLayoutOverflow( + tester, + () => _pumpScreen(tester, cell, subject), + reason: 'SendConfirmPage overflow / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.widgetWithText(FilledButton, S.current.sendConfirmButton), + within: find.byType(SendConfirmPage), + reason: 'SendConfirmPage / ${cell.label}: Confirm CTA not tappable', + ); + }); + }); + } + }); +} 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..dd45b5fc3 --- /dev/null +++ b/test/screens/send/send_process_page_test.dart @@ -0,0 +1,308 @@ +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, + ); + }); + }); + + group('$SendProcessView PopScope canPop', () { + testWidgets('blocks back navigation while signing (non-terminal)', (tester) async { + when(() => processCubit.state).thenReturn(const SendProcessSigning()); + await tester.pumpApp(buildSubject()); + + final popScope = tester.widget(find.byType(PopScope)); + expect(popScope.canPop, isFalse); + }); + + testWidgets('allows back navigation on success (terminal)', (tester) async { + when(() => processCubit.state).thenReturn(const SendProcessSuccess('0xtx')); + await tester.pumpApp(buildSubject()); + + final popScope = tester.widget(find.byType(PopScope)); + expect(popScope.canPop, isTrue); + }); + + testWidgets('blocks back navigation on failure (closes the retry stale-window)', ( + tester, + ) async { + when(() => processCubit.state).thenReturn( + const SendProcessFailure(SendProcessFailureReason.generic), + ); + await tester.pumpApp(buildSubject()); + + final popScope = tester.widget(find.byType(PopScope)); + expect(popScope.canPop, isFalse); + }); + + testWidgets('blocks back navigation while preparing (non-terminal)', (tester) async { + when(() => processCubit.state).thenReturn(const SendProcessPreparing()); + await tester.pumpApp(buildSubject()); + + final popScope = tester.widget(find.byType(PopScope)); + expect(popScope.canPop, isFalse); + }); + }); + + // 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); + // Non-retryable terminal states never offer Retry. + expect(find.text(S.current.retry), findsNothing); + + 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); + expect(find.text(S.current.retry), findsNothing); + }); + + 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( + 'registration/KYC failure always shows localized copy, never raw API message', + (tester) async { + await pumpWithState( + tester, + const SendProcessFailure( + SendProcessFailureReason.registrationOrKycRequired, + message: 'Please complete KYC', + ), + ); + + expect(find.text(S.current.sendFailureRegistrationOrKycRequired), findsOne); + expect(find.text('Please complete KYC'), findsNothing); + }, + ); + + testWidgets('registration/KYC failure falls back to localized copy without message', ( + tester, + ) async { + await pumpWithState( + tester, + const SendProcessFailure(SendProcessFailureReason.registrationOrKycRequired), + ); + + expect(find.text(S.current.sendFailureRegistrationOrKycRequired), findsOne); + }); + + testWidgets('generic failure message', (tester) async { + await pumpWithState( + tester, + const SendProcessFailure(SendProcessFailureReason.generic), + ); + + expect(find.text(S.current.sendFailureGeneric), findsOne); + expect(find.text(S.current.retry), findsNothing); + }); + + testWidgets('confirm-mismatch failure message (non-retryable)', (tester) async { + await pumpWithState( + tester, + const SendProcessFailure(SendProcessFailureReason.confirmMismatch), + ); + + expect(find.text(S.current.sendFailureConfirmMismatch), findsOne); + expect(find.text(S.current.retry), findsNothing); + expect(find.text(S.current.close), findsOne); + }); + + testWidgets( + 'retryable failure shows Retry; tapping it calls retryConfirm and keeps the page', + (tester) async { + when(() => processCubit.retryConfirm()).thenAnswer((_) async {}); + + await pumpWithState( + tester, + const SendProcessFailure( + SendProcessFailureReason.generic, + message: 'socket hung up', + canRetry: true, + ), + ); + + expect(find.text(S.current.retry), findsOne); + expect(find.text(S.current.close), findsOne); + expect(find.byType(SendProcessView), findsOne); + + await tester.tap(find.text(S.current.retry)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + verify(() => processCubit.retryConfirm()).called(1); + // Retry must dismiss only the sheet — the SendProcessPage/View stays. + expect(find.byType(SendProcessView), findsOne); + expect(find.text(S.current.retry), findsNothing); + }, + ); + + testWidgets('non-retryable failure: Close only, no Retry button', (tester) async { + await pumpWithState( + tester, + const SendProcessFailure(SendProcessFailureReason.signatureUnsupported), + ); + + expect(find.text(S.current.retry), findsNothing); + 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.error_rounded), findsNothing); + }); + }); +} diff --git a/test/screens/send/send_process_result_sheet_responsive_matrix_test.dart b/test/screens/send/send_process_result_sheet_responsive_matrix_test.dart new file mode 100644 index 000000000..357f6dbce --- /dev/null +++ b/test/screens/send/send_process_result_sheet_responsive_matrix_test.dart @@ -0,0 +1,82 @@ +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/send/cubits/send_process/send_process_cubit.dart'; +import 'package:realunit_wallet/screens/send/send_process_page.dart'; +import 'package:realunit_wallet/styles/themes.dart'; + +import '../../helper/helper.dart'; + +class _MockSendProcessCubit extends MockCubit implements SendProcessCubit {} + +Future _pumpResultSheet( + WidgetTester tester, + MatrixCell cell, + SendProcessCubit cubit, +) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: BlocProvider.value( + value: cubit, + child: const SendProcessView(), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); +} + +void main() { + group('SendProcess result sheet responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + final processCubit = _MockSendProcessCubit(); + when(() => processCubit.retryConfirm()).thenAnswer((_) async {}); + whenListen( + processCubit, + Stream.value( + const SendProcessFailure( + SendProcessFailureReason.generic, + message: 'socket hung up', + canRetry: true, + ), + ), + initialState: const SendProcessSigning(), + ); + + await expectNoLayoutOverflow( + tester, + () => _pumpResultSheet(tester, cell, processCubit), + reason: 'SendProcess result sheet overflow / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.widgetWithText(FilledButton, S.current.retry), + within: find.byType(SendProcessView), + reason: 'SendProcess result sheet / ${cell.label}: Retry CTA not tappable', + ); + }); + }); + } + }); +} 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/screens/send/send_recipient_responsive_matrix_test.dart b/test/screens/send/send_recipient_responsive_matrix_test.dart new file mode 100644 index 000000000..cf5563e60 --- /dev/null +++ b/test/screens/send/send_recipient_responsive_matrix_test.dart @@ -0,0 +1,82 @@ +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/send/cubits/send_recipient/send_recipient_cubit.dart'; +import 'package:realunit_wallet/screens/send/send_recipient_page.dart'; +import 'package:realunit_wallet/styles/themes.dart'; + +import '../../helper/helper.dart'; + +class _MockSendRecipientCubit extends MockCubit implements SendRecipientCubit {} + +Future _pumpScreen( + WidgetTester tester, + MatrixCell cell, + Widget child, +) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp( + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: child, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + setUpAll(stubMobileScannerChannel); + + group('SendRecipientView responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + final recipientCubit = _MockSendRecipientCubit(); + when(() => recipientCubit.state).thenReturn(const SendRecipientEmpty()); + whenListen( + recipientCubit, + const Stream.empty(), + initialState: const SendRecipientEmpty(), + ); + when(() => recipientCubit.submit(any())).thenReturn(null); + when(() => recipientCubit.onCodeDetected(any())).thenReturn(null); + + final subject = BlocProvider.value( + value: recipientCubit, + child: const SendRecipientView(), + ); + + await expectNoLayoutOverflow( + tester, + () => _pumpScreen(tester, cell, subject), + reason: 'SendRecipientView overflow / ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.widgetWithText(FilledButton, S.current.next), + within: find.byType(SendRecipientView), + reason: 'SendRecipientView / ${cell.label}: Next CTA not tappable', + ); + }); + }); + } + }); +} diff --git a/test/screens/support/support_tickets_page_test.dart b/test/screens/support/support_tickets_page_test.dart index f6fcfbb70..601d5f88c 100644 --- a/test/screens/support/support_tickets_page_test.dart +++ b/test/screens/support/support_tickets_page_test.dart @@ -98,6 +98,39 @@ void main() { expect(find.byType(OutlinedTile), findsNWidgets(tickets.length)); }); + testWidgets('renders the ticket date in local time, not UTC', (tester) async { + // 23:30 UTC crosses midnight only in a positive-offset zone, where the + // local calendar date differs from the UTC one — the case that tells the + // fix apart from the old raw-UTC rendering. On a zero/negative-offset + // runner (e.g. the GitHub-hosted UTC CI job) the two coincide, so skip + // rather than pass vacuously and masquerade as a real regression guard. + final createdUtc = DateTime.utc(2024, 1, 15, 23, 30); + final local = createdUtc.toLocal(); + if (local.day == createdUtc.day) { + markTestSkipped( + 'runner timezone does not push 23:30 UTC across midnight; ' + 'the local-vs-UTC date regression is not exercised here', + ); + return; + } + final tickets = [ + SupportIssue( + uid: '123', + created: createdUtc, + messages: [], + name: 'name', + reason: SupportIssueReason.other, + state: SupportIssueState.created, + type: SupportIssueType.genericIssue, + ), + ]; + when(() => supportTicketsCubit.state).thenReturn(SupportTicketsLoaded(tickets)); + + await tester.pumpApp(buildSubject(const SupportTicketsView())); + + expect(find.text('${local.day}.${local.month}.${local.year}'), findsOne); + }); + testWidgets('renders correctly when successfully loaded but no tickets', (tester) async { final tickets = []; when(() => supportTicketsCubit.state).thenReturn(SupportTicketsLoaded(tickets)); diff --git a/test/setup/di_pay_service_test.dart b/test/setup/di_pay_service_test.dart new file mode 100644 index 000000000..f8d88e2a8 --- /dev/null +++ b/test/setup/di_pay_service_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/repository/settings_repository.dart'; +import 'package:realunit_wallet/packages/repository/wallet_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/setup/di.dart'; + +class _MockAppStore extends Mock implements AppStore {} + +class _MockBalanceRepository extends Mock implements BalanceRepository {} + +class _MockSettingsRepository extends Mock implements SettingsRepository {} + +class _MockWalletRepository extends Mock implements WalletRepository {} + +void main() { + // The pay flow's backend client is wired through `setupServices()` as a + // factory: `() => RealUnitPayService(getIt(), getIt())`. + // This test exercises that exact registration and then resolves the factory. + // + // `setupServices()` constructs `BalanceService` (eager singleton) up front, + // so `AppStore` + `BalanceRepository` must already be registered. Resolving + // `RealUnitPayService` then pulls the lazy `WalletService`, whose own + // dependencies bottom out at `WalletRepository` + `SettingsRepository` + + // `AppStore` (`BitboxService` is registered by `setupServices()` itself). + // Registering those leaf mocks keeps the whole chain construct-only — none + // of the mocked collaborators perform I/O on construction. + setUp(() { + getIt.reset(); + getIt.registerSingleton(_MockAppStore()); + getIt.registerSingleton(_MockBalanceRepository()); + getIt.registerSingleton(_MockSettingsRepository()); + getIt.registerSingleton(_MockWalletRepository()); + }); + + tearDown(() => getIt.reset()); + + test('setupServices registers a resolvable RealUnitPayService factory', () { + setupServices(); + + expect(getIt.isRegistered(), isTrue); + + final service = getIt(); + expect(service, isA()); + + // registerFactory hands back a fresh instance on every resolution. + expect(identical(service, getIt()), isFalse); + }); +} diff --git a/test/setup/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/error_handling/error_handlers_test.dart b/test/setup/error_handling/error_handlers_test.dart new file mode 100644 index 000000000..dbbef6f5b --- /dev/null +++ b/test/setup/error_handling/error_handlers_test.dart @@ -0,0 +1,73 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/setup/error_handling/error_handlers.dart'; +import 'package:realunit_wallet/setup/error_handling/realunit_error_view.dart'; + +void main() { + // `installErrorHandlers` mutates process-wide statics that the test binding + // also relies on, so every handler is captured before and restored after each + // test — otherwise a failure here would leak into unrelated suites. + late FlutterExceptionHandler? originalFlutterOnError; + late ErrorWidgetBuilder originalErrorWidgetBuilder; + late ErrorCallback? originalPlatformOnError; + + setUp(() { + originalFlutterOnError = FlutterError.onError; + originalErrorWidgetBuilder = ErrorWidget.builder; + originalPlatformOnError = PlatformDispatcher.instance.onError; + }); + + tearDown(() { + FlutterError.onError = originalFlutterOnError; + ErrorWidget.builder = originalErrorWidgetBuilder; + PlatformDispatcher.instance.onError = originalPlatformOnError; + }); + + group('installErrorHandlers', () { + test('installs an async error handler that reports the error onwards', () { + installErrorHandlers(); + + final handler = PlatformDispatcher.instance.onError; + expect(handler, isNotNull, reason: 'async errors must not go unhandled'); + + // The contract that matters: the handler returns false, so the engine's + // fallback reporting still runs after we log. Returning true would claim + // the error as handled and suppress that reporting — and since our + // developer.log is compiled out of release builds, that would leave a + // release crash reported by nobody at all. + expect(handler!(Exception('async boom'), StackTrace.current), isFalse); + }); + + test('async handler tolerates an empty stack trace', () { + installErrorHandlers(); + + expect( + () => PlatformDispatcher.instance.onError!(Exception('boom'), StackTrace.empty), + returnsNormally, + ); + }); + + test('Flutter error handler delegates to the previously installed handler', () { + final received = []; + FlutterError.onError = received.add; + + installErrorHandlers(); + final details = FlutterErrorDetails(exception: Exception('sync boom')); + FlutterError.onError!(details); + + expect(received, [details], reason: 'the default reporting path must stay intact'); + }); + + test('installs the on-brand error widget builder', () { + installErrorHandlers(); + + expect( + ErrorWidget.builder(FlutterErrorDetails(exception: Exception('boom'))), + isA(), + ); + }); + }); +} diff --git a/test/setup/routing/app_link_entry_test.dart b/test/setup/routing/app_link_entry_test.dart index f61c5bb89..608cfafe0 100644 --- a/test/setup/routing/app_link_entry_test.dart +++ b/test/setup/routing/app_link_entry_test.dart @@ -2,11 +2,29 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; import 'package:realunit_wallet/setup/routing/boot_navigation.dart'; import 'package:realunit_wallet/setup/routing/routes/app_link_entry.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; + +class _MockPinAuthCubit extends Mock implements PinAuthCubit {} void main() { + late _MockPinAuthCubit pinAuthCubit; + + setUp(() { + pinAuthCubit = _MockPinAuthCubit(); + GetIt.instance.registerSingleton(pinAuthCubit); + when(() => pinAuthCubit.state).thenReturn( + const PinAuthState(isPinVerified: true, isPinSetup: true), + ); + }); + + tearDown(() => GetIt.instance.reset()); + // A minimal router wired to the *production* scheme handling — the // [appLinkSchemeRedirect] + [appLinkOnException] pair with the push-aware // [effectiveLocation], exactly as router_config.dart wires it. `/home` is the @@ -19,6 +37,7 @@ void main() { redirect: (context, state) => appLinkSchemeRedirect( state, effectiveLocation(router.routerDelegate.currentConfiguration), + router, ), onException: appLinkOnException, routes: [ @@ -41,6 +60,28 @@ void main() { builder: (_, state) => Scaffold(body: Text(state.extra! as String, key: const Key('details'))), ), + // Gate routes from `gateLocations` — needed so warm-resume tests can + // land on a real lock path and prove /pay is NOT pushed over it. + GoRoute( + path: '/verifyPin', + builder: (_, _) => + const Scaffold(body: Text('VERIFY', key: Key('verifyPin'))), + ), + GoRoute( + path: '/pinGate', + builder: (_, _) => + const Scaffold(body: Text('PINGATE', key: Key('pinGate'))), + ), + GoRoute( + path: '/setupPin', + builder: (_, _) => + const Scaffold(body: Text('SETUP', key: Key('setupPin'))), + ), + GoRoute( + name: AppRoutes.pay, + path: '/pay', + builder: (_, state) => Scaffold(body: Text('PAY:${state.extra}', key: const Key('pay'))), + ), ], ); return router; @@ -62,6 +103,48 @@ void main() { expect(appLinkColdStartLocation, '/home'); }); + group('extractPaymentDeeplinkPayload', () { + const sampleLnurl = + 'LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD9E3K7MF0V9CXJTMKXP6XCEF'; + + test('single-colon lightning form extracts the lightning payload', () { + expect( + extractPaymentDeeplinkPayload('realunit-wallet:lightning:$sampleLnurl'), + 'lightning:$sampleLnurl', + ); + }); + + test('bare LNURL form extracts the LNURL verbatim', () { + expect( + extractPaymentDeeplinkPayload('realunit-wallet:$sampleLnurl'), + sampleLnurl, + ); + }); + + test('https lnurlp form extracts the URL verbatim', () { + const url = 'https://api.dfx.swiss/v1/lnurlp/pl_abc123'; + expect( + extractPaymentDeeplinkPayload('realunit-wallet:$url'), + url, + ); + }); + + test('canonical path-less open returns null', () { + expect(extractPaymentDeeplinkPayload(appLinkUrl), isNull); + }); + + test('path-carrying scheme URL returns null', () { + expect( + extractPaymentDeeplinkPayload('realunit-wallet://open/settings'), + isNull, + ); + }); + + test('non-scheme string returns null', () { + expect(extractPaymentDeeplinkPayload('https://example.com'), isNull); + }); + }); + testWidgets('warm resume: a scheme open keeps the user on /dashboard', ( tester, ) async { @@ -130,6 +213,164 @@ void main() { }, ); + testWidgets( + 'warm resume: a payment deeplink pushes /pay with the payload as extra ' + 'on top of the current stack (pushed route not clobbered)', + (tester) async { + const sampleLnurl = + 'LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD9E3K7MF0V9CXJTMKXP6XCEF'; + final router = await pump(tester); + router.go('/dashboard'); + await tester.pumpAndSettle(); + + // Mirror the KYC-pattern test: push a route so the stack is non-trivial. + unawaited(router.push('/settings')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('settings')), findsOneWidget); + + // Single-colon form — the double-slash form throws in Uri.parse / go. + router.go('realunit-wallet:lightning:$sampleLnurl'); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('pay')), findsOneWidget); + expect(find.text('PAY:lightning:$sampleLnurl'), findsOneWidget); + + // The push sits ON TOP of the existing stack — nothing was replaced. + expect(router.canPop(), isTrue); + router.pop(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('settings')), findsOneWidget); + expect(router.canPop(), isTrue); + router.pop(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('dashboard')), findsOneWidget); + }, + ); + + testWidgets( + 'warm resume: a re-lock landing before the deferred push runs is honored — ' + 'no /pay push, payload stashed for post-unlock replay', + (tester) async { + addTearDown(clearPendingPaymentDeeplink); + const sampleLnurl = + 'LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD9E3K7MF0V9CXJTMKXP6XCEF'; + final router = await pump(tester); + router.go('/dashboard'); + await tester.pumpAndSettle(); + + // Keep setUp's unlocked stub (isPinVerified && isPinSetup) so the + // decision-time check schedules the deferred push callback. + router.go('realunit-wallet:lightning:$sampleLnurl'); + // Flip the mock AFTER router.go and BEFORE pumpAndSettle so the + // decision-time check reads unlocked (schedules the deferred callback) + // while the execution-time re-check reads locked — this is what makes + // the test non-vacuous (it would fail if the postFrame re-check were + // deleted, since the deferred callback would then unconditionally push). + // Simulates a re-lock (AppLifecycleState.resumed -> onAppResumed()) + // landing between decision and deferred frame. + when(() => pinAuthCubit.state).thenReturn(const PinAuthState(isPinVerified: false)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('pay')), findsNothing); + expect(currentPath(router), '/dashboard'); + expect(peekPendingPaymentDeeplink(), 'lightning:$sampleLnurl'); + }, + ); + + testWidgets( + 'warm resume: the canonical no-payload open never pushes /pay', + (tester) async { + final router = await pump(tester); + router.go('/dashboard'); + await tester.pumpAndSettle(); + + router.go(appLinkUrl); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('pay')), findsNothing); + expect(find.byKey(const Key('dashboard')), findsOneWidget); + }, + ); + + // Genuinely-locked warm-resume: a payment deeplink while isPinVerified is + // false (real PIN/unlock gates) must stash and stay — never push /pay over + // the lock screen. Step-up gates (e.g. /pinGate while already unlocked) take + // the deferred-push path instead; see the /pinGate test below. + testWidgets( + 'warm resume on /verifyPin while locked: payment deeplink stashes and does not push /pay', + (tester) async { + addTearDown(clearPendingPaymentDeeplink); + when(() => pinAuthCubit.state).thenReturn(const PinAuthState(isPinVerified: false)); + const sampleLnurl = + 'LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD9E3K7MF0V9CXJTMKXP6XCEF'; + final router = await pump(tester); + router.go('/verifyPin'); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('verifyPin')), findsOneWidget); + + router.go('realunit-wallet:lightning:$sampleLnurl'); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('pay')), findsNothing); + expect(find.byKey(const Key('verifyPin')), findsOneWidget); + expect(currentPath(router), '/verifyPin'); + expect(peekPendingPaymentDeeplink(), 'lightning:$sampleLnurl'); + }, + ); + + // /setupPin runtime state from PinAuthCubit.initialize() is + // isPinSetup=false, isPinVerified=true (vacuous auto-pass before any PIN is + // configured). Classifying unlock by isPinVerified alone would deferred-push + // /pay and bypass the mandatory PIN-setup gate — this regression guard uses + // the real pair state. + testWidgets( + 'warm resume on /setupPin (vacuous isPinVerified before any PIN is configured): ' + 'payment deeplink stashes and does not push /pay', + (tester) async { + addTearDown(clearPendingPaymentDeeplink); + when(() => pinAuthCubit.state).thenReturn( + const PinAuthState(isPinSetup: false, isPinVerified: true), + ); + const sampleLnurl = + 'LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD9E3K7MF0V9CXJTMKXP6XCEF'; + final router = await pump(tester); + router.go('/setupPin'); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('setupPin')), findsOneWidget); + + router.go('realunit-wallet:lightning:$sampleLnurl'); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('pay')), findsNothing); + expect(find.byKey(const Key('setupPin')), findsOneWidget); + expect(currentPath(router), '/setupPin'); + expect(peekPendingPaymentDeeplink(), 'lightning:$sampleLnurl'); + }, + ); + + testWidgets( + 'warm resume on /pinGate while unlocked (step-up): payment deeplink ' + 'deferred-pushes /pay and does not stash', + (tester) async { + addTearDown(clearPendingPaymentDeeplink); + // Already unlocked — e.g. /pinGate reached from settings seed-view / + // change-PIN. setUp already stubs isPinVerified && isPinSetup. + const sampleLnurl = + 'LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD9E3K7MF0V9CXJTMKXP6XCEF'; + final router = await pump(tester); + router.go('/pinGate'); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('pinGate')), findsOneWidget); + + router.go('realunit-wallet:lightning:$sampleLnurl'); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('pay')), findsOneWidget); + expect(find.text('PAY:lightning:$sampleLnurl'), findsOneWidget); + expect(peekPendingPaymentDeeplink(), isNull); + }, + ); + testWidgets('cold start on the scheme URL boots to the normal entry', ( tester, ) async { @@ -141,6 +382,24 @@ void main() { expect(find.byKey(const Key('home')), findsOneWidget); }); + testWidgets( + 'cold start on a payment deeplink boots to /home and stashes the payload', + (tester) async { + addTearDown(clearPendingPaymentDeeplink); + const sampleLnurl = + 'LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD9E3K7MF0V9CXJTMKXP6XCEF'; + + final router = await pump( + tester, + initial: 'realunit-wallet:lightning:$sampleLnurl', + ); + + expect(currentPath(router), appLinkColdStartLocation); + expect(find.byKey(const Key('home')), findsOneWidget); + expect(peekPendingPaymentDeeplink(), 'lightning:$sampleLnurl'); + }, + ); + testWidgets( 'cold start on any scheme host/path boots without an error screen', (tester) async { diff --git a/test/setup/routing/boot_navigation_apply_test.dart b/test/setup/routing/boot_navigation_apply_test.dart index 53be5a027..64d5a9067 100644 --- a/test/setup/routing/boot_navigation_apply_test.dart +++ b/test/setup/routing/boot_navigation_apply_test.dart @@ -42,6 +42,11 @@ void main() { path: '/buyPaymentDetails', builder: (_, state) => Text('buy ${state.extra as Object}'), ), + GoRoute( + name: AppRoutes.pay, + path: '/pay', + builder: (_, state) => Text('pay ${state.extra}'), + ), ], ); @@ -201,4 +206,193 @@ void main() { expect(effectiveLocation(router.routerDelegate.currentConfiguration), '/kyc'); }, ); + + group('pendingPaymentDeeplink replay', () { + setUp(clearPendingPaymentDeeplink); + tearDown(clearPendingPaymentDeeplink); + + testWidgets( + 'dashboard landing with a stashed payload pushes /pay on top and consumes the stash', + (tester) async { + final router = buildRouter(); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + stashPendingPaymentDeeplink('lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD'); + applyBootNavAction( + const BootNavGoNamed(AppRoutes.dashboard), + router, + onLoadWallet: () {}, + onClearResume: () {}, + ); + await tester.pumpAndSettle(); + + // /pay is pushed ON TOP of /dashboard: once an opaque push settles, + // Flutter only keeps the topmost route built, so /dashboard's own + // Text is correctly absent from the tree right now (not a bug) — + // prove "on top of, not instead of" via the match list + canPop, and + // via the AFTER-pop check below where /dashboard is onstage again. + expect(find.text('pay lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD'), findsOneWidget); + expect( + router.routerDelegate.currentConfiguration.matches + .map((m) => m.matchedLocation) + .toList(), + ['/dashboard', '/pay'], + ); + expect(peekPendingPaymentDeeplink(), isNull); + expect(router.canPop(), isTrue); + router.pop(); + await tester.pumpAndSettle(); + expect(find.text('dashboard'), findsOneWidget); + }, + ); + + testWidgets( + 'dashboard landing with no stash pushes nothing extra', + (tester) async { + final router = buildRouter(); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + applyBootNavAction( + const BootNavGoNamed(AppRoutes.dashboard), + router, + onLoadWallet: () {}, + onClearResume: () {}, + ); + await tester.pumpAndSettle(); + + expect(find.text('dashboard'), findsOneWidget); + expect(router.canPop(), isFalse); + }, + ); + + testWidgets( + 'a non-dashboard gate landing never consumes or replays the stash', + (tester) async { + final router = buildRouter(); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + stashPendingPaymentDeeplink('lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD'); + applyBootNavAction( + const BootNavGoNamed(PinRoutes.verify), + router, + onLoadWallet: () {}, + onClearResume: () {}, + ); + await tester.pumpAndSettle(); + + expect(find.text('verify'), findsOneWidget); + expect(peekPendingPaymentDeeplink(), 'lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD'); + }, + ); + + testWidgets( + 'warm unlock with stash: resolveAfterRelock(null) lands on dashboard, ' + 'pushes /pay, and consumes the stash', + (tester) async { + final router = buildRouter(); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + // Full gate-ladder path (not a bare BootNavGoNamed): after a plain + // PIN unlock with no captured resume location, resolveBootNavigation + // returns BootNavGoNamed(dashboard), which replays the stash. + stashPendingPaymentDeeplink('lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD'); + applyBootNavAction( + resolveAfterRelock(null), + router, + onLoadWallet: () {}, + onClearResume: () {}, + ); + await tester.pumpAndSettle(); + + expect(find.text('pay lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD'), findsOneWidget); + expect( + router.routerDelegate.currentConfiguration.matches + .map((m) => m.matchedLocation) + .toList(), + ['/dashboard', '/pay'], + ); + expect(peekPendingPaymentDeeplink(), isNull); + expect(router.canPop(), isTrue); + router.pop(); + await tester.pumpAndSettle(); + expect(find.text('dashboard'), findsOneWidget); + }, + ); + + testWidgets( + 'BootNavRestore replays a stashed pendingPaymentDeeplink, pushing /pay ' + 'on top of the restored location', + (tester) async { + final router = buildRouter(); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + stashPendingPaymentDeeplink('lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD'); + applyBootNavAction( + resolveAfterRelock('/kyc'), + router, + onLoadWallet: () {}, + onClearResume: () {}, + ); + await tester.pumpAndSettle(); + + // /pay is pushed ON TOP of the restored stack (dashboard base + /kyc): + // prove via the match list + canPop, and via the AFTER-pop check + // where /kyc is onstage again. + expect(find.text('pay lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD'), findsOneWidget); + expect( + router.routerDelegate.currentConfiguration.matches + .map((m) => m.matchedLocation) + .toList(), + ['/dashboard', '/kyc', '/pay'], + ); + expect(peekPendingPaymentDeeplink(), isNull); + expect(router.canPop(), isTrue); + router.pop(); + await tester.pumpAndSettle(); + expect(find.text('kyc'), findsOneWidget); + }, + ); + + testWidgets( + 'BootNavStay replays a stashed pendingPaymentDeeplink, pushing /pay on ' + 'top of the current location', + (tester) async { + final router = buildRouter(); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + // BootNavStay itself never navigates — put the router on a real + // non-gate location first so /pay has something to land on top of. + router.go('/dashboard'); + await tester.pumpAndSettle(); + + stashPendingPaymentDeeplink('lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD'); + applyBootNavAction( + const BootNavStay(), + router, + onLoadWallet: () {}, + onClearResume: () {}, + ); + await tester.pumpAndSettle(); + + expect(find.text('pay lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD'), findsOneWidget); + expect( + router.routerDelegate.currentConfiguration.matches + .map((m) => m.matchedLocation) + .toList(), + ['/dashboard', '/pay'], + ); + expect(peekPendingPaymentDeeplink(), isNull); + expect(router.canPop(), isTrue); + router.pop(); + await tester.pumpAndSettle(); + expect(find.text('dashboard'), findsOneWidget); + }, + ); + }); } diff --git a/test/setup/routing/pay_route_test.dart b/test/setup/routing/pay_route_test.dart new file mode 100644 index 000000000..c0a1dda27 --- /dev/null +++ b/test/setup/routing/pay_route_test.dart @@ -0,0 +1,111 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; +import 'package:realunit_wallet/setup/routing/router_config.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; + +import '../../helper/helper.dart'; + +class _MockHomeBloc extends MockBloc implements HomeBloc {} + +void main() { + late _MockHomeBloc homeBloc; + + setUpAll(() { + // PayScanPage embeds a MobileScanner; the stub keeps the headless camera + // preview deterministic and free of MissingPluginException. + stubMobileScannerChannel(); + }); + + setUp(() { + homeBloc = _MockHomeBloc(); + when(() => homeBloc.state).thenReturn(const HomeState()); + }); + + // Mirrors the production wiring in main.dart: the routed pages read their + // blocs from above MaterialApp.router, so HomeBloc (used by the initial + // /home route) is provided here. Navigation then drives the real + // `routerConfig` to the /pay GoRoute under test. + Future pumpRouter(WidgetTester tester) async { + await tester.pumpWidget( + BlocProvider.value( + value: homeBloc, + child: MaterialApp.router( + routerConfig: routerConfig, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('the pay route builds PayScanPage', (tester) async { + await pumpRouter(tester); + + routerConfig.goNamed(AppRoutes.pay); + await tester.pumpAndSettle(); + + expect(find.byType(PayScanPage), findsOneWidget); + expect(find.byType(PayScanView), findsOneWidget); + + // Restore the router to its initial location so the global singleton + // does not leak the /pay location into any later test. + addTearDown(() => routerConfig.goNamed(AppRoutes.home)); + }); + + testWidgets( + 'the pay route passes a String extra through as initialPayload', + (tester) async { + await pumpRouter(tester); + + routerConfig.pushNamed( + AppRoutes.pay, + extra: 'lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD', + ); + // A single pump is not enough for an imperative pushNamed to land in + // the tree yet, and pumpAndSettle is unsafe here (PayScanView shows an + // indefinitely-animating CupertinoActivityIndicator once initialPayload + // is set). Two plain pumps reliably lands the pushed route. + await tester.pump(); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect( + tester.widget(find.byType(PayScanPage)).initialPayload, + 'lightning:LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD', + ); + + addTearDown(() => routerConfig.goNamed(AppRoutes.home)); + }, + ); + + testWidgets( + 'the pay route guards a non-String extra to null (never an unchecked cast)', + (tester) async { + await pumpRouter(tester); + + routerConfig.pushNamed(AppRoutes.pay, extra: 42); + // Same reasoning as the test above: two plain pumps, no pumpAndSettle. + await tester.pump(); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect( + tester.widget(find.byType(PayScanPage)).initialPayload, + isNull, + ); + + addTearDown(() => routerConfig.goNamed(AppRoutes.home)); + }, + ); +} 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); + }); +} diff --git a/test/widgets/scanner/qr_scanner_view_test.dart b/test/widgets/scanner/qr_scanner_view_test.dart new file mode 100644 index 000000000..ac9c0b5bc --- /dev/null +++ b/test/widgets/scanner/qr_scanner_view_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/widgets/scanner/qr_scanner_view.dart'; + +import '../../helper/helper.dart'; + +void main() { + setUpAll(stubMobileScannerChannel); + + group('$QrScannerView default error placeholder', () { + testWidgets( + 'renders the compact icon placeholder without overflow at high textScale in a tight box', + (tester) async { + await tester.pumpApp( + MediaQuery( + data: const MediaQueryData(size: Size(400, 800)) + .copyWith(textScaler: const TextScaler.linear(3.0)), + child: Center( + child: SizedBox( + width: 60, + height: 60, + child: QrScannerView(onDetect: (_) {}), + ), + ), + ), + ); + + // Let the stubbed permission handshake resolve into the + // permission-denied error state so the default error builder paints. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(tester.takeException(), isNull); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + final icon = tester.widget(find.byIcon(Icons.error_outline)); + expect(icon.size, 48); + }, + ); + }); +}