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