diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb
index e3f215950..8304bbb3d 100644
--- a/assets/languages/strings_de.arb
+++ b/assets/languages/strings_de.arb
@@ -33,8 +33,8 @@
"buyPaymentConfirmFailedAktionariat": "Es gibt ein technisches Problem. Bitte überprüfen Sie Ihr E-Mail-Postfach, möglicherweise fehlt noch eine Bestätigung Ihrer Blockchain-Adresse. Andernfalls versuchen Sie es später erneut. Falls der Fehler weiterhin besteht, kontaktieren Sie unseren Support.",
"buyPaymentInformation": "Zahlungsinformationen",
"buyPaymentInformationDescription": "Bitte überweisen Sie den Kaufbetrag mit diesen Angaben über Ihre Bankanwendung. Der Verwendungszweck ist wichtig!",
- "buyRealUnit": "RealUnit kaufen",
"buyRealu": "RealUnit Token kaufen",
+ "buyRealUnit": "RealUnit kaufen",
"cancel": "Abbrechen",
"changeAddress": "Adresse ändern",
"changeInReview": "Änderung in Prüfung",
@@ -53,11 +53,11 @@
"connectBitboxContent": "Bitte verbinden Sie Ihre BitBox mit Ihrem Smartphone.",
"connectBitboxContentIos": "Bitte verbinden Sie Ihre BitBox mit Ihrem Smartphone und aktivieren Sie zusätzlich Bluetooth.",
"connectBitboxFailed": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
- "connectBitboxSignInHint": "Nach der Code-Überprüfung wird die BitBox um eine zusätzliche Bestätigung zur Anmeldung gebeten.",
"connectBitboxSignatureCapturing": "Bitte bestätigen Sie die Anmeldeanfrage auf Ihrem BitBox-Gerät. Diese Signatur wird einmalig erfasst, damit künftige Käufe Ihre BitBox nicht erneut benötigen.",
"connectBitboxSignatureCapturingTitle": "Anmeldung bestätigen",
"connectBitboxSignatureFailed": "Ihre Anmeldesignatur konnte nicht erfasst werden. Sie können es erneut versuchen oder trotzdem fortfahren – Ihre BitBox wird dann möglicherweise für Ihren ersten Kauf erneut benötigt.",
"connectBitboxSignatureFailedTitle": "Anmeldung nicht abgeschlossen",
+ "connectBitboxSignInHint": "Nach der Code-Überprüfung wird die BitBox um eine zusätzliche Bestätigung zur Anmeldung gebeten.",
"connectBitboxTitle": "BitBox verbinden",
"connected": "Verbunden",
"connectedBitboxContent": "Bitte bestätigen Sie und folgen nun den letzten Anweisungen auf Ihrer BitBox.",
@@ -167,10 +167,39 @@
"or": "Oder",
"originalPdf": "Original-PDF",
"pay": "Bezahlen",
+ "payAwaitingSettlement": "Zahlung wird abgeschlossen",
+ "payConfirmButton": "Bezahlen",
+ "payFailureBitboxRequired": "Bitte verbinden Sie Ihre BitBox, um fortzufahren.",
+ "payFailureGeneric": "Bei der Zahlung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
+ "payFailureInsufficientEth": "Es konnten nicht genügend ETH für die Netzwerkgebühren bereitgestellt werden. Bitte versuchen Sie es später erneut.",
+ "payFailureInsufficientZchf": "Ihr REALU-Bestand reicht für diesen Betrag nicht aus.",
+ "payFailureQuoteExpired": "Das Zahlungsangebot ist abgelaufen. Bitte scannen Sie den Code erneut.",
+ "payFailureSignatureUnsupported": "Diese Wallet kann keine Transaktionen signieren. Wechseln Sie zu einer Software- oder BitBox-Wallet.",
+ "payFailureTitle": "Zahlung fehlgeschlagen",
+ "payFailureUnsupportedEnvironment": "Open CryptoPay ist nur im Mainnet verfügbar.",
"paymentInformationFailed": "Beim Abrufen der Zahlungsinformationen ist ein Fehler aufgetreten.",
"paymentInformationFailedDescription": "Bitte versuchen Sie es später erneut. Wenn der Fehler weiterhin besteht, wenden Sie sich an unseren Support.",
"payoutAccountAdd": "Auszahlungskonto hinzufügen",
"payoutAccountSelect": "Auszahlungskonto auswählen",
+ "payPaying": "Zahlung wird gesendet",
+ "payPreparingSwap": "Tausch wird vorbereitet",
+ "payQuoteRequested": "Geforderter Betrag",
+ "payQuoteSummary": "Sie bezahlen ${amount} ${asset}",
+ "payQuoteTitle": "Zahlung bestätigen",
+ "payQuoteUnavailable": "Für diesen Zahlungscode ist keine ZCHF-Zahlung verfügbar.",
+ "payQuoteZchfNeeded": "Benötigte ZCHF",
+ "payRefreshingQuote": "Angebot wird aktualisiert",
+ "payRetryButton": "Zahlung erneut versuchen",
+ "payRetryInsufficientZchf": "Der Preis hat sich geändert und die getauschten ZCHF decken diese Zahlung nicht mehr. Ihre ZCHF bleiben in Ihrer Wallet – versuchen Sie es erneut, um ein neues Angebot zu erhalten.",
+ "payRetryQuoteExpired": "Das Zahlungsangebot ist vor dem Abschluss abgelaufen. Ihre getauschten ZCHF bleiben in Ihrer Wallet – versuchen Sie es erneut, um ein neues Angebot zu erhalten und zu bezahlen.",
+ "payRetryTitle": "Schließen Sie Ihre Zahlung ab",
+ "payRetryTransient": "Die Zahlung konnte nicht abgeschlossen werden, aber Ihre getauschten ZCHF bleiben in Ihrer Wallet. Versuchen Sie es erneut, um ohne erneuten Tausch zu bezahlen.",
+ "payScanInvalid": "Dies ist kein gültiger RealUnit-Zahlungscode.",
+ "payScanTitle": "Zahlungscode scannen",
+ "paySuccess": "Zahlung erfolgreich",
+ "paySuccessDescription": "Ihre Zahlung wurde abgeschlossen.",
+ "paySwapping": "REALU wird in ZCHF getauscht",
+ "payWaitingForEth": "Netzwerkgebühren werden angefordert",
"pdf": "PDF",
"pendingTransactions": "Ausstehende Transaktionen",
"personalData": "Persönliche Daten",
@@ -197,8 +226,8 @@
"proofDocument": "Nachweis-Dokument",
"purposeOfPayment": "Verwendungszweck",
"qrCode": "QR-Code",
- "realunitStockToken": "RealUnit Aktientoken",
"realunitStockprice": "RealUnit Aktienkurs",
+ "realunitStockToken": "RealUnit Aktientoken",
"realunitWallet": "RealUnit Wallet",
"realunitWalletLogout": "Aus REALU Wallet abmelden",
"realunitWalletLogoutCheck": "Ich habe meine Wiederherstellungsphrase gesichert.",
@@ -246,18 +275,18 @@
"sellBitboxCheckingEth": "Wallet-Guthaben wird geprüft",
"sellBitboxDepositDescription": "Bestätigen Sie auf der BitBox, um ZCHF an die DFX-Einzahlungsadresse zu überweisen.",
"sellBitboxDepositFrom": "Sie senden",
+ "sellBitboxDepositing": "ZCHF wird gesendet. Bestätigen Sie auf der Bitbox",
"sellBitboxDepositRetryDescription": "Der Tausch wurde abgeschlossen, aber die ZCHF-Einzahlung konnte nicht gesendet werden. Ihre Mittel sind sicher. Tippen Sie auf Wiederholen.",
"sellBitboxDepositRetryTitle": "Einzahlung fehlgeschlagen",
"sellBitboxDepositTitle": "ZCHF an DFX senden",
"sellBitboxDepositTo": "DFX-Einzahlung",
- "sellBitboxDepositing": "ZCHF wird gesendet. Bestätigen Sie auf der Bitbox",
"sellBitboxEthReady": "Wallet bereit",
"sellBitboxEthReadyDescription": "Ihr Wallet hat genug ETH, um mit dem Verkauf fortzufahren.",
"sellBitboxSwapDescription": "Bestätigen Sie auf Ihrem BitBox, um REALU über den BrokerBot in ZCHF zu tauschen.",
"sellBitboxSwapFrom": "Sie senden",
+ "sellBitboxSwapping": "Tausch on-chain. Bestätigen Sie auf der Bitbox.",
"sellBitboxSwapTitle": "REALU → ZCHF tauschen",
"sellBitboxSwapTo": "Sie erhalten",
- "sellBitboxSwapping": "Tausch on-chain. Bestätigen Sie auf der Bitbox.",
"sellBitboxWaitingForEth": "Gasgebühren werden angefordert",
"sellBitboxWaitingForEthDescription": "Ein kleiner ETH-Betrag wird an Ihr Wallet gesendet, um die Transaktionsgebühren zu decken. Dies kann einige Minuten dauern.",
"sellMinAmount": "Mindestbetrag: ${amount} ${currency}",
@@ -265,7 +294,35 @@
"sellReviewAndConfirm": "Verkauf prüfen & bestätigen",
"sellSuccess": "Verkauf erfolgreich",
"sellSuccessDescription": "Der Betrag wird Ihnen auf das angegebene Bankkonto ausgezahlt.",
+ "send": "Senden",
+ "sendAmountAvailable": "Verfügbar: ${shares} REALU",
+ "sendAmountInsufficient": "Nicht genügend REALU in Ihrer Wallet.",
+ "sendAmountInvalid": "Geben Sie eine ganze Anzahl REALU-Anteile ein.",
+ "sendAmountLabel": "Betrag in REALU-Anteilen",
+ "sendAmountTitle": "Zu sendender Betrag",
+ "sendConfirmAmount": "Betrag",
+ "sendConfirmButton": "REALU senden",
+ "sendConfirmRecipient": "Empfänger",
+ "sendConfirmSummary": "Sie senden ${shares} REALU",
+ "sendConfirmTitle": "Überweisung prüfen",
+ "sendFailureGasUnavailable": "Überweisungen sind vorübergehend nicht verfügbar. Ihre REALU bleiben unangetastet – bitte versuchen Sie es später erneut.",
+ "sendFailureGeneric": "Bei der Überweisung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
+ "sendFailureInvalidRequest": "Die Überweisung konnte nicht vorbereitet werden. Bitte prüfen Sie Empfänger und Betrag und versuchen Sie es erneut.",
+ "sendFailureSignatureCancelled": "Die Signatur wurde abgebrochen. Ihre REALU bleiben unangetastet.",
+ "sendFailureSignatureUnsupported": "Diese Wallet kann keine gaslosen Überweisungen signieren. Wechseln Sie zu einer Software-Wallet, um REALU zu senden.",
+ "sendFailureTitle": "Überweisung fehlgeschlagen",
"sending": "Wird gesendet",
+ "sendPaste": "Einfügen",
+ "sendPreparing": "Überweisung wird vorbereitet",
+ "sendProcessTitle": "REALU wird gesendet",
+ "sendRecipientInvalid": "Dies ist keine gültige Wallet-Adresse.",
+ "sendRecipientLabel": "Empfängeradresse",
+ "sendRecipientManualHint": "Scannen Sie einen Wallet-QR-Code oder fügen Sie die Empfängeradresse unten ein.",
+ "sendRecipientTitle": "Empfänger scannen",
+ "sendShares": "${shares} REALU",
+ "sendSigning": "Bestätigen Sie die Überweisung in Ihrer Wallet",
+ "sendSuccess": "Überweisung gesendet",
+ "sendSuccessDescription": "Ihre REALU sind auf dem Weg zum Empfänger.",
"setNationalityFailed": "Ihre Staatsangehörigkeit konnte nicht gesetzt werden:\n${message}",
"settings": "Einstellungen",
"settingsAppVersion": "Version ${tag}",
@@ -282,10 +339,10 @@
"settingsWalletBackupSubtitle1": "Bitte notieren Sie Ihre 12 Wiederherstellungs-Wörter in der exakten Reihenfolge auf einem Blatt Papier und bewahren Sie sie absolut sicher auf.",
"settingsWalletBackupSubtitle2": "Dies ist die einzige Möglichkeit, Ihre Wallet wiederherzustellen.",
"showSeed": "Seed anzeigen",
- "signMessage": "Signierte Nachricht",
- "signMessageGet": "Signierte Nachricht abrufen",
"signature": "Signatur",
"signingCancelled": "Signatur abgebrochen — bitte BitBox erneut bestätigen",
+ "signMessage": "Signierte Nachricht",
+ "signMessageGet": "Signierte Nachricht abrufen",
"skip": "Überspringen",
"softwareTermsText": "Mit der Nutzung dieser App akzeptieren Sie die Nutzungsbedingungen dieser Software.",
"softwareTermsTextHighlighted": "Nutzungsbedingungen",
@@ -329,9 +386,9 @@
"transactionBuy": "Kauf",
"transactionHistory": "Transaktionshistorie",
"transactionPending": "In Bearbeitung",
+ "transactions": "Transaktionen",
"transactionSell": "Verkauf",
"transactionWaitingForPayment": "Warte auf Zahlung",
- "transactions": "Transaktionen",
"twoFa": "2-Faktor Authentifizierung",
"twoFaCodeRequired": "Code ist erforderlich",
"twoFaCodeTooShort": "Der Code sollte 6 Ziffern lang sein",
@@ -356,4 +413,4 @@
"youPay": "Sie bezahlen",
"youReceive": "Sie erhalten",
"youSell": "Sie verkaufen"
-}
+}
\ No newline at end of file
diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb
index 56e4db7f0..21c09c401 100644
--- a/assets/languages/strings_en.arb
+++ b/assets/languages/strings_en.arb
@@ -33,8 +33,8 @@
"buyPaymentConfirmFailedAktionariat": "There is a technical problem. Please check your email inbox — you may still need to confirm your blockchain address. Otherwise, please try again later. If the error persists, contact our support team.",
"buyPaymentInformation": "Payment information",
"buyPaymentInformationDescription": "Please transfer the purchase amount using your banking app with these details. The purpose of payment is important!",
- "buyRealUnit": "Buy RealUnit",
"buyRealu": "Buy RealUnit Token",
+ "buyRealUnit": "Buy RealUnit",
"cancel": "Cancel",
"changeAddress": "Change address",
"changeInReview": "Change in review",
@@ -53,11 +53,11 @@
"connectBitboxContent": "Please connect your BitBox with your Smartphone.",
"connectBitboxContentIos": "Please connect your BitBox with your Smartphone and activate Bluetooth.",
"connectBitboxFailed": "Something went wrong. Please try to connect again.",
- "connectBitboxSignInHint": "After verifying the code, the BitBox will ask for one additional confirmation to sign you in.",
"connectBitboxSignatureCapturing": "Please confirm the sign-in request on your BitBox device. This signature is captured once so future purchases won't need your BitBox again.",
"connectBitboxSignatureCapturingTitle": "Confirm sign-in",
"connectBitboxSignatureFailed": "We couldn't capture your sign-in signature. You can retry, or continue anyway – your BitBox may then be needed again for your first purchase.",
"connectBitboxSignatureFailedTitle": "Sign-in not completed",
+ "connectBitboxSignInHint": "After verifying the code, the BitBox will ask for one additional confirmation to sign you in.",
"connectBitboxTitle": "Connect BitBox",
"connected": "Connected",
"connectedBitboxContent": "Please confirm and follow the last steps on your BitBox.",
@@ -167,10 +167,39 @@
"or": "Or",
"originalPdf": "Original PDF",
"pay": "Pay",
+ "payAwaitingSettlement": "Completing payment",
+ "payConfirmButton": "Pay",
+ "payFailureBitboxRequired": "Please connect your BitBox to continue.",
+ "payFailureGeneric": "Something went wrong with the payment. Please try again.",
+ "payFailureInsufficientEth": "Could not provision enough ETH for network fees. Please try again later.",
+ "payFailureInsufficientZchf": "Your REALU holdings are not enough for this amount.",
+ "payFailureQuoteExpired": "The payment quote expired. Please scan the code again.",
+ "payFailureSignatureUnsupported": "This wallet cannot sign transactions. Switch to a software or BitBox wallet.",
+ "payFailureTitle": "Payment failed",
+ "payFailureUnsupportedEnvironment": "Open CryptoPay is only available on mainnet.",
"paymentInformationFailed": "An error occurred while getting the payment information.",
"paymentInformationFailedDescription": "Please try again later. If the error persists, contact our support team.",
"payoutAccountAdd": "Add payout account",
"payoutAccountSelect": "Select payout account",
+ "payPaying": "Sending payment",
+ "payPreparingSwap": "Preparing swap",
+ "payQuoteRequested": "Requested amount",
+ "payQuoteSummary": "You pay ${amount} ${asset}",
+ "payQuoteTitle": "Confirm payment",
+ "payQuoteUnavailable": "No ZCHF payment is available for this payment code.",
+ "payQuoteZchfNeeded": "ZCHF needed",
+ "payRefreshingQuote": "Refreshing quote",
+ "payRetryButton": "Retry payment",
+ "payRetryInsufficientZchf": "The price moved and the swapped ZCHF no longer covers this payment. Your ZCHF stays in your wallet — retry to fetch a new quote.",
+ "payRetryQuoteExpired": "The payment quote expired before settling. Your swapped ZCHF stays in your wallet — retry to fetch a new quote and pay.",
+ "payRetryTitle": "Finish your payment",
+ "payRetryTransient": "The payment could not be completed, but your swapped ZCHF stays in your wallet. Retry to pay without swapping again.",
+ "payScanInvalid": "This is not a valid RealUnit payment code.",
+ "payScanTitle": "Scan payment code",
+ "paySuccess": "Payment successful",
+ "paySuccessDescription": "Your payment has been completed.",
+ "paySwapping": "Swapping REALU to ZCHF",
+ "payWaitingForEth": "Requesting network fees",
"pdf": "PDF",
"pendingTransactions": "Pending transactions",
"personalData": "Personal data",
@@ -197,8 +226,8 @@
"proofDocument": "Proof document",
"purposeOfPayment": "Purpose of payment",
"qrCode": "QR code",
- "realunitStockToken": "RealUnit Stock Token",
"realunitStockprice": "RealUnit Stockprice",
+ "realunitStockToken": "RealUnit Stock Token",
"realunitWallet": "RealUnit Wallet",
"realunitWalletLogout": "Log out of REALU Wallet",
"realunitWalletLogoutCheck": "I have backed up my recovery phrase.",
@@ -246,18 +275,18 @@
"sellBitboxCheckingEth": "Checking your wallet balance",
"sellBitboxDepositDescription": "Confirm on your BitBox to transfer ZCHF to the DFX deposit address.",
"sellBitboxDepositFrom": "You send",
+ "sellBitboxDepositing": "Sending ZCHF. Please confirm on the Bitbox.",
"sellBitboxDepositRetryDescription": "The swap was completed but the ZCHF deposit could not be sent. Your funds are safe. Tap retry to try again.",
"sellBitboxDepositRetryTitle": "Deposit failed",
"sellBitboxDepositTitle": "Send ZCHF to DFX",
"sellBitboxDepositTo": "DFX deposit",
- "sellBitboxDepositing": "Sending ZCHF. Please confirm on the Bitbox.",
"sellBitboxEthReady": "Wallet ready",
"sellBitboxEthReadyDescription": "Your wallet has enough ETH to proceed with the sale.",
"sellBitboxSwapDescription": "Confirm on your BitBox to swap REALU for ZCHF via the BrokerBot.",
"sellBitboxSwapFrom": "You send",
+ "sellBitboxSwapping": "Swapping on-chain. Please confirm on the Bitbox.",
"sellBitboxSwapTitle": "Swap REALU → ZCHF",
"sellBitboxSwapTo": "You receive",
- "sellBitboxSwapping": "Swapping on-chain. Please confirm on the Bitbox.",
"sellBitboxWaitingForEth": "Requesting gas funds",
"sellBitboxWaitingForEthDescription": "A small amount of ETH is being sent to your wallet to cover transaction fees. This may take a few minutes.",
"sellMinAmount": "Minimum amount: ${amount} ${currency}",
@@ -265,7 +294,35 @@
"sellReviewAndConfirm": "Review & confirm sale",
"sellSuccess": "Sell successful",
"sellSuccessDescription": "The amount will be paid into the bank account you have specified.",
+ "send": "Send",
+ "sendAmountAvailable": "Available: ${shares} REALU",
+ "sendAmountInsufficient": "Not enough REALU in your wallet.",
+ "sendAmountInvalid": "Enter a whole number of REALU shares.",
+ "sendAmountLabel": "Amount in REALU shares",
+ "sendAmountTitle": "Amount to send",
+ "sendConfirmAmount": "Amount",
+ "sendConfirmButton": "Send REALU",
+ "sendConfirmRecipient": "Recipient",
+ "sendConfirmSummary": "You send ${shares} REALU",
+ "sendConfirmTitle": "Review transfer",
+ "sendFailureGasUnavailable": "Transfers are temporarily unavailable. Your REALU is untouched — please try again later.",
+ "sendFailureGeneric": "Something went wrong with the transfer. Please try again.",
+ "sendFailureInvalidRequest": "The transfer could not be prepared. Please check the recipient and amount and try again.",
+ "sendFailureSignatureCancelled": "The signature was cancelled. Your REALU is untouched.",
+ "sendFailureSignatureUnsupported": "This wallet cannot sign gasless transfers. Switch to a software wallet to send REALU.",
+ "sendFailureTitle": "Transfer failed",
"sending": "Sending",
+ "sendPaste": "Paste",
+ "sendPreparing": "Preparing transfer",
+ "sendProcessTitle": "Sending REALU",
+ "sendRecipientInvalid": "This is not a valid wallet address.",
+ "sendRecipientLabel": "Recipient address",
+ "sendRecipientManualHint": "Scan a wallet QR code, or paste the recipient address below.",
+ "sendRecipientTitle": "Scan recipient",
+ "sendShares": "${shares} REALU",
+ "sendSigning": "Confirm the transfer in your wallet",
+ "sendSuccess": "Transfer sent",
+ "sendSuccessDescription": "Your REALU is on its way to the recipient.",
"setNationalityFailed": "Could not set your nationality:\n${message}",
"settings": "Settings",
"settingsAppVersion": "Version ${tag}",
@@ -282,10 +339,10 @@
"settingsWalletBackupSubtitle1": "Please write down your 12 recovery words in the exact order on a piece of paper and keep them in a completely safe place.",
"settingsWalletBackupSubtitle2": "This is the only way to recover your wallet.",
"showSeed": "Show Seed",
- "signMessage": "Sign Message",
- "signMessageGet": "Get Sign Message",
"signature": "Signature",
"signingCancelled": "Signature cancelled — please confirm on the BitBox again",
+ "signMessage": "Sign Message",
+ "signMessageGet": "Get Sign Message",
"skip": "Skip",
"softwareTermsText": "By using this app, you accept the terms of use of this software.",
"softwareTermsTextHighlighted": "terms of use",
@@ -329,9 +386,9 @@
"transactionBuy": "Buy",
"transactionHistory": "Transaction history",
"transactionPending": "Processing",
+ "transactions": "Transactions",
"transactionSell": "Sell",
"transactionWaitingForPayment": "Waiting for payment",
- "transactions": "Transactions",
"twoFa": "Two-factor authentication",
"twoFaCodeRequired": "Code is required",
"twoFaCodeTooShort": "Code should be 6 digits",
@@ -356,4 +413,4 @@
"youPay": "You pay",
"youReceive": "You receive",
"youSell": "You sell"
-}
+}
\ No newline at end of file
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 421d96889..60b32b0c3 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -44,7 +44,7 @@
LSRequiresIPhoneOS
NSCameraUsageDescription
- This app needs camera access to verify your identity
+ This app needs camera access to verify your identity and to scan payment codes
NSLocationWhenInUseUsageDescription
Please provide us with your geolocation data to prove your current location
NSMicrophoneUsageDescription
diff --git a/lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart b/lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart
new file mode 100644
index 000000000..4872f4926
--- /dev/null
+++ b/lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart
@@ -0,0 +1,39 @@
+// Typed failures for the OCP pay flow (scan → swap → pay). Each one renders a
+// human-readable string (see `exception_surface_test.dart`) so it can surface
+// cleanly in logs, Sentry, and user-facing error states instead of the Dart
+// default `Instance of '...'`.
+
+/// The scanned QR / pasted code is not a DFX Open CryptoPay payment link.
+class InvalidPaymentLinkException implements Exception {
+ final String reason;
+
+ const InvalidPaymentLinkException(this.reason);
+
+ @override
+ String toString() => 'InvalidPaymentLinkException: $reason';
+}
+
+/// The Open CryptoPay settlement is not available on the current backend
+/// environment. The payment-link engine is mainnet-only, so `pay/submit` and
+/// `pay/unsigned-transaction` fail fast on dev.api.dfx.swiss (Sepolia).
+class PayUnsupportedEnvironmentException implements Exception {
+ const PayUnsupportedEnvironmentException();
+
+ @override
+ String toString() =>
+ 'PayUnsupportedEnvironmentException: Open CryptoPay settlement is only '
+ 'available on mainnet';
+}
+
+/// The loaded wallet cannot produce EIP-1559 signatures (today: the debug
+/// wallet). The pay flow needs to sign the swap and pay transactions locally,
+/// so it cannot proceed in this wallet mode.
+class PaySignatureUnsupportedException implements Exception {
+ // Only ever thrown / constructed as a const expression, so the zero-arg
+ // body never registers a runtime line hit; toString() below is exercised.
+ const PaySignatureUnsupportedException(); // coverage:ignore-line
+
+ @override
+ String toString() =>
+ 'PaySignatureUnsupportedException: this wallet mode cannot sign transactions';
+}
diff --git a/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart b/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart
new file mode 100644
index 000000000..362409d0c
--- /dev/null
+++ b/lib/packages/service/dfx/exceptions/payment/transfer_exceptions.dart
@@ -0,0 +1,51 @@
+// Typed failures for the wallet-to-wallet (W2W) RealUnit transfer flow
+// (enter/scan recipient → amount → confirm → sign → transfer → confirm). Each
+// one renders a human-readable string (enumerated in `exception_surface_test.dart`)
+// so it surfaces cleanly in logs and user-facing error states instead of the
+// Dart default `Instance of '...'`. Typed failures drive control flow — no
+// error-string parsing.
+
+/// The recipient string the user scanned or pasted is not a syntactically valid
+/// EVM address. This is a client-side UX guard only; the API remains the final
+/// authority on the address.
+class InvalidRecipientAddressException implements Exception {
+ /// The rejected raw input, for diagnostics.
+ final String input;
+
+ const InvalidRecipientAddressException(this.input);
+
+ @override
+ String toString() => 'InvalidRecipientAddressException: $input is not a valid wallet address';
+}
+
+/// The active wallet mode cannot produce the EIP-712 delegation + EIP-7702
+/// authorization the gasless transfer requires (today: the debug wallet, and
+/// hardware wallets whose firmware exposes no raw EIP-7702 signing API). The
+/// flow is not branched on wallet type beyond this capability gate.
+class TransferSignatureUnsupportedException implements Exception {
+ /// Diagnostic detail (e.g. the underlying signer message).
+ final String detail;
+
+ const TransferSignatureUnsupportedException([
+ this.detail = 'this wallet mode cannot sign gasless transfers',
+ ]);
+
+ @override
+ String toString() => 'TransferSignatureUnsupportedException: $detail';
+}
+
+/// DFX cannot currently fund gas for the gasless transfer (the backend's
+/// dedicated W2W gas wallet is unconfigured or below its low-balance
+/// threshold). Surfaced from the API's `ServiceUnavailable` (503) as a friendly
+/// "temporarily unavailable" state — the user's REALU is untouched.
+class TransferGasFundingUnavailableException implements Exception {
+ /// Diagnostic detail (e.g. the API message), for logs.
+ final String detail;
+
+ const TransferGasFundingUnavailableException([
+ this.detail = 'gas funding for transfers is temporarily unavailable',
+ ]);
+
+ @override
+ String toString() => 'TransferGasFundingUnavailableException: $detail';
+}
diff --git a/lib/packages/service/dfx/lnurl_decoder.dart b/lib/packages/service/dfx/lnurl_decoder.dart
new file mode 100644
index 000000000..6c7781104
--- /dev/null
+++ b/lib/packages/service/dfx/lnurl_decoder.dart
@@ -0,0 +1,192 @@
+/// Decodes an Open CryptoPay POS QR into the DFX lnurlp payment-link id and the
+/// API URL the app must read the quote from.
+///
+/// Two encodings are supported, both pointing at the single allowed DFX host:
+/// 1. A LUD-01 bech32 `LNURL1...` string (carried in the `lightning` query
+/// parameter of an `https://app.dfx.swiss/pl/?lightning=LNURL1...` QR).
+/// Decoding the bech32 yields the wrapped `https://api.dfx.swiss/v1/lnurlp/pl_...`
+/// URL directly.
+/// 2. A plain `https://app.dfx.swiss/v1/lnurlp/pl_...` (or `/pl/?...`) URL,
+/// where the `app` host is rewritten to `api` as a fallback.
+///
+/// Only `app.dfx.swiss` / `api.dfx.swiss` (and their `dev.` testnet twins) are
+/// accepted — any other host is rejected so a malicious QR cannot redirect the
+/// authenticated quote read to a third party.
+library;
+
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart';
+
+class DecodedPaymentLink {
+ /// Fully-qualified `https:///v1/lnurlp/` URL the app reads the
+ /// OCP quote from.
+ final Uri lnurlpUrl;
+
+ /// The payment-link id (e.g. `pl_...` / `plp_...`).
+ final String id;
+
+ const DecodedPaymentLink({required this.lnurlpUrl, required this.id});
+}
+
+abstract final class LnurlDecoder {
+ static const _allowedHosts = {
+ 'api.dfx.swiss',
+ 'app.dfx.swiss',
+ 'dev.api.dfx.swiss',
+ 'dev.app.dfx.swiss',
+ };
+
+ // bech32 character set (BIP-173). Index in this string is the 5-bit value.
+ static const _charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
+
+ /// Decodes [raw] — the full scanned QR payload — into a [DecodedPaymentLink].
+ ///
+ /// Throws [InvalidPaymentLinkException] when the payload is neither a
+ /// DFX-hosted lnurlp URL nor a bech32 LNURL wrapping one.
+ static DecodedPaymentLink decode(String raw) {
+ final input = raw.trim();
+ if (input.isEmpty) {
+ throw const InvalidPaymentLinkException('Empty payment code');
+ }
+
+ final lightning = _extractLightningParam(input);
+ final target = lightning != null ? _decodeBech32(lightning) : input;
+
+ final uri = _parseHttpUri(target);
+ final apiUri = _toApiUri(uri);
+ final id = _extractId(apiUri);
+
+ return DecodedPaymentLink(lnurlpUrl: apiUri, id: id);
+ }
+
+ /// Pulls the `lightning=` value out of a wrapper URL/URI, or returns the raw
+ /// bech32 when the scan is a bare `lightning:LNURL1...` / `LNURL1...` string.
+ static String? _extractLightningParam(String input) {
+ final upper = input.toUpperCase();
+ if (upper.startsWith('LNURL1')) return input;
+ if (upper.startsWith('LIGHTNING:')) return input.substring('lightning:'.length);
+
+ final uri = Uri.tryParse(input);
+ final value = uri?.queryParameters['lightning'];
+ if (value != null && value.toUpperCase().startsWith('LNURL1')) return value;
+ return null;
+ }
+
+ static Uri _parseHttpUri(String value) {
+ final uri = Uri.tryParse(value);
+ if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) {
+ throw InvalidPaymentLinkException('Not a payment link: $value');
+ }
+ return uri;
+ }
+
+ /// Rewrites an allowed `app.dfx.swiss` host to its `api.dfx.swiss` twin and
+ /// forces https. Rejects any non-DFX host.
+ static Uri _toApiUri(Uri uri) {
+ if (!_allowedHosts.contains(uri.host)) {
+ throw InvalidPaymentLinkException('Unsupported payment host: ${uri.host}');
+ }
+ final apiHost = uri.host.replaceFirst('app.dfx.swiss', 'api.dfx.swiss');
+ return uri.replace(scheme: 'https', host: apiHost);
+ }
+
+ /// Extracts the `pl_...` / `plp_...` id from the lnurlp path.
+ static String _extractId(Uri uri) {
+ final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList();
+ final lnurlpIndex = segments.indexOf('lnurlp');
+ if (lnurlpIndex != -1 && lnurlpIndex + 1 < segments.length) {
+ return segments[lnurlpIndex + 1];
+ }
+ if (segments.isNotEmpty) return segments.last;
+ throw InvalidPaymentLinkException('No payment id in: $uri');
+ }
+
+ /// Decodes a LUD-01 bech32 `LNURL1...` string to its wrapped UTF-8 URL.
+ ///
+ /// LUD-01 deliberately drops the 90-char BIP-173 length limit, so only the
+ /// charset, the 1-byte-per-char separation, and the 6-char checksum are
+ /// validated here.
+ static String _decodeBech32(String bech) {
+ final lower = bech.toLowerCase();
+ final sepIndex = lower.lastIndexOf('1');
+ if (sepIndex < 1 || sepIndex + 7 > lower.length) {
+ throw InvalidPaymentLinkException('Malformed LNURL: $bech');
+ }
+
+ final hrp = lower.substring(0, sepIndex);
+ final dataPart = lower.substring(sepIndex + 1);
+
+ final data = [];
+ for (final char in dataPart.split('')) {
+ final value = _charset.indexOf(char);
+ if (value == -1) {
+ throw InvalidPaymentLinkException('Invalid LNURL character: $char');
+ }
+ data.add(value);
+ }
+
+ if (!_verifyChecksum(hrp, data)) {
+ throw const InvalidPaymentLinkException('Invalid LNURL checksum');
+ }
+
+ // Drop the 6-symbol checksum, then regroup 5-bit → 8-bit.
+ final payload = data.sublist(0, data.length - 6);
+ final bytes = _convertBitsTo8(payload);
+ return String.fromCharCodes(bytes);
+ }
+
+ /// Regroups 5-bit bech32 symbols into 8-bit bytes (no padding — the LNURL
+ /// payload is always a whole number of bytes). Rejects leftover bits that
+ /// cannot form a full byte, which signals a corrupt data section.
+ static List _convertBitsTo8(List data) {
+ const from = 5;
+ const to = 8;
+ var acc = 0;
+ var bits = 0;
+ final result = [];
+ const maxv = (1 << to) - 1;
+ for (final value in data) {
+ acc = (acc << from) | value;
+ bits += from;
+ while (bits >= to) {
+ bits -= to;
+ result.add((acc >> bits) & maxv);
+ }
+ }
+ // Defensive bech32 invariant: a checksum-valid LNURL payload always
+ // regroups into whole bytes, so this only trips on corrupt-yet-checksum-
+ // passing input, which the preceding checksum verify already rules out.
+ if (bits >= from || ((acc << (to - bits)) & maxv) != 0) {
+ throw const InvalidPaymentLinkException('Invalid LNURL padding'); // coverage:ignore-line
+ }
+ return result;
+ }
+
+ static int _polymod(List values) {
+ const generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
+ var chk = 1;
+ for (final value in values) {
+ final top = chk >> 25;
+ chk = ((chk & 0x1ffffff) << 5) ^ value;
+ for (var i = 0; i < 5; i++) {
+ if (((top >> i) & 1) == 1) chk ^= generator[i];
+ }
+ }
+ return chk;
+ }
+
+ static List _hrpExpand(String hrp) {
+ final result = [];
+ for (final c in hrp.codeUnits) {
+ result.add(c >> 5);
+ }
+ result.add(0);
+ for (final c in hrp.codeUnits) {
+ result.add(c & 31);
+ }
+ return result;
+ }
+
+ static bool _verifyChecksum(String hrp, List data) {
+ return _polymod([..._hrpExpand(hrp), ...data]) == 1;
+ }
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart
new file mode 100644
index 000000000..4f923a57e
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart
@@ -0,0 +1,102 @@
+/// Public payment-link read response of `GET /v1/lnurlp/:id` (on api.dfx.swiss,
+/// no auth). Carries the requested fiat amount and the active quote the app
+/// needs to size the swap and later settle the payment. Only the fields the pay
+/// flow consumes are mapped.
+///
+/// The backend `recipient` field is a structured `PaymentLinkRecipientDto`
+/// object, not a string, and the flow never surfaces a merchant name (the real
+/// settlement recipient comes from the pay/unsigned-transaction response). It is
+/// therefore not mapped here — eagerly casting it `as String?` threw a
+/// `TypeError` whenever the backend populated it.
+class LnurlpPaymentDto {
+ final LnurlpRequestedAmountDto requestedAmount;
+ final LnurlpQuoteDto quote;
+
+ /// Per-method/chain transfer amounts. The Ethereum entry lists the exact ZCHF
+ /// amount the app must transfer; the app does not compute it locally.
+ final List transferAmounts;
+
+ const LnurlpPaymentDto({
+ required this.requestedAmount,
+ required this.quote,
+ required this.transferAmounts,
+ });
+
+ factory LnurlpPaymentDto.fromJson(Map json) {
+ final transfers = (json['transferAmounts'] as List?) ?? const [];
+ return LnurlpPaymentDto(
+ requestedAmount: LnurlpRequestedAmountDto.fromJson(
+ json['requestedAmount'] as Map,
+ ),
+ quote: LnurlpQuoteDto.fromJson(json['quote'] as Map),
+ transferAmounts: transfers
+ .map((e) => LnurlpTransferAmountDto.fromJson(e as Map))
+ .toList(),
+ );
+ }
+}
+
+class LnurlpRequestedAmountDto {
+ final String asset;
+ final double amount;
+
+ const LnurlpRequestedAmountDto({required this.asset, required this.amount});
+
+ factory LnurlpRequestedAmountDto.fromJson(Map json) {
+ return LnurlpRequestedAmountDto(
+ asset: json['asset'] as String,
+ amount: (json['amount'] as num).toDouble(),
+ );
+ }
+}
+
+class LnurlpQuoteDto {
+ final String id;
+ final DateTime expiration;
+
+ const LnurlpQuoteDto({required this.id, required this.expiration});
+
+ factory LnurlpQuoteDto.fromJson(Map json) {
+ return LnurlpQuoteDto(
+ id: json['id'] as String,
+ expiration: DateTime.parse(json['expiration'] as String),
+ );
+ }
+}
+
+class LnurlpTransferAmountDto {
+ final String method;
+ final List assets;
+
+ const LnurlpTransferAmountDto({required this.method, required this.assets});
+
+ factory LnurlpTransferAmountDto.fromJson(Map json) {
+ final assets = (json['assets'] as List?) ?? const [];
+ return LnurlpTransferAmountDto(
+ method: json['method'] as String,
+ assets: assets
+ .map((e) => LnurlpTransferAssetDto.fromJson(e as Map))
+ .toList(),
+ );
+ }
+}
+
+class LnurlpTransferAssetDto {
+ final String asset;
+
+ /// Optional on the backend (`amount?`): the non-priced display path emits
+ /// amount-less asset entries. Parsed as nullable so reading the whole quote
+ /// never throws — the pay flow only requires the amount for the asset it
+ /// actually transfers (ZCHF on Ethereum), filtered before it is read.
+ final double? amount;
+
+ const LnurlpTransferAssetDto({required this.asset, this.amount});
+
+ factory LnurlpTransferAssetDto.fromJson(Map json) {
+ final amount = json['amount'] as num?;
+ return LnurlpTransferAssetDto(
+ asset: json['asset'] as String,
+ amount: amount?.toDouble(),
+ );
+ }
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart
new file mode 100644
index 000000000..7393a5628
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart
@@ -0,0 +1,14 @@
+/// Request body for `PUT /v1/realunit/pay/unsigned-transaction`. References the
+/// scanned payment link and its active quote so the backend resolves recipient
+/// and exact ZCHF amount.
+class RealUnitOcpPayDto {
+ final String paymentLinkId;
+ final String quoteId;
+
+ const RealUnitOcpPayDto({required this.paymentLinkId, required this.quoteId});
+
+ Map toJson() => {
+ 'paymentLinkId': paymentLinkId,
+ 'quoteId': quoteId,
+ };
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart
new file mode 100644
index 000000000..1a90a2b46
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart
@@ -0,0 +1,11 @@
+/// Response of `PUT /v1/realunit/pay/submit` — the blockchain transaction id of
+/// the submitted ZCHF payment.
+class RealUnitOcpPayResultDto {
+ final String txId;
+
+ const RealUnitOcpPayResultDto({required this.txId});
+
+ factory RealUnitOcpPayResultDto.fromJson(Map json) {
+ return RealUnitOcpPayResultDto(txId: json['txId'] as String);
+ }
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart
new file mode 100644
index 000000000..b03f4e0ab
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart
@@ -0,0 +1,41 @@
+/// Mirrors the backend `PaymentLinkPaymentStatus` enum 1:1 (type-safe DTO
+/// mirroring, not local business logic). The backend remains the authority on
+/// the payment status; the app renders it and uses [isTerminal] / [isCompleted]
+/// only to decide when to stop polling and which UI state to show.
+enum OcpPaymentStatus {
+ pending('Pending'),
+ completed('Completed'),
+ cancelled('Cancelled'),
+ expired('Expired'),
+ unknown('')
+ ;
+
+ final String value;
+
+ const OcpPaymentStatus(this.value);
+
+ static OcpPaymentStatus fromValue(String value) {
+ return OcpPaymentStatus.values.firstWhere(
+ (s) => s.value == value,
+ orElse: () => OcpPaymentStatus.unknown,
+ );
+ }
+
+ /// Polling stops once the payment reaches a final state.
+ bool get isTerminal => this == completed || this == cancelled || this == expired;
+
+ bool get isCompleted => this == completed;
+}
+
+/// Response of `GET /v1/realunit/pay/:id/status`.
+class RealUnitOcpPayStatusDto {
+ final OcpPaymentStatus status;
+
+ const RealUnitOcpPayStatusDto({required this.status});
+
+ factory RealUnitOcpPayStatusDto.fromJson(Map json) {
+ return RealUnitOcpPayStatusDto(
+ status: OcpPaymentStatus.fromValue(json['status'] as String),
+ );
+ }
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart
new file mode 100644
index 000000000..acd5740bb
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart
@@ -0,0 +1,30 @@
+/// Request body for `PUT /v1/realunit/pay/submit`. The signed-tx envelope
+/// (`unsignedTx` + `r`/`s`/`v`) mirrors the sell/swap broadcast shape, plus the
+/// payment-link/quote references so the backend forwards the hex into the
+/// lnurlp settlement path.
+class RealUnitOcpPaySubmitDto {
+ final String unsignedTx;
+ final String r;
+ final String s;
+ final int v;
+ final String paymentLinkId;
+ final String quoteId;
+
+ const RealUnitOcpPaySubmitDto({
+ required this.unsignedTx,
+ required this.r,
+ required this.s,
+ required this.v,
+ required this.paymentLinkId,
+ required this.quoteId,
+ });
+
+ Map toJson() => {
+ 'unsignedTx': unsignedTx,
+ 'r': r,
+ 's': s,
+ 'v': v,
+ 'paymentLinkId': paymentLinkId,
+ 'quoteId': quoteId,
+ };
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart
new file mode 100644
index 000000000..e887938fb
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart
@@ -0,0 +1,28 @@
+/// Response of `PUT /v1/realunit/pay/unsigned-transaction` — the serialized
+/// unsigned EIP-1559 ZCHF transfer transaction to the OCP recipient, plus the
+/// metadata the app shows / can sanity-check before signing.
+class RealUnitOcpPayUnsignedTransactionDto {
+ final String unsignedTx;
+ final String tokenAddress;
+ final String recipient;
+ final String amountWei;
+ final int chainId;
+
+ const RealUnitOcpPayUnsignedTransactionDto({
+ required this.unsignedTx,
+ required this.tokenAddress,
+ required this.recipient,
+ required this.amountWei,
+ required this.chainId,
+ });
+
+ factory RealUnitOcpPayUnsignedTransactionDto.fromJson(Map json) {
+ return RealUnitOcpPayUnsignedTransactionDto(
+ unsignedTx: json['unsignedTx'] as String,
+ tokenAddress: json['tokenAddress'] as String,
+ recipient: json['recipient'] as String,
+ amountWei: json['amountWei'] as String,
+ chainId: json['chainId'] as int,
+ );
+ }
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart
new file mode 100644
index 000000000..e16c95782
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart
@@ -0,0 +1,21 @@
+/// Request body for `PUT /v1/realunit/swap`. The backend enforces the `amount`
+/// XOR `targetAmount` rule; the app sends exactly one. `amount` is in REALU
+/// shares, `targetAmount` is in ZCHF. IBAN-free by design (proceeds stay in the
+/// user wallet).
+class RealUnitSwapDto {
+ /// Amount of REALU shares to swap.
+ final int? amount;
+
+ /// Target amount in ZCHF (alternative to [amount]).
+ final double? targetAmount;
+
+ // The OCP pay flow always sizes the swap by ZCHF target (fromTargetAmount);
+ // `amount` stays in the body only to document the backend's XOR contract and
+ // is always null on the wire here.
+ const RealUnitSwapDto.fromTargetAmount(double this.targetAmount) : amount = null;
+
+ Map toJson() => {
+ if (amount != null) 'amount': amount,
+ if (targetAmount != null) 'targetAmount': targetAmount,
+ };
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart
new file mode 100644
index 000000000..54dee50d9
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart
@@ -0,0 +1,58 @@
+/// Response of `PUT /v1/realunit/swap` — the REALU → ZCHF swap quote. The
+/// backend is the authority on validity, limits, fees and the ZCHF estimate;
+/// the app renders these fields and never recomputes them.
+class RealUnitSwapPaymentInfoDto {
+ final int id;
+ final String uid;
+ final int routeId;
+ final DateTime timestamp;
+ final double amount;
+ final double estimatedAmount;
+ final String targetAsset;
+ final double minVolume;
+ final double maxVolume;
+ final double minVolumeTarget;
+ final double maxVolumeTarget;
+ final double ethBalance;
+ final double requiredGasEth;
+ final bool isValid;
+ final String? error;
+
+ const RealUnitSwapPaymentInfoDto({
+ required this.id,
+ required this.uid,
+ required this.routeId,
+ required this.timestamp,
+ required this.amount,
+ required this.estimatedAmount,
+ required this.targetAsset,
+ required this.minVolume,
+ required this.maxVolume,
+ required this.minVolumeTarget,
+ required this.maxVolumeTarget,
+ required this.ethBalance,
+ required this.requiredGasEth,
+ required this.isValid,
+ this.error,
+ });
+
+ factory RealUnitSwapPaymentInfoDto.fromJson(Map json) {
+ return RealUnitSwapPaymentInfoDto(
+ id: json['id'] as int,
+ uid: json['uid'] as String,
+ routeId: json['routeId'] as int,
+ timestamp: DateTime.parse(json['timestamp'] as String),
+ amount: (json['amount'] as num).toDouble(),
+ estimatedAmount: (json['estimatedAmount'] as num).toDouble(),
+ targetAsset: json['targetAsset'] as String,
+ minVolume: (json['minVolume'] as num).toDouble(),
+ maxVolume: (json['maxVolume'] as num).toDouble(),
+ minVolumeTarget: (json['minVolumeTarget'] as num).toDouble(),
+ maxVolumeTarget: (json['maxVolumeTarget'] as num).toDouble(),
+ ethBalance: (json['ethBalance'] as num).toDouble(),
+ requiredGasEth: (json['requiredGasEth'] as num).toDouble(),
+ isValid: json['isValid'] as bool,
+ error: json['error'] as String?,
+ );
+ }
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart
new file mode 100644
index 000000000..5e0fe5b69
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart
@@ -0,0 +1,12 @@
+/// Response of `PUT /v1/realunit/swap/:id/unsigned-transaction` — the
+/// serialized unsigned EIP-1559 REALU `transferAndCall` swap transaction hex
+/// (no deposit sweep; ZCHF lands in the user wallet).
+class RealUnitSwapUnsignedTransactionDto {
+ final String swap;
+
+ const RealUnitSwapUnsignedTransactionDto({required this.swap});
+
+ factory RealUnitSwapUnsignedTransactionDto.fromJson(Map json) {
+ return RealUnitSwapUnsignedTransactionDto(swap: json['swap'] as String);
+ }
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/swap_payment_info.dart b/lib/packages/service/dfx/models/payment/pay/swap_payment_info.dart
new file mode 100644
index 000000000..b1704adc5
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/swap_payment_info.dart
@@ -0,0 +1,50 @@
+import 'package:equatable/equatable.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart';
+
+/// Domain model for an IBAN-free REALU → ZCHF swap quote. The backend decides
+/// validity, limits and the ZCHF estimate; this model only carries those fields
+/// for the flow's cubits to render and to drive the ETH-balance / swap steps.
+class SwapPaymentInfo extends Equatable {
+ final int id;
+ final double amount;
+ final double estimatedAmount;
+ final String targetAsset;
+ final double ethBalance;
+ final double requiredGasEth;
+ final bool isValid;
+ final String? error;
+
+ const SwapPaymentInfo({
+ required this.id,
+ required this.amount,
+ required this.estimatedAmount,
+ required this.targetAsset,
+ required this.ethBalance,
+ required this.requiredGasEth,
+ required this.isValid,
+ this.error,
+ });
+
+ factory SwapPaymentInfo.fromDto(RealUnitSwapPaymentInfoDto dto) => SwapPaymentInfo(
+ id: dto.id,
+ amount: dto.amount,
+ estimatedAmount: dto.estimatedAmount,
+ targetAsset: dto.targetAsset,
+ ethBalance: dto.ethBalance,
+ requiredGasEth: dto.requiredGasEth,
+ isValid: dto.isValid,
+ error: dto.error,
+ );
+
+ @override
+ List get props => [
+ id,
+ amount,
+ estimatedAmount,
+ targetAsset,
+ ethBalance,
+ requiredGasEth,
+ isValid,
+ error,
+ ];
+}
diff --git a/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart
new file mode 100644
index 000000000..29cb4af4a
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart
@@ -0,0 +1,19 @@
+/// Request body for `PUT /v1/realunit/transfer` — the wallet-to-wallet
+/// transfer intent. REALU has `decimals = 0`, so [amount] is a whole number of
+/// shares.
+class RealUnitTransferDto {
+ final String toAddress;
+ final int amount;
+
+ const RealUnitTransferDto({
+ required this.toAddress,
+ required this.amount,
+ });
+
+ Map toJson() {
+ return {
+ 'toAddress': toAddress,
+ 'amount': amount,
+ };
+ }
+}
diff --git a/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart
new file mode 100644
index 000000000..8c4f659ca
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart
@@ -0,0 +1,67 @@
+import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart';
+
+/// EIP-7702 delegation data for a gasless wallet-to-wallet REALU transfer.
+///
+/// Mirrors the sell flow's [Eip7702Data] (`domain`/`types`/`message` are the
+/// exact same shape the EIP-712 delegation + EIP-7702 authorization signers
+/// consume), but the transfer endpoint returns the on-chain destination as
+/// `recipient` rather than the sell flow's `depositAddress`.
+class RealUnitTransferEip7702Data {
+ final String relayerAddress;
+ final String delegationManagerAddress;
+ final String delegatorAddress;
+ final int userNonce;
+ final Eip7702Domain domain;
+ final Eip7702Types types;
+ final Eip7702Message message;
+ final String tokenAddress;
+ final String amountWei;
+ final String recipient;
+
+ const RealUnitTransferEip7702Data({
+ required this.relayerAddress,
+ required this.delegationManagerAddress,
+ required this.delegatorAddress,
+ required this.userNonce,
+ required this.domain,
+ required this.types,
+ required this.message,
+ required this.tokenAddress,
+ required this.amountWei,
+ required this.recipient,
+ });
+
+ factory RealUnitTransferEip7702Data.fromJson(Map json) {
+ return RealUnitTransferEip7702Data(
+ relayerAddress: json['relayerAddress'] as String,
+ delegationManagerAddress: json['delegationManagerAddress'] as String,
+ delegatorAddress: json['delegatorAddress'] as String,
+ userNonce: json['userNonce'] as int,
+ domain: Eip7702Domain.fromJson(json['domain'] as Map),
+ types: Eip7702Types.fromJson(json['types'] as Map),
+ message: Eip7702Message.fromJson(json['message'] as Map),
+ tokenAddress: json['tokenAddress'] as String,
+ amountWei: json['amountWei'] as String,
+ recipient: json['recipient'] as String,
+ );
+ }
+
+ /// Adapts to the sell flow's [Eip7702Data] so the shared
+ /// `Eip712Signer.signDelegation` / `Eip7702Signer.signAuthorization` can sign
+ /// it without a transfer-specific signer. The signers never read
+ /// `depositAddress`, so the `recipient` is mapped through it verbatim.
+ Eip7702Data toEip7702Data() {
+ return Eip7702Data(
+ relayerAddress: relayerAddress,
+ delegationManagerAddress: delegationManagerAddress,
+ delegatorAddress: delegatorAddress,
+ userNonce: userNonce,
+ domain: domain,
+ types: types,
+ message: message,
+ tokenAddress: tokenAddress,
+ amountWei: amountWei,
+ depositAddress: recipient,
+ );
+ }
+}
diff --git a/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart
new file mode 100644
index 000000000..8c7658e89
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart
@@ -0,0 +1,38 @@
+import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart';
+
+/// Response of `PUT /v1/realunit/transfer` — the persisted transfer intent plus
+/// the EIP-7702 delegation data the app must sign for the gasless REALU
+/// transfer.
+class RealUnitTransferPaymentInfoDto {
+ final int id;
+ final String uid;
+ final String toAddress;
+ final int amount;
+ final String tokenAddress;
+ final int chainId;
+ final RealUnitTransferEip7702Data eip7702;
+
+ const RealUnitTransferPaymentInfoDto({
+ required this.id,
+ required this.uid,
+ required this.toAddress,
+ required this.amount,
+ required this.tokenAddress,
+ required this.chainId,
+ required this.eip7702,
+ });
+
+ factory RealUnitTransferPaymentInfoDto.fromJson(Map json) {
+ return RealUnitTransferPaymentInfoDto(
+ id: json['id'] as int,
+ uid: json['uid'] as String,
+ toAddress: json['toAddress'] as String,
+ amount: (json['amount'] as num).toInt(),
+ tokenAddress: json['tokenAddress'] as String,
+ chainId: json['chainId'] as int,
+ eip7702: RealUnitTransferEip7702Data.fromJson(
+ json['eip7702'] as Map,
+ ),
+ );
+ }
+}
diff --git a/lib/packages/service/dfx/real_unit_pay_service.dart b/lib/packages/service/dfx/real_unit_pay_service.dart
new file mode 100644
index 000000000..6b83e874d
--- /dev/null
+++ b/lib/packages/service/dfx/real_unit_pay_service.dart
@@ -0,0 +1,172 @@
+import 'dart:convert';
+
+import 'package:realunit_wallet/packages/config/api_config.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_request_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_response_dto.dart';
+
+/// Backend client for the Open CryptoPay pay flow (DFXswiss/api #3819, all under
+/// `/v1/realunit/...`). Subclasses [DFXAuthService] for the JWT handshake +
+/// retry-on-401 the sell flow already uses; the public lnurlp read is the only
+/// unauthenticated call.
+class RealUnitPayService extends DFXAuthService {
+ static const _lnurlpPath = '/v1/lnurlp';
+ static const _swapPath = '/v1/realunit/swap';
+ static String _swapUnsignedTxPath(int id) => '/v1/realunit/swap/$id/unsigned-transaction';
+ static String _swapBroadcastPath(int id) => '/v1/realunit/swap/$id/broadcast';
+ static const _payUnsignedTxPath = '/v1/realunit/pay/unsigned-transaction';
+ static const _paySubmitPath = '/v1/realunit/pay/submit';
+ static String _payStatusPath(String id) => '/v1/realunit/pay/$id/status';
+
+ static const _httpTimeout = Duration(seconds: 20);
+
+ RealUnitPayService(super.appStore, super.walletService);
+
+ /// Public OCP payment-link read (no auth). Returns the requested fiat amount,
+ /// the active quote (id + expiration) and the per-method transfer amounts.
+ Future getPaymentDetails(String id) async {
+ final uri = buildUri(host, '$_lnurlpPath/$id');
+ final response = await appStore.httpClient
+ .get(uri, headers: {'accept': 'application/json'})
+ .timeout(_httpTimeout);
+
+ if (response.statusCode != 200) {
+ _throwApi(response.body, response.statusCode);
+ }
+ return LnurlpPaymentDto.fromJson(jsonDecode(response.body) as Map);
+ }
+
+ // --- Swap (REALU → ZCHF, proceeds stay in the user wallet) ---
+
+ Future getSwapPaymentInfo(RealUnitSwapDto dto) async {
+ final uri = buildUri(host, _swapPath);
+ final response = await authenticatedPut(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ body: jsonEncode(dto.toJson()),
+ );
+
+ if (response.statusCode != 200 && response.statusCode != 201) {
+ _throwApi(response.body, response.statusCode);
+ }
+ final responseDto = RealUnitSwapPaymentInfoDto.fromJson(
+ jsonDecode(response.body) as Map,
+ );
+ return SwapPaymentInfo.fromDto(responseDto);
+ }
+
+ Future createSwapUnsignedTransaction(int id) async {
+ final uri = buildUri(host, _swapUnsignedTxPath(id));
+ final response = await authenticatedPut(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ );
+
+ if (response.statusCode != 200 && response.statusCode != 201) {
+ _throwApi(response.body, response.statusCode);
+ }
+ return RealUnitSwapUnsignedTransactionDto.fromJson(
+ jsonDecode(response.body) as Map,
+ );
+ }
+
+ Future broadcastSwapTransaction(int id, BroadcastTransactionRequestDto dto) async {
+ final uri = buildUri(host, _swapBroadcastPath(id));
+ final response = await authenticatedPut(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ body: jsonEncode(dto.toJson()),
+ );
+
+ if (response.statusCode != 200 && response.statusCode != 201) {
+ _throwApi(response.body, response.statusCode);
+ }
+ return BroadcastTransactionResponseDto.fromJson(
+ jsonDecode(response.body) as Map,
+ ).txHash;
+ }
+
+ // --- OCP pay (settle a ZCHF payment-link quote via the lnurlp flow) ---
+
+ Future createPayUnsignedTransaction(
+ RealUnitOcpPayDto dto,
+ ) async {
+ assertPaySupported();
+ final uri = buildUri(host, _payUnsignedTxPath);
+ final response = await authenticatedPut(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ body: jsonEncode(dto.toJson()),
+ );
+
+ if (response.statusCode != 200 && response.statusCode != 201) {
+ _throwApi(response.body, response.statusCode);
+ }
+ return RealUnitOcpPayUnsignedTransactionDto.fromJson(
+ jsonDecode(response.body) as Map,
+ );
+ }
+
+ Future submitPay(RealUnitOcpPaySubmitDto dto) async {
+ assertPaySupported();
+ final uri = buildUri(host, _paySubmitPath);
+ final response = await authenticatedPut(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ body: jsonEncode(dto.toJson()),
+ );
+
+ if (response.statusCode != 200 && response.statusCode != 201) {
+ _throwApi(response.body, response.statusCode);
+ }
+ return RealUnitOcpPayResultDto.fromJson(
+ jsonDecode(response.body) as Map,
+ ).txId;
+ }
+
+ Future getPayStatus(String id) async {
+ final uri = buildUri(host, _payStatusPath(id));
+ final response = await authenticatedGet(uri);
+
+ if (response.statusCode != 200) {
+ _throwApi(response.body, response.statusCode);
+ }
+ return RealUnitOcpPayStatusDto.fromJson(
+ jsonDecode(response.body) as Map,
+ );
+ }
+
+ /// Whether the current backend environment can settle an OCP payment. The
+ /// payment-link engine is mainnet-only; on testnet the pay/* endpoints fail
+ /// fast server-side with a 400. This is environment-static (keyed off
+ /// [ApiConfig]), so the flow can read it BEFORE the irreversible REALU→ZCHF
+ /// swap and refuse to swap on an environment where the pay leg can never
+ /// settle. Not local business logic — purely a capability gate.
+ bool get isPaySupportedEnvironment => !appStore.apiConfig.networkMode.isTestnet;
+
+ /// Defense-in-depth mirror of [isPaySupportedEnvironment] on the pay/* calls:
+ /// even though the flow gates up-front, surface the typed failure before the
+ /// round-trip rather than parsing the backend error body.
+ void assertPaySupported() {
+ if (!isPaySupportedEnvironment) {
+ throw const PayUnsupportedEnvironmentException();
+ }
+ }
+
+ Never _throwApi(String body, int statusCode) {
+ final errorJson = jsonDecode(body) as Map;
+ throw ApiException.fromJson(errorJson, httpStatusCode: statusCode);
+ }
+}
diff --git a/lib/packages/service/dfx/real_unit_transfer_service.dart b/lib/packages/service/dfx/real_unit_transfer_service.dart
new file mode 100644
index 000000000..50df07268
--- /dev/null
+++ b/lib/packages/service/dfx/real_unit_transfer_service.dart
@@ -0,0 +1,194 @@
+import 'dart:convert';
+
+import 'package:eip7702/eip7702.dart' as eip7702;
+import 'package:realunit_wallet/packages/config/api_config.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_confirm_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart';
+import 'package:realunit_wallet/packages/wallet/eip712_signer.dart';
+import 'package:realunit_wallet/packages/wallet/eip7702_signer.dart';
+
+/// Consumes the gasless wallet-to-wallet RealUnit transfer endpoints
+/// (`PUT /v1/realunit/transfer`, `PUT /v1/realunit/transfer/:id/confirm` —
+/// DFXswiss/api #3820). DFX pays gas via EIP-7702 from a dedicated W2W gas
+/// wallet, so the app signs an EIP-712 delegation + an EIP-7702 authorization —
+/// the exact pattern the SOFTWARE gasless sell confirm uses
+/// ([RealUnitSellPaymentInfoService.confirmPayment]).
+class RealUnitTransferService extends DFXAuthService {
+ static const _transferPath = '/v1/realunit/transfer';
+ static String _confirmPath(int id) => '/v1/realunit/transfer/$id/confirm';
+
+ // MetaMask Delegation Framework v1.3.0, CREATE2 — identical on all EVM chains.
+ // Pinned exactly as the sell software-confirm path pins them, so a tampered
+ // delegation payload is rejected before it is signed.
+ static const _metaMaskDelegatorAddress = '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b';
+ static const _delegationManagerAddress = '0xdb9b1e94b5b69df7e401ddbede43491141047db3';
+
+ RealUnitTransferService(super.appStore, super.walletService);
+
+ /// `PUT /transfer` — persists the transfer intent and returns the EIP-7702
+ /// delegation data to sign. A 503 means DFX cannot currently fund gas; it is
+ /// surfaced as a typed [TransferGasFundingUnavailableException] so the flow can
+ /// render a friendly "temporarily unavailable" state. Every other non-2xx is
+ /// an [ApiException] (KYC30 / registration / invalid recipient are signaled by
+ /// the API and rendered from its message).
+ Future prepareTransfer(RealUnitTransferDto dto) async {
+ final uri = buildUri(host, _transferPath);
+ final response = await authenticatedPut(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ body: jsonEncode(dto.toJson()),
+ );
+
+ if (response.statusCode == 200 || response.statusCode == 201) {
+ return RealUnitTransferPaymentInfoDto.fromJson(
+ jsonDecode(response.body) as Map,
+ );
+ }
+
+ final errorJson = jsonDecode(response.body) as Map;
+ if (response.statusCode == 503) {
+ throw TransferGasFundingUnavailableException(
+ (errorJson['message'] ?? 'gas funding for transfers is temporarily unavailable').toString(),
+ );
+ }
+ throw ApiException.fromJson(errorJson, httpStatusCode: response.statusCode);
+ }
+
+ /// `PUT /transfer/:id/confirm` — signs the EIP-712 delegation + EIP-7702
+ /// authorization and relays them; DFX broadcasts the gasless transfer and
+ /// returns the tx hash.
+ ///
+ /// Reuses the wallet unlock/lock boundary and the shared
+ /// `Eip712Signer.signDelegation` / `Eip7702Signer.signAuthorization` exactly
+ /// like the sell software-confirm. A wallet that cannot produce these
+ /// signatures (debug wallet, or hardware firmware without raw EIP-7702
+ /// support) raises [TransferSignatureUnsupportedException] — the capability
+ /// gate, not a wallet-type branch.
+ Future confirmTransfer(RealUnitTransferPaymentInfoDto info) async {
+ // EIP-712 + EIP-7702 typed-data signing needs the private key; promote the
+ // view-wallet to a fully unlocked SoftwareWallet before reading credentials.
+ await walletService.ensureCurrentWalletUnlocked();
+ try {
+ final credentials = appStore.wallet.currentAccount.primaryAddress;
+ final transferData = info.eip7702;
+ _validateEip7702Data(transferData, credentials.address.hexEip55, info.amount);
+
+ final eip7702Data = transferData.toEip7702Data();
+ final String delegationSignature;
+ final eip7702.EIP7702MsgSignature authorizationSignature;
+ try {
+ delegationSignature = await Eip712Signer.signDelegation(
+ credentials: credentials,
+ eip7702Data: eip7702Data,
+ );
+ authorizationSignature = Eip7702Signer.signAuthorization(
+ credentials: credentials,
+ eip7702Data: eip7702Data,
+ );
+ } on UnsupportedError catch (e) {
+ // Debug-wallet credentials reject typed-data signing with UnsupportedError.
+ throw TransferSignatureUnsupportedException(e.message ?? e.toString());
+ }
+
+ return _sendConfirm(
+ info.id,
+ Eip7702ConfirmDto(
+ delegation: Eip7702DelegationDto(
+ delegate: transferData.relayerAddress,
+ delegator: transferData.message.delegator,
+ authority: transferData.message.authority,
+ salt: '${transferData.message.salt}',
+ signature: delegationSignature,
+ ),
+ authorization: Eip7702AuthorizationDto(
+ chainId: transferData.domain.chainId,
+ address: transferData.delegatorAddress,
+ nonce: transferData.userNonce,
+ r: '0x${authorizationSignature.r.toRadixString(16).padLeft(64, '0')}',
+ s: '0x${authorizationSignature.s.toRadixString(16).padLeft(64, '0')}',
+ yParity: authorizationSignature.yParity,
+ ),
+ ),
+ );
+ } finally {
+ // Drop the mnemonic from memory as soon as signing is done — runs on the
+ // throw path too so a validation/sign failure mid-sequence does not leave
+ // the key resident. Mirrors [RealUnitSellPaymentInfoService.confirmPayment].
+ await walletService.lockCurrentWallet();
+ }
+ }
+
+ Future _sendConfirm(int id, Eip7702ConfirmDto dto) async {
+ final uri = buildUri(host, _confirmPath(id));
+ final response = await authenticatedPut(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ body: jsonEncode(dto.toJson()),
+ );
+
+ if (response.statusCode != 200 && response.statusCode != 201) {
+ final errorJson = jsonDecode(response.body) as Map;
+ if (response.statusCode == 503) {
+ throw TransferGasFundingUnavailableException(
+ (errorJson['message'] ?? 'gas funding for transfers is temporarily unavailable')
+ .toString(),
+ );
+ }
+ throw ApiException.fromJson(errorJson, httpStatusCode: response.statusCode);
+ }
+
+ return (jsonDecode(response.body) as Map)['txHash'] as String;
+ }
+
+ /// Pins the signed contract addresses + cross-checks the signed/unsigned
+ /// fields against known values before signing, mirroring the sell flow. The
+ /// recipient is server-bound (the backend supplies the ERC20 transfer call at
+ /// execute time), so it is validated for amount/token/chain consistency here.
+ void _validateEip7702Data(
+ RealUnitTransferEip7702Data data,
+ String walletAddress,
+ int userAmount,
+ ) {
+ final expectedChainId = appStore.apiConfig.asset.chainId;
+
+ if (data.delegatorAddress.toLowerCase() != _metaMaskDelegatorAddress) {
+ throw Exception(
+ 'EIP-7702 delegator address does not match expected MetaMask Delegator contract',
+ );
+ }
+ if (data.delegationManagerAddress.toLowerCase() != _delegationManagerAddress) {
+ throw Exception('EIP-7702 delegation manager address does not match expected contract');
+ }
+ if (data.domain.verifyingContract.toLowerCase() != _delegationManagerAddress) {
+ throw Exception('EIP-7702 verifying contract does not match expected DelegationManager');
+ }
+ if (data.message.delegator.toLowerCase() != walletAddress.toLowerCase()) {
+ throw Exception('EIP-7702 message delegator does not match wallet address');
+ }
+ if (data.domain.chainId != expectedChainId) {
+ throw Exception(
+ 'EIP-7702 chain ID mismatch: expected $expectedChainId, got ${data.domain.chainId}',
+ );
+ }
+ if (data.message.delegate.toLowerCase() != data.relayerAddress.toLowerCase()) {
+ throw Exception('EIP-7702 message delegate does not match relayer address');
+ }
+ if (data.tokenAddress.toLowerCase() != appStore.apiConfig.asset.address.toLowerCase()) {
+ throw Exception('EIP-7702 token address does not match RealUnit token');
+ }
+ // REALU has decimals = 0, so the wei amount equals the share count; compute
+ // generically against the asset decimals so a non-zero-decimals asset would
+ // still be validated correctly.
+ final expectedWei =
+ BigInt.from(userAmount) * BigInt.from(10).pow(appStore.apiConfig.asset.decimals);
+ final actualWei = BigInt.tryParse(data.amountWei);
+ if (actualWei == null || actualWei != expectedWei) {
+ throw Exception('EIP-7702 amount mismatch: expected $expectedWei, got ${data.amountWei}');
+ }
+ }
+}
diff --git a/lib/screens/dashboard/widgets/sections/dashboard_actions.dart b/lib/screens/dashboard/widgets/sections/dashboard_actions.dart
index 9b68eed66..0c64a6a60 100644
--- a/lib/screens/dashboard/widgets/sections/dashboard_actions.dart
+++ b/lib/screens/dashboard/widgets/sections/dashboard_actions.dart
@@ -13,23 +13,49 @@ class DashboardActions extends StatelessWidget {
return Row(
spacing: 10,
children: [
- ActionButton(
- icon: Icon(
- Icons.add_circle_rounded,
- color: RealUnitColors.basic.white,
- size: 20,
+ Expanded(
+ child: ActionButton(
+ icon: Icon(
+ Icons.add_circle_rounded,
+ color: RealUnitColors.basic.white,
+ size: 20,
+ ),
+ label: S.of(context).buy,
+ onPressed: () => context.pushNamed(AppRoutes.buy),
),
- label: S.of(context).buy,
- onPressed: () => context.pushNamed(AppRoutes.buy),
),
- ActionButton(
- icon: Icon(
- Icons.do_not_disturb_on_rounded,
- color: RealUnitColors.basic.white,
- size: 20,
+ Expanded(
+ child: ActionButton(
+ icon: Icon(
+ Icons.do_not_disturb_on_rounded,
+ color: RealUnitColors.basic.white,
+ size: 20,
+ ),
+ label: S.of(context).sell,
+ onPressed: () => context.pushNamed(AppRoutes.sell),
+ ),
+ ),
+ Expanded(
+ child: ActionButton(
+ icon: Icon(
+ Icons.qr_code_scanner_rounded,
+ color: RealUnitColors.basic.white,
+ size: 20,
+ ),
+ label: S.of(context).pay,
+ onPressed: () => context.pushNamed(AppRoutes.pay),
+ ),
+ ),
+ Expanded(
+ child: ActionButton(
+ icon: Icon(
+ Icons.send_rounded,
+ color: RealUnitColors.basic.white,
+ size: 20,
+ ),
+ label: S.of(context).send,
+ onPressed: () => context.pushNamed(AppRoutes.send),
),
- label: S.of(context).sell,
- onPressed: () => context.pushNamed(AppRoutes.sell),
),
],
);
diff --git a/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart
new file mode 100644
index 000000000..9870e468c
--- /dev/null
+++ b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart
@@ -0,0 +1,345 @@
+import 'dart:async';
+import 'dart:typed_data';
+
+import 'package:convert/convert.dart' as convert;
+import 'package:equatable/equatable.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_request_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/packages/service/wallet_service.dart';
+import 'package:realunit_wallet/packages/wallet/wallet.dart';
+import 'package:web3dart/crypto.dart';
+
+part 'pay_process_state.dart';
+
+/// Orchestrates the on-chain half of the OCP pay flow after the user confirms a
+/// quote: check ETH gas → swap REALU→ZCHF (sign + broadcast) → re-fetch the OCP
+/// quote (fresh quoteId, guards expiry between swap and pay) → pay (sign +
+/// submit) → poll status until terminal.
+///
+/// Signing uses the unified raw-payload path (`signToSignature` → r/s/v) for
+/// BOTH software and BitBox wallets — the backend returns the unsigned txs, the
+/// app signs them the same way regardless of wallet mode. The flow is NOT
+/// branched on `walletType`; only the genuine capability gap (a debug wallet
+/// that cannot sign) is gated, surfacing [PaySignaturePending] →
+/// [PaySignatureUnsupportedException].
+class PayProcessCubit extends Cubit {
+ final RealUnitPayService _payService;
+ final DfxFaucetService _faucetService;
+ final DfxBlockchainApiService _blockchainService;
+ final WalletService _walletService;
+ final AppStore _appStore;
+
+ final String _paymentLinkId;
+ final double _zchfNeeded;
+
+ SwapPaymentInfo? _swap;
+
+ /// Set once the REALU→ZCHF swap has been broadcast successfully. From this
+ /// point the user holds ZCHF and recovery must NEVER re-swap — the pay leg is
+ /// retried on its own via [retryPay].
+ bool _swapCompleted = false;
+
+ /// ZCHF acquired by the (completed) swap — the backend `estimatedAmount` of
+ /// the swap quote. Used to detect when a freshly re-fetched settlement amount
+ /// can no longer be covered by what we actually hold.
+ double _acquiredZchf = 0;
+
+ Timer? _ethPollingTimer;
+ Timer? _statusPollingTimer;
+
+ /// Headroom over the OCP ZCHF amount when sizing the swap target. The swap is
+ /// quoted/broadcast against the ORIGINAL OCP quote, but the pay step settles
+ /// the EXACT amount of a FRESHLY re-fetched quote; in between, the OCP price
+ /// (CHF→ZCHF) and the swap rate can both move. A 1% buffer left no margin for
+ /// the common case (a few minutes of drift + the OCP/swap fees), so any
+ /// adverse move stranded the user in ZCHF that could not cover settlement.
+ /// 3% is a pragmatic headroom that absorbs ordinary drift while keeping the
+ /// over-swap small (leftover ZCHF simply stays in the wallet); a larger move
+ /// is caught explicitly and surfaced as a retryable
+ /// [PayRetryReason.insufficientZchf] rather than a server-side failure.
+ static const _slippageBuffer = 1.03;
+
+ static const _ethPollInterval = Duration(seconds: 5);
+ static const _statusPollInterval = Duration(seconds: 3);
+
+ PayProcessCubit({
+ required RealUnitPayService payService,
+ required DfxFaucetService faucetService,
+ required DfxBlockchainApiService blockchainService,
+ required WalletService walletService,
+ required AppStore appStore,
+ required String paymentLinkId,
+ required double zchfNeeded,
+ }) : _payService = payService,
+ _faucetService = faucetService,
+ _blockchainService = blockchainService,
+ _walletService = walletService,
+ _appStore = appStore,
+ _paymentLinkId = paymentLinkId,
+ _zchfNeeded = zchfNeeded,
+ super(const PayProcessInitial());
+
+ /// Entry point — called by the view once the user confirms the quote.
+ Future start() async {
+ // Environment capability gate — checked BEFORE any on-chain action. The
+ // REALU→ZCHF swap is irreversible; if OCP settlement can never succeed on
+ // this environment (mainnet-only), refuse here so the user is never swapped
+ // into ZCHF and then told "mainnet only". This is environment-static, so it
+ // is safe (and required) to evaluate before the swap is signed/broadcast.
+ if (!_payService.isPaySupportedEnvironment) {
+ emit(const PayProcessFailure(PayProcessFailureReason.payUnsupportedEnvironment));
+ return;
+ }
+ if (_appStore.wallet.walletType == WalletType.debug) {
+ emit(const PayProcessFailure(PayProcessFailureReason.signatureUnsupported));
+ return;
+ }
+ await _requestSwapQuote();
+ }
+
+ Future _requestSwapQuote() async {
+ try {
+ emit(const PayProcessPreparingSwap());
+ final swap = await _payService.getSwapPaymentInfo(
+ RealUnitSwapDto.fromTargetAmount(_zchfNeeded * _slippageBuffer),
+ );
+ _swap = swap;
+
+ // The API is the authority on whether the swap is fundable; render its
+ // signal rather than recomputing limits locally.
+ if (!swap.isValid) {
+ emit(const PayProcessFailure(PayProcessFailureReason.insufficientZchf));
+ return;
+ }
+
+ await _checkEthBalance(swap);
+ } catch (e) {
+ emit(PayProcessFailure(PayProcessFailureReason.generic, message: e.toString()));
+ }
+ }
+
+ Future _checkEthBalance(SwapPaymentInfo swap) async {
+ if (swap.ethBalance >= swap.requiredGasEth) {
+ await _executeSwap();
+ return;
+ }
+ await _requestFaucet(swap);
+ }
+
+ Future _requestFaucet(SwapPaymentInfo swap) async {
+ try {
+ emit(const PayProcessWaitingForEth());
+ await _faucetService.requestFaucet();
+ _startEthPolling(swap);
+ } catch (e) {
+ emit(PayProcessFailure(PayProcessFailureReason.insufficientEth, message: e.toString()));
+ }
+ }
+
+ void _startEthPolling(SwapPaymentInfo swap) {
+ _ethPollingTimer?.cancel();
+ _ethPollingTimer = Timer.periodic(_ethPollInterval, (_) async {
+ try {
+ final balance = await _blockchainService.getEthBalance(_appStore.primaryAddress);
+ if (balance >= swap.requiredGasEth) {
+ _ethPollingTimer?.cancel();
+ await _executeSwap();
+ }
+ } catch (_) {
+ // keep polling on transient errors
+ }
+ });
+ }
+
+ Future _executeSwap() async {
+ final swap = _swap;
+ if (swap == null) return;
+ try {
+ emit(const PayProcessSwapping());
+ final unsigned = await _payService.createSwapUnsignedTransaction(swap.id);
+ final signed = await _signTransaction(unsigned.swap);
+ await _payService.broadcastSwapTransaction(swap.id, signed);
+ // The swap is now irreversible — the user holds ZCHF. From here every
+ // recovery path retries the PAY leg only; the swap is never redone.
+ _swapCompleted = true;
+ _acquiredZchf = swap.estimatedAmount;
+ await _refreshQuoteAndPay();
+ } on PaySignatureUnsupportedException {
+ emit(const PayProcessFailure(PayProcessFailureReason.signatureUnsupported));
+ } on BitboxNotConnectedException {
+ emit(const PayProcessFailure(PayProcessFailureReason.bitboxRequired));
+ } catch (e) {
+ emit(PayProcessFailure(PayProcessFailureReason.generic, message: e.toString()));
+ }
+ }
+
+ /// Retries the pay leg ONLY, after a successful swap. Re-fetches the OCP quote
+ /// and re-runs sign + submit; it never re-swaps (guarded by [_swapCompleted]),
+ /// so the ZCHF already in the wallet is reused and REALU is never
+ /// double-converted. Wired to the retry action on [PayProcessPayRetry].
+ Future retryPay() async {
+ if (!_swapCompleted) return;
+ await _refreshQuoteAndPay();
+ }
+
+ /// Re-reads the OCP quote so the pay step uses a fresh quoteId — the swap may
+ /// have taken longer than the original quote's validity window. Runs both on
+ /// the first pay attempt (right after the swap) and on every [retryPay].
+ ///
+ /// A GENUINE expiry (the explicit `expiration.isBefore(now)` check) and a
+ /// TRANSIENT fetch error are kept distinct: both are recoverable by retrying
+ /// the pay leg, so neither forces a re-scan → re-swap.
+ Future _refreshQuoteAndPay() async {
+ final LnurlpPaymentDto details;
+ try {
+ emit(const PayProcessRefreshingQuote());
+ details = await _payService.getPaymentDetails(_paymentLinkId);
+ } catch (e) {
+ // Transient/network error fetching the quote — NOT a genuine expiry.
+ // Retry the pay leg; the swapped ZCHF stays in the wallet.
+ emit(PayProcessPayRetry(PayRetryReason.transient, message: e.toString()));
+ return;
+ }
+
+ if (details.quote.expiration.isBefore(DateTime.now())) {
+ emit(const PayProcessPayRetry(PayRetryReason.quoteExpired));
+ return;
+ }
+
+ // Guard the slippage boundary: the swap acquired [_acquiredZchf], but the
+ // fresh quote may now demand more ZCHF than that. Settling it would fail
+ // server-side AFTER the irreversible swap, so surface a typed, retryable
+ // state (re-quote may land within the held ZCHF) instead of an opaque
+ // failure. The leftover ZCHF stays in the wallet.
+ final freshZchf = _zchfTransferAmount(details);
+ if (freshZchf != null && freshZchf > _acquiredZchf) {
+ emit(
+ PayProcessPayRetry(
+ PayRetryReason.insufficientZchf,
+ message: 'fresh settlement $freshZchf ZCHF exceeds acquired $_acquiredZchf ZCHF',
+ ),
+ );
+ return;
+ }
+
+ await _executePay(details.quote.id);
+ }
+
+ Future _executePay(String quoteId) async {
+ try {
+ emit(const PayProcessPaying());
+ final RealUnitOcpPayUnsignedTransactionDto unsigned = await _payService
+ .createPayUnsignedTransaction(
+ RealUnitOcpPayDto(paymentLinkId: _paymentLinkId, quoteId: quoteId),
+ );
+ final signed = await _signTransaction(unsigned.unsignedTx);
+ final txId = await _payService.submitPay(
+ RealUnitOcpPaySubmitDto(
+ unsignedTx: signed.unsignedTx,
+ r: signed.r,
+ s: signed.s,
+ v: signed.v,
+ paymentLinkId: _paymentLinkId,
+ quoteId: quoteId,
+ ),
+ );
+ emit(PayProcessAwaitingSettlement(txId));
+ _startStatusPolling();
+ } catch (e) {
+ // The swap already happened; the user holds ZCHF. Any pay-leg failure
+ // here (signing dropped, BitBox disconnect, transient submit error,
+ // settlement rejected) is recoverable by retrying the pay leg — never by
+ // re-swapping. Surface the retryable state rather than a terminal failure.
+ emit(PayProcessPayRetry(PayRetryReason.transient, message: e.toString()));
+ }
+ }
+
+ /// The ZCHF amount listed for the Ethereum transfer method in a fresh quote,
+ /// or null if the link no longer offers a priced Ethereum/ZCHF method. Mirrors
+ /// [PayQuoteCubit]'s selection — the app never computes the amount locally.
+ static double? _zchfTransferAmount(LnurlpPaymentDto details) {
+ for (final transfer in details.transferAmounts) {
+ if (transfer.method.toLowerCase() != 'ethereum') continue;
+ for (final asset in transfer.assets) {
+ if (asset.asset.toUpperCase() == 'ZCHF') return asset.amount;
+ }
+ }
+ return null;
+ }
+
+ void _startStatusPolling() {
+ _statusPollingTimer?.cancel();
+ _statusPollingTimer = Timer.periodic(_statusPollInterval, (_) async {
+ try {
+ final status = await _payService.getPayStatus(_paymentLinkId);
+ if (!status.status.isTerminal) return;
+ _statusPollingTimer?.cancel();
+ if (status.status.isCompleted) {
+ emit(const PayProcessSuccess());
+ } else {
+ // The engine reached a terminal non-completed status (e.g. the quote
+ // expired or was cancelled before it settled). The user still holds
+ // the swapped ZCHF, so this is recoverable by retrying the pay leg.
+ emit(const PayProcessPayRetry(PayRetryReason.transient));
+ }
+ } catch (_) {
+ // keep polling on transient errors
+ }
+ });
+ }
+
+ /// Signs a serialized unsigned EIP-1559 tx with the active wallet credentials
+ /// and returns the broadcast envelope (`unsignedTx` + r/s/v). Works for
+ /// software and BitBox; a debug wallet's `signToSignature` throws
+ /// [UnsupportedError], normalised here to [PaySignatureUnsupportedException].
+ Future _signTransaction(String rawTransaction) async {
+ await _walletService.ensureCurrentWalletUnlocked();
+ try {
+ final credentials = _appStore.wallet.currentAccount.primaryAddress;
+ final payload = Uint8List.fromList(
+ convert.hex.decode(
+ rawTransaction.startsWith('0x') ? rawTransaction.substring(2) : rawTransaction,
+ ),
+ );
+ final MsgSignature sig;
+ try {
+ sig = await credentials.signToSignature(
+ payload,
+ chainId: _appStore.apiConfig.asset.chainId,
+ isEIP1559: true,
+ );
+ } on UnsupportedError {
+ throw const PaySignatureUnsupportedException();
+ }
+ final r = sig.r.toRadixString(16).padLeft(64, '0');
+ final s = sig.s.toRadixString(16).padLeft(64, '0');
+ return BroadcastTransactionRequestDto(
+ unsignedTx: rawTransaction,
+ r: '0x$r',
+ s: '0x$s',
+ v: sig.v,
+ );
+ } finally {
+ await _walletService.lockCurrentWallet();
+ }
+ }
+
+ @override
+ Future close() {
+ _ethPollingTimer?.cancel();
+ _statusPollingTimer?.cancel();
+ return super.close();
+ }
+}
diff --git a/lib/screens/pay/cubits/pay_process/pay_process_state.dart b/lib/screens/pay/cubits/pay_process/pay_process_state.dart
new file mode 100644
index 000000000..f7894c83a
--- /dev/null
+++ b/lib/screens/pay/cubits/pay_process/pay_process_state.dart
@@ -0,0 +1,118 @@
+part of 'pay_process_cubit.dart';
+
+/// Why the pay flow failed. Each reason maps to a localized, user-facing
+/// message in the view — the cubit carries the reason, not the copy.
+enum PayProcessFailureReason {
+ /// The swap quote came back invalid (e.g. not fundable for the requested
+ /// ZCHF amount after the slippage buffer).
+ insufficientZchf,
+
+ /// Not enough ETH to cover gas and the faucet top-up did not arrive.
+ insufficientEth,
+
+ /// Open CryptoPay settlement is unavailable on the current backend
+ /// environment (mainnet-only; checked BEFORE the swap so it never strands the
+ /// user in ZCHF).
+ payUnsupportedEnvironment,
+
+ /// The active wallet mode cannot sign transactions (debug wallet).
+ signatureUnsupported,
+
+ /// A BitBox is required but not connected.
+ bitboxRequired,
+
+ /// Any other unexpected error.
+ generic,
+}
+
+/// Why the pay leg failed AFTER the REALU→ZCHF swap already succeeded. The user
+/// holds ZCHF, so recovery must retry the pay leg ONLY (re-quote + sign +
+/// submit) — never the swap. Each reason maps to a localized message.
+enum PayRetryReason {
+ /// The OCP quote expired between the swap and the pay step. Re-quoting is
+ /// safe — the swapped ZCHF stays in the wallet.
+ quoteExpired,
+
+ /// A transient/network error while re-fetching the quote or settling. Not a
+ /// genuine expiry; retrying the pay leg is the correct recovery.
+ transient,
+
+ /// The freshly re-fetched settlement amount exceeds the ZCHF acquired by the
+ /// swap (price moved more than the swap headroom buffer). Re-quoting may land
+ /// within the held ZCHF; the leftover ZCHF stays in the wallet meanwhile.
+ insufficientZchf,
+}
+
+sealed class PayProcessState extends Equatable {
+ const PayProcessState();
+
+ @override
+ List get props => [];
+}
+
+class PayProcessInitial extends PayProcessState {
+ const PayProcessInitial();
+}
+
+class PayProcessPreparingSwap extends PayProcessState {
+ const PayProcessPreparingSwap();
+}
+
+class PayProcessWaitingForEth extends PayProcessState {
+ const PayProcessWaitingForEth();
+}
+
+class PayProcessSwapping extends PayProcessState {
+ const PayProcessSwapping();
+}
+
+class PayProcessRefreshingQuote extends PayProcessState {
+ const PayProcessRefreshingQuote();
+}
+
+class PayProcessPaying extends PayProcessState {
+ const PayProcessPaying();
+}
+
+/// Pay tx submitted; polling `/pay/:id/status` until it settles.
+class PayProcessAwaitingSettlement extends PayProcessState {
+ final String txId;
+
+ const PayProcessAwaitingSettlement(this.txId);
+
+ @override
+ List get props => [txId];
+}
+
+class PayProcessSuccess extends PayProcessState {
+ const PayProcessSuccess();
+}
+
+/// The swap succeeded (ZCHF is in the wallet) but the pay leg failed. Recoverable
+/// by retrying the pay leg ONLY — the view calls [PayProcessCubit.retryPay],
+/// which re-quotes + signs + submits without ever re-swapping. This is the key
+/// fund-safety state: a failed pay no longer forces a re-scan → re-swap (which
+/// would double-convert REALU).
+class PayProcessPayRetry extends PayProcessState {
+ final PayRetryReason reason;
+
+ /// Diagnostic detail for logs — not the user-facing copy.
+ final String? message;
+
+ const PayProcessPayRetry(this.reason, {this.message});
+
+ @override
+ List get props => [reason, message];
+}
+
+class PayProcessFailure extends PayProcessState {
+ final PayProcessFailureReason reason;
+
+ /// Diagnostic detail for logs — not the user-facing copy.
+ final String? message;
+
+ const PayProcessFailure(this.reason, {this.message});
+
+ @override
+ List get props => [reason, message];
+}
diff --git a/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart
new file mode 100644
index 000000000..6180400c2
--- /dev/null
+++ b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart
@@ -0,0 +1,70 @@
+import 'package:equatable/equatable.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+
+part 'pay_quote_state.dart';
+
+/// Reads the public OCP payment-link quote (`GET /v1/lnurlp/:id`) and surfaces
+/// the requested fiat amount + the exact ZCHF amount the Ethereum method
+/// requires. The amount comes from the API `transferAmounts` (ZCHF on the
+/// Ethereum entry) — the app never computes it. An expired quote surfaces as a
+/// typed state so the view can prompt a re-scan.
+class PayQuoteCubit extends Cubit {
+ final RealUnitPayService _payService;
+ final String _paymentLinkId;
+
+ PayQuoteCubit(this._payService, this._paymentLinkId) : super(const PayQuoteLoading());
+
+ Future load() async {
+ emit(const PayQuoteLoading());
+
+ // Gate the irreversible flow up-front: if OCP settlement can never succeed
+ // on this environment, surface it now — before the user can confirm a quote
+ // and trigger the REALU→ZCHF swap. The swap must never run where the pay
+ // leg cannot settle.
+ if (!_payService.isPaySupportedEnvironment) {
+ emit(const PayQuoteUnsupportedEnvironment());
+ return;
+ }
+
+ try {
+ final details = await _payService.getPaymentDetails(_paymentLinkId);
+
+ if (details.quote.expiration.isBefore(DateTime.now())) {
+ emit(const PayQuoteExpired());
+ return;
+ }
+
+ final zchfAmount = _zchfTransferAmount(details);
+ if (zchfAmount == null) {
+ emit(const PayQuoteUnavailable());
+ return;
+ }
+
+ emit(
+ PayQuoteReady(
+ paymentLinkId: _paymentLinkId,
+ quoteId: details.quote.id,
+ fiatAsset: details.requestedAmount.asset,
+ fiatAmount: details.requestedAmount.amount,
+ zchfAmount: zchfAmount,
+ ),
+ );
+ } catch (e) {
+ emit(PayQuoteError(e.toString()));
+ }
+ }
+
+ /// The ZCHF amount listed for the Ethereum transfer method, or null if the
+ /// payment link does not offer an Ethereum/ZCHF method.
+ static double? _zchfTransferAmount(LnurlpPaymentDto details) {
+ for (final transfer in details.transferAmounts) {
+ if (transfer.method.toLowerCase() != 'ethereum') continue;
+ for (final asset in transfer.assets) {
+ if (asset.asset.toUpperCase() == 'ZCHF') return asset.amount;
+ }
+ }
+ return null;
+ }
+}
diff --git a/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart b/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart
new file mode 100644
index 000000000..9125644e5
--- /dev/null
+++ b/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart
@@ -0,0 +1,56 @@
+part of 'pay_quote_cubit.dart';
+
+sealed class PayQuoteState extends Equatable {
+ const PayQuoteState();
+
+ @override
+ List get props => [];
+}
+
+class PayQuoteLoading extends PayQuoteState {
+ const PayQuoteLoading();
+}
+
+class PayQuoteReady extends PayQuoteState {
+ final String paymentLinkId;
+ final String quoteId;
+ final String fiatAsset;
+ final double fiatAmount;
+ final double zchfAmount;
+
+ const PayQuoteReady({
+ required this.paymentLinkId,
+ required this.quoteId,
+ required this.fiatAsset,
+ required this.fiatAmount,
+ required this.zchfAmount,
+ });
+
+ @override
+ List get props => [paymentLinkId, quoteId, fiatAsset, fiatAmount, zchfAmount];
+}
+
+/// The quote attached to the scanned link has expired — the user must re-scan.
+class PayQuoteExpired extends PayQuoteState {
+ const PayQuoteExpired();
+}
+
+/// The payment link offers no Ethereum/ZCHF transfer method.
+class PayQuoteUnavailable extends PayQuoteState {
+ const PayQuoteUnavailable();
+}
+
+/// OCP settlement is unavailable on the current backend environment
+/// (mainnet-only). Surfaced before the swap so it can never run on testnet.
+class PayQuoteUnsupportedEnvironment extends PayQuoteState {
+ const PayQuoteUnsupportedEnvironment();
+}
+
+class PayQuoteError extends PayQuoteState {
+ final String message;
+
+ const PayQuoteError(this.message);
+
+ @override
+ List get props => [message];
+}
diff --git a/lib/screens/pay/cubits/pay_scan/pay_scan_cubit.dart b/lib/screens/pay/cubits/pay_scan/pay_scan_cubit.dart
new file mode 100644
index 000000000..d40c3cf68
--- /dev/null
+++ b/lib/screens/pay/cubits/pay_scan/pay_scan_cubit.dart
@@ -0,0 +1,28 @@
+import 'package:equatable/equatable.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart';
+import 'package:realunit_wallet/packages/service/dfx/lnurl_decoder.dart';
+
+part 'pay_scan_state.dart';
+
+/// Decodes a scanned OCP QR into a DFX payment-link id + lnurlp URL. Pure
+/// decode — no network. The view advances to the quote step on
+/// [PayScanDecoded]; a malformed code keeps the scanner open with an error.
+class PayScanCubit extends Cubit {
+ PayScanCubit() : super(const PayScanScanning());
+
+ /// Called once per detected barcode. Guards against re-entry after a
+ /// successful decode so a continuously-detecting scanner does not re-emit.
+ void onCodeDetected(String raw) {
+ if (state is PayScanDecoded) return;
+ try {
+ final decoded = LnurlDecoder.decode(raw);
+ emit(PayScanDecoded(decoded));
+ } on InvalidPaymentLinkException catch (e) {
+ emit(PayScanInvalid(e.reason));
+ }
+ }
+
+ /// Dismiss an error and resume scanning.
+ void reset() => emit(const PayScanScanning());
+}
diff --git a/lib/screens/pay/cubits/pay_scan/pay_scan_state.dart b/lib/screens/pay/cubits/pay_scan/pay_scan_state.dart
new file mode 100644
index 000000000..f3552e5f4
--- /dev/null
+++ b/lib/screens/pay/cubits/pay_scan/pay_scan_state.dart
@@ -0,0 +1,30 @@
+part of 'pay_scan_cubit.dart';
+
+sealed class PayScanState extends Equatable {
+ const PayScanState();
+
+ @override
+ List get props => [];
+}
+
+class PayScanScanning extends PayScanState {
+ const PayScanScanning();
+}
+
+class PayScanInvalid extends PayScanState {
+ final String reason;
+
+ const PayScanInvalid(this.reason);
+
+ @override
+ List get props => [reason];
+}
+
+class PayScanDecoded extends PayScanState {
+ final DecodedPaymentLink link;
+
+ const PayScanDecoded(this.link);
+
+ @override
+ List get props => [link.id, link.lnurlpUrl];
+}
diff --git a/lib/screens/pay/pay_process_page.dart b/lib/screens/pay/pay_process_page.dart
new file mode 100644
index 000000000..0d7f9fb9c
--- /dev/null
+++ b/lib/screens/pay/pay_process_page.dart
@@ -0,0 +1,214 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/packages/service/wallet_service.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart';
+import 'package:realunit_wallet/setup/di.dart';
+import 'package:realunit_wallet/styles/colors.dart';
+
+class PayProcessPage extends StatelessWidget {
+ final String paymentLinkId;
+ final double zchfNeeded;
+
+ const PayProcessPage({
+ super.key,
+ required this.paymentLinkId,
+ required this.zchfNeeded,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocProvider(
+ create: (_) => PayProcessCubit(
+ payService: getIt(),
+ faucetService: getIt(),
+ blockchainService: getIt(),
+ walletService: getIt(),
+ appStore: getIt(),
+ paymentLinkId: paymentLinkId,
+ zchfNeeded: zchfNeeded,
+ )..start(),
+ child: const PayProcessView(),
+ );
+ }
+}
+
+class PayProcessView extends StatelessWidget {
+ const PayProcessView({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocConsumer(
+ listenWhen: (previous, current) =>
+ current is PayProcessSuccess ||
+ current is PayProcessFailure ||
+ current is PayProcessPayRetry,
+ listener: (context, state) async {
+ if (state is PayProcessSuccess) {
+ await _showResultSheet(
+ context,
+ icon: Icons.check_circle_rounded,
+ title: S.of(context).paySuccess,
+ description: S.of(context).paySuccessDescription,
+ );
+ } else if (state is PayProcessPayRetry) {
+ // The swap already succeeded — offer to retry the PAY leg only. The
+ // ZCHF stays in the wallet; this never re-swaps.
+ await _showRetrySheet(context, state.reason);
+ } else if (state is PayProcessFailure) {
+ await _showResultSheet(
+ context,
+ icon: Icons.error_rounded,
+ title: S.of(context).payFailureTitle,
+ description: _failureMessage(context, state.reason),
+ );
+ }
+ },
+ builder: (context, state) {
+ return Scaffold(
+ appBar: AppBar(title: Text(S.of(context).pay)),
+ body: SafeArea(
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ spacing: 24,
+ children: [
+ const CupertinoActivityIndicator(radius: 16),
+ Text(
+ _progressLabel(context, state),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ }
+
+ String _progressLabel(BuildContext context, PayProcessState state) => switch (state) {
+ PayProcessInitial() || PayProcessPreparingSwap() => S.of(context).payPreparingSwap,
+ PayProcessWaitingForEth() => S.of(context).payWaitingForEth,
+ PayProcessSwapping() => S.of(context).paySwapping,
+ PayProcessRefreshingQuote() => S.of(context).payRefreshingQuote,
+ PayProcessPaying() => S.of(context).payPaying,
+ PayProcessAwaitingSettlement() => S.of(context).payAwaitingSettlement,
+ PayProcessSuccess() => S.of(context).paySuccess,
+ PayProcessPayRetry() => S.of(context).payRetryTitle,
+ PayProcessFailure() => S.of(context).payFailureTitle,
+ };
+
+ String _failureMessage(BuildContext context, PayProcessFailureReason reason) => switch (reason) {
+ PayProcessFailureReason.insufficientZchf => S.of(context).payFailureInsufficientZchf,
+ PayProcessFailureReason.insufficientEth => S.of(context).payFailureInsufficientEth,
+ PayProcessFailureReason.payUnsupportedEnvironment =>
+ S.of(context).payFailureUnsupportedEnvironment,
+ PayProcessFailureReason.signatureUnsupported => S.of(context).payFailureSignatureUnsupported,
+ PayProcessFailureReason.bitboxRequired => S.of(context).payFailureBitboxRequired,
+ PayProcessFailureReason.generic => S.of(context).payFailureGeneric,
+ };
+
+ String _retryMessage(BuildContext context, PayRetryReason reason) => switch (reason) {
+ PayRetryReason.quoteExpired => S.of(context).payRetryQuoteExpired,
+ PayRetryReason.transient => S.of(context).payRetryTransient,
+ PayRetryReason.insufficientZchf => S.of(context).payRetryInsufficientZchf,
+ };
+
+ Future _showResultSheet(
+ BuildContext context, {
+ required IconData icon,
+ required String title,
+ required String description,
+ }) async {
+ await showModalBottomSheet(
+ context: context,
+ isDismissible: false,
+ builder: (_) => SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ spacing: 24,
+ children: [
+ Icon(icon, color: RealUnitColors.realUnitBlue, size: 64),
+ Text(title, style: Theme.of(context).textTheme.headlineMedium),
+ Text(
+ description,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: RealUnitColors.neutral500,
+ ),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: Text(S.of(context).close),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ if (context.mounted) Navigator.of(context).pop();
+ }
+
+ /// Recovery sheet shown after a successful swap when the pay leg failed. The
+ /// primary action retries the PAY leg only ([PayProcessCubit.retryPay]) — the
+ /// swap is never redone, so the ZCHF already held is reused. Dismissing leaves
+ /// that ZCHF safely in the wallet.
+ Future _showRetrySheet(BuildContext context, PayRetryReason reason) async {
+ final cubit = context.read();
+ // The sheet returns true when the user retries (keep the page) and false
+ // when they close (leave the flow); a barrier dismissal yields null.
+ final retry = await showModalBottomSheet(
+ context: context,
+ isDismissible: false,
+ builder: (sheetContext) => SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ spacing: 24,
+ children: [
+ const Icon(Icons.replay_rounded, color: RealUnitColors.realUnitBlue, size: 64),
+ Text(
+ S.of(sheetContext).payRetryTitle,
+ style: Theme.of(sheetContext).textTheme.headlineMedium,
+ ),
+ Text(
+ _retryMessage(sheetContext, reason),
+ textAlign: TextAlign.center,
+ style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith(
+ color: RealUnitColors.neutral500,
+ ),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.of(sheetContext).pop(true),
+ child: Text(S.of(sheetContext).payRetryButton),
+ ),
+ TextButton(
+ onPressed: () => Navigator.of(sheetContext).pop(false),
+ child: Text(S.of(sheetContext).close),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ if (retry == true) {
+ // Retry the PAY leg only — never re-swaps. Keep the page so the next
+ // attempt surfaces its own result.
+ await cubit.retryPay();
+ } else if (context.mounted) {
+ // Closed: leave the flow. The swapped ZCHF stays safely in the wallet.
+ Navigator.of(context).pop();
+ }
+ }
+}
diff --git a/lib/screens/pay/pay_quote_page.dart b/lib/screens/pay/pay_quote_page.dart
new file mode 100644
index 000000000..7708b29ad
--- /dev/null
+++ b/lib/screens/pay/pay_quote_page.dart
@@ -0,0 +1,140 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart';
+import 'package:realunit_wallet/screens/pay/pay_process_page.dart';
+import 'package:realunit_wallet/setup/di.dart';
+import 'package:realunit_wallet/styles/colors.dart';
+
+class PayQuotePage extends StatelessWidget {
+ final String paymentLinkId;
+
+ const PayQuotePage({super.key, required this.paymentLinkId});
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocProvider(
+ create: (_) => PayQuoteCubit(getIt(), paymentLinkId)..load(),
+ child: const PayQuoteView(),
+ );
+ }
+}
+
+class PayQuoteView extends StatelessWidget {
+ const PayQuoteView({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: Text(S.of(context).payQuoteTitle)),
+ body: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
+ child: BlocBuilder(
+ builder: (context, state) => switch (state) {
+ PayQuoteLoading() => const Center(child: CupertinoActivityIndicator()),
+ PayQuoteReady() => _PayQuoteReadyView(state: state),
+ PayQuoteExpired() => _PayQuoteMessage(message: S.of(context).payFailureQuoteExpired),
+ PayQuoteUnavailable() => _PayQuoteMessage(message: S.of(context).payQuoteUnavailable),
+ PayQuoteUnsupportedEnvironment() => _PayQuoteMessage(
+ message: S.of(context).payFailureUnsupportedEnvironment,
+ ),
+ PayQuoteError() => _PayQuoteMessage(message: S.of(context).payFailureGeneric),
+ },
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _PayQuoteReadyView extends StatelessWidget {
+ final PayQuoteReady state;
+
+ const _PayQuoteReadyView({required this.state});
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ spacing: 24,
+ children: [
+ const Spacer(),
+ Text(
+ S
+ .of(context)
+ .payQuoteSummary(
+ state.fiatAmount.toStringAsFixed(2),
+ state.fiatAsset,
+ ),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.headlineMedium,
+ ),
+ _AmountRow(
+ label: S.of(context).payQuoteRequested,
+ value: '${state.fiatAmount.toStringAsFixed(2)} ${state.fiatAsset}',
+ ),
+ _AmountRow(
+ label: S.of(context).payQuoteZchfNeeded,
+ value: '${state.zchfAmount.toStringAsFixed(2)} ZCHF',
+ ),
+ const Spacer(),
+ FilledButton(
+ onPressed: () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => PayProcessPage(
+ paymentLinkId: state.paymentLinkId,
+ zchfNeeded: state.zchfAmount,
+ ),
+ ),
+ ),
+ child: Text(S.of(context).payConfirmButton),
+ ),
+ ],
+ );
+ }
+}
+
+class _AmountRow extends StatelessWidget {
+ final String label;
+ final String value;
+
+ const _AmountRow({required this.label, required this.value});
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ label,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: RealUnitColors.neutral500,
+ ),
+ ),
+ Text(value, style: Theme.of(context).textTheme.bodyLarge),
+ ],
+ );
+ }
+}
+
+class _PayQuoteMessage extends StatelessWidget {
+ final String message;
+
+ const _PayQuoteMessage({required this.message});
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Text(
+ message,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(
+ color: RealUnitColors.neutral500,
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/pay/pay_scan_page.dart b/lib/screens/pay/pay_scan_page.dart
new file mode 100644
index 000000000..7cc13dd58
--- /dev/null
+++ b/lib/screens/pay/pay_scan_page.dart
@@ -0,0 +1,62 @@
+// @no-integration-test: the QR scanner is camera/MethodChannel-coupled
+// (mobile_scanner) and can only be exercised on a real device with a live
+// camera. The decode logic it feeds is unit-tested in lnurl_decoder_test.dart
+// and the cubit behaviour in pay_scan_cubit_test.dart; the camera preview
+// itself is out of scope for widget tests.
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart';
+import 'package:realunit_wallet/screens/pay/pay_quote_page.dart';
+import 'package:realunit_wallet/styles/colors.dart';
+import 'package:realunit_wallet/widgets/scanner/qr_scanner_view.dart';
+
+class PayScanPage extends StatelessWidget {
+ const PayScanPage({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocProvider(
+ create: (_) => PayScanCubit(),
+ child: const PayScanView(),
+ );
+ }
+}
+
+class PayScanView extends StatelessWidget {
+ const PayScanView({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocConsumer(
+ listener: (context, state) {
+ if (state is PayScanDecoded) {
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => PayQuotePage(paymentLinkId: state.link.id),
+ ),
+ );
+ // Reset so returning to the scanner re-arms detection.
+ context.read().reset();
+ }
+ if (state is PayScanInvalid) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(S.of(context).payScanInvalid),
+ backgroundColor: RealUnitColors.status.red600,
+ ),
+ );
+ context.read().reset();
+ }
+ },
+ builder: (context, state) {
+ return Scaffold(
+ appBar: AppBar(title: Text(S.of(context).payScanTitle)),
+ body: QrScannerView(
+ onDetect: (raw) => context.read().onCodeDetected(raw),
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/screens/send/cubits/send_amount/send_amount_cubit.dart b/lib/screens/send/cubits/send_amount/send_amount_cubit.dart
new file mode 100644
index 000000000..7a373374e
--- /dev/null
+++ b/lib/screens/send/cubits/send_amount/send_amount_cubit.dart
@@ -0,0 +1,60 @@
+import 'package:equatable/equatable.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+part 'send_amount_state.dart';
+
+/// Validates the REALU amount to send. REALU has `decimals = 0`, so the amount
+/// is a whole number of shares. The available balance (also whole shares) is
+/// tracked here so the field can be validated against it locally for UX; the
+/// API remains the authority and re-checks the balance on `PUT /transfer`.
+class SendAmountCubit extends Cubit {
+ /// Available REALU shares in the wallet, or null when the balance is unknown
+ /// (the available-balance hint and the over-balance guard are then skipped —
+ /// the API still validates). Mutable because the balance arrives over a
+ /// stream and may update after the cubit is built.
+ BigInt? availableShares;
+
+ SendAmountCubit({this.availableShares}) : super(const SendAmountState());
+
+ /// Updates the tracked balance when a fresh value arrives from the balance
+ /// stream, then re-evaluates the current input against it so an amount that
+ /// was provisionally valid (unknown balance) is re-checked.
+ void availableSharesChanged(BigInt shares) {
+ if (availableShares == shares) return;
+ availableShares = shares;
+ if (state.text.isNotEmpty) amountChanged(state.text);
+ }
+
+ /// Re-evaluates [raw] on every keystroke and emits the parsed amount + a
+ /// validity flag the confirm button binds to.
+ void amountChanged(String raw) {
+ final text = raw.trim();
+ if (text.isEmpty) {
+ emit(SendAmountState(text: text, amount: null, status: SendAmountStatus.empty));
+ return;
+ }
+
+ final parsed = int.tryParse(text);
+ if (parsed == null || parsed < 1) {
+ emit(SendAmountState(text: text, amount: parsed, status: SendAmountStatus.invalid));
+ return;
+ }
+
+ final balance = availableShares;
+ if (balance != null && BigInt.from(parsed) > balance) {
+ emit(
+ SendAmountState(text: text, amount: parsed, status: SendAmountStatus.insufficientBalance),
+ );
+ return;
+ }
+
+ emit(SendAmountState(text: text, amount: parsed, status: SendAmountStatus.valid));
+ }
+
+ /// Fills the field with the full available balance.
+ void useMax() {
+ final balance = availableShares;
+ if (balance == null || balance < BigInt.one) return;
+ amountChanged(balance.toString());
+ }
+}
diff --git a/lib/screens/send/cubits/send_amount/send_amount_state.dart b/lib/screens/send/cubits/send_amount/send_amount_state.dart
new file mode 100644
index 000000000..f666804f6
--- /dev/null
+++ b/lib/screens/send/cubits/send_amount/send_amount_state.dart
@@ -0,0 +1,37 @@
+part of 'send_amount_cubit.dart';
+
+/// Validity of the entered amount. Each value maps to a localized hint/error in
+/// the view — the cubit carries the status, not the copy.
+enum SendAmountStatus {
+ /// Nothing entered yet.
+ empty,
+
+ /// Not a whole number >= 1.
+ invalid,
+
+ /// A valid whole number, but more than the available REALU balance.
+ insufficientBalance,
+
+ /// A valid, sendable amount.
+ valid,
+}
+
+class SendAmountState extends Equatable {
+ final String text;
+
+ /// The parsed amount, or null when [text] is empty / not an integer.
+ final int? amount;
+ final SendAmountStatus status;
+
+ const SendAmountState({
+ this.text = '',
+ this.amount,
+ this.status = SendAmountStatus.empty,
+ });
+
+ /// The confirm button binds to this — only a fully valid amount may advance.
+ bool get isValid => status == SendAmountStatus.valid && amount != null;
+
+ @override
+ List get props => [text, amount, status];
+}
diff --git a/lib/screens/send/cubits/send_process/send_process_cubit.dart b/lib/screens/send/cubits/send_process/send_process_cubit.dart
new file mode 100644
index 000000000..e7f5c2e5c
--- /dev/null
+++ b/lib/screens/send/cubits/send_process/send_process_cubit.dart
@@ -0,0 +1,90 @@
+import 'package:equatable/equatable.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart';
+import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart';
+import 'package:realunit_wallet/packages/wallet/wallet.dart';
+
+part 'send_process_state.dart';
+
+/// Orchestrates the gasless wallet-to-wallet transfer once the user confirms:
+/// gate the wallet's signing capability → `PUT /transfer` (prepare + EIP-7702
+/// delegation data) → sign the EIP-712 delegation + EIP-7702 authorization and
+/// `PUT /transfer/:id/confirm` → success (txHash) / a typed failure.
+///
+/// The gasless EIP-7702 path can only be signed by a software wallet today;
+/// debug and BitBox wallets are gated up-front (the contract's capability gate —
+/// the flow is NOT otherwise branched on wallet type). Insufficient REALU and
+/// invalid-address are decided by the API and rendered from its signal; a 503
+/// (gas-funding-unavailable) surfaces as a dedicated "temporarily unavailable"
+/// state.
+class SendProcessCubit extends Cubit {
+ final RealUnitTransferService _transferService;
+ final AppStore _appStore;
+ final String _recipient;
+ final int _amount;
+
+ SendProcessCubit({
+ required RealUnitTransferService transferService,
+ required AppStore appStore,
+ required String recipient,
+ required int amount,
+ }) : _transferService = transferService,
+ _appStore = appStore,
+ _recipient = recipient,
+ _amount = amount,
+ super(const SendProcessInitial());
+
+ /// Entry point — called by the view once the user confirms the summary.
+ Future start() async {
+ // Capability gate BEFORE any network/sign action: only a software wallet can
+ // produce the EIP-712 delegation + EIP-7702 authorization the gasless
+ // transfer requires. Surface the dedicated unsupported state otherwise.
+ if (_appStore.wallet.walletType != WalletType.software) {
+ emit(const SendProcessFailure(SendProcessFailureReason.signatureUnsupported));
+ return;
+ }
+
+ try {
+ emit(const SendProcessPreparing());
+ final info = await _transferService.prepareTransfer(
+ RealUnitTransferDto(toAddress: _recipient, amount: _amount),
+ );
+
+ emit(const SendProcessSigning());
+ final txHash = await _transferService.confirmTransfer(info);
+
+ emit(SendProcessSuccess(txHash));
+ } on TransferSignatureUnsupportedException {
+ emit(const SendProcessFailure(SendProcessFailureReason.signatureUnsupported));
+ } on TransferGasFundingUnavailableException {
+ emit(const SendProcessFailure(SendProcessFailureReason.gasFundingUnavailable));
+ } on SigningCancelledException {
+ emit(const SendProcessFailure(SendProcessFailureReason.signatureCancelled));
+ } on BitboxNotConnectedException {
+ emit(const SendProcessFailure(SendProcessFailureReason.signatureUnsupported));
+ } on ApiException catch (e) {
+ // The API is the authority on recipient/amount/eligibility. Render its
+ // signaled reason rather than re-deriving limits locally.
+ emit(SendProcessFailure(_reasonForApi(e), message: e.message));
+ } catch (e) {
+ emit(SendProcessFailure(SendProcessFailureReason.generic, message: e.toString()));
+ }
+ }
+
+ /// Maps an API error to a typed failure reason. A 400 from `PUT /transfer`
+ /// covers both an invalid recipient and insufficient REALU; both render a
+ /// generic "could not prepare the transfer" message keyed off the API text,
+ /// so they share [SendProcessFailureReason.invalidRequest].
+ static SendProcessFailureReason _reasonForApi(ApiException e) {
+ if (e.statusCode == 503) return SendProcessFailureReason.gasFundingUnavailable;
+ if (e.statusCode == 400 || e.statusCode == 404) {
+ return SendProcessFailureReason.invalidRequest;
+ }
+ return SendProcessFailureReason.generic;
+ }
+}
diff --git a/lib/screens/send/cubits/send_process/send_process_state.dart b/lib/screens/send/cubits/send_process/send_process_state.dart
new file mode 100644
index 000000000..897345b1c
--- /dev/null
+++ b/lib/screens/send/cubits/send_process/send_process_state.dart
@@ -0,0 +1,66 @@
+part of 'send_process_cubit.dart';
+
+/// Why the transfer failed. Each reason maps to a localized, user-facing message
+/// in the view — the cubit carries the reason, not the copy.
+enum SendProcessFailureReason {
+ /// The active wallet mode cannot sign the gasless EIP-7702 transfer (debug or
+ /// BitBox wallet, or a debug credential detected at sign time).
+ signatureUnsupported,
+
+ /// The user cancelled the signature (or the signing device dropped the link).
+ signatureCancelled,
+
+ /// DFX cannot currently fund gas for the transfer (the API's 503). The user's
+ /// REALU is untouched — this is a transient "temporarily unavailable" state.
+ gasFundingUnavailable,
+
+ /// The API rejected the prepare request (invalid recipient, self-transfer,
+ /// token-contract recipient, non-integer amount, or insufficient REALU). The
+ /// API message carries the specific detail.
+ invalidRequest,
+
+ /// Any other unexpected error.
+ generic,
+}
+
+sealed class SendProcessState extends Equatable {
+ const SendProcessState();
+
+ @override
+ List get props => [];
+}
+
+class SendProcessInitial extends SendProcessState {
+ const SendProcessInitial();
+}
+
+/// `PUT /transfer` in flight — preparing the intent + EIP-7702 delegation data.
+class SendProcessPreparing extends SendProcessState {
+ const SendProcessPreparing();
+}
+
+/// Signing the EIP-712 delegation + EIP-7702 authorization and confirming.
+class SendProcessSigning extends SendProcessState {
+ const SendProcessSigning();
+}
+
+class SendProcessSuccess extends SendProcessState {
+ final String txHash;
+
+ const SendProcessSuccess(this.txHash);
+
+ @override
+ List get props => [txHash];
+}
+
+class SendProcessFailure extends SendProcessState {
+ final SendProcessFailureReason reason;
+
+ /// Diagnostic detail for logs — not the user-facing copy.
+ final String? message;
+
+ const SendProcessFailure(this.reason, {this.message});
+
+ @override
+ List get props => [reason, message];
+}
diff --git a/lib/screens/send/cubits/send_recipient/send_recipient_cubit.dart b/lib/screens/send/cubits/send_recipient/send_recipient_cubit.dart
new file mode 100644
index 000000000..993411f48
--- /dev/null
+++ b/lib/screens/send/cubits/send_recipient/send_recipient_cubit.dart
@@ -0,0 +1,55 @@
+import 'package:equatable/equatable.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart';
+import 'package:web3dart/web3dart.dart' show EthereumAddress;
+
+part 'send_recipient_state.dart';
+
+/// Captures the transfer recipient — either scanned from a QR code or pasted /
+/// typed manually. Validation here is a client-side UX guard only (a malformed
+/// address is rejected before the user can advance); the API remains the final
+/// authority on the address. A scanned `ethereum:0x…` URI is normalized to the
+/// bare address so a wallet QR that encodes an EIP-681 URI is accepted.
+class SendRecipientCubit extends Cubit {
+ SendRecipientCubit() : super(const SendRecipientEmpty());
+
+ /// Called once per detected barcode while the scanner is open. Guards against
+ /// re-entry after a successful decode so a continuously-detecting scanner does
+ /// not re-emit.
+ void onCodeDetected(String raw) {
+ if (state is SendRecipientValid) return;
+ submit(raw);
+ }
+
+ /// Validates a manually entered / pasted / scanned [input]. Emits
+ /// [SendRecipientValid] with the checksummed address on success, or
+ /// [SendRecipientInvalid] otherwise.
+ void submit(String input) {
+ final address = _extractAddress(input);
+ try {
+ final checksummed = EthereumAddress.fromHex(address).hexEip55;
+ emit(SendRecipientValid(checksummed));
+ } catch (_) {
+ emit(SendRecipientInvalid(InvalidRecipientAddressException(input.trim())));
+ }
+ }
+
+ /// Clears the current selection so the field/scanner is ready for new input.
+ void reset() => emit(const SendRecipientEmpty());
+
+ /// Strips an optional `ethereum:` EIP-681 scheme and any `@chainId` / query
+ /// suffix, returning the bare hex address candidate.
+ static String _extractAddress(String input) {
+ var value = input.trim();
+ if (value.toLowerCase().startsWith('ethereum:')) {
+ value = value.substring('ethereum:'.length);
+ }
+ final at = value.indexOf('@');
+ if (at != -1) value = value.substring(0, at);
+ final query = value.indexOf('?');
+ if (query != -1) value = value.substring(0, query);
+ final slash = value.indexOf('/');
+ if (slash != -1) value = value.substring(0, slash);
+ return value.trim();
+ }
+}
diff --git a/lib/screens/send/cubits/send_recipient/send_recipient_state.dart b/lib/screens/send/cubits/send_recipient/send_recipient_state.dart
new file mode 100644
index 000000000..549d754e1
--- /dev/null
+++ b/lib/screens/send/cubits/send_recipient/send_recipient_state.dart
@@ -0,0 +1,33 @@
+part of 'send_recipient_cubit.dart';
+
+sealed class SendRecipientState extends Equatable {
+ const SendRecipientState();
+
+ @override
+ List get props => [];
+}
+
+/// No recipient entered yet.
+class SendRecipientEmpty extends SendRecipientState {
+ const SendRecipientEmpty();
+}
+
+/// A syntactically valid EVM address, normalized to its EIP-55 checksum form.
+class SendRecipientValid extends SendRecipientState {
+ final String address;
+
+ const SendRecipientValid(this.address);
+
+ @override
+ List get props => [address];
+}
+
+/// The entered/scanned value is not a valid EVM address (client-side UX guard).
+class SendRecipientInvalid extends SendRecipientState {
+ final InvalidRecipientAddressException error;
+
+ const SendRecipientInvalid(this.error);
+
+ @override
+ List get props => [error.input];
+}
diff --git a/lib/screens/send/send_amount_page.dart b/lib/screens/send/send_amount_page.dart
new file mode 100644
index 000000000..bbb95ea08
--- /dev/null
+++ b/lib/screens/send/send_amount_page.dart
@@ -0,0 +1,147 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/models/balance.dart';
+import 'package:realunit_wallet/packages/repository/balance_repository.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/screens/sell/cubits/sell_balance/sell_balance_cubit.dart';
+import 'package:realunit_wallet/screens/send/cubits/send_amount/send_amount_cubit.dart';
+import 'package:realunit_wallet/screens/send/send_confirm_page.dart';
+import 'package:realunit_wallet/setup/di.dart';
+import 'package:realunit_wallet/styles/colors.dart';
+
+/// Second step: choose the whole-share REALU amount. The available balance is
+/// read via the shared [SellBalanceCubit] (a generic REALU balance watcher) and
+/// fed into [SendAmountCubit] for the local over-balance UX guard. REALU has
+/// `decimals = 0`, so the raw balance equals whole shares.
+class SendAmountPage extends StatelessWidget {
+ final String recipient;
+
+ const SendAmountPage({super.key, required this.recipient});
+
+ @override
+ Widget build(BuildContext context) {
+ return MultiBlocProvider(
+ providers: [
+ BlocProvider(
+ create: (_) => SellBalanceCubit(getIt(), getIt()),
+ ),
+ BlocProvider(create: (_) => SendAmountCubit()),
+ ],
+ child: SendAmountView(recipient: recipient),
+ );
+ }
+}
+
+class SendAmountView extends StatelessWidget {
+ final String recipient;
+
+ const SendAmountView({super.key, required this.recipient});
+
+ @override
+ Widget build(BuildContext context) {
+ // The balance arrives over a stream; push every update into the amount
+ // cubit so the available hint + over-balance guard track the live balance
+ // (BlocProvider.create runs once, so the balance can't be captured there).
+ return BlocListener(
+ listener: (context, balance) =>
+ context.read().availableSharesChanged(balance.balance),
+ child: _SendAmountBody(recipient: recipient),
+ );
+ }
+}
+
+class _SendAmountBody extends StatefulWidget {
+ final String recipient;
+
+ const _SendAmountBody({required this.recipient});
+
+ @override
+ State<_SendAmountBody> createState() => _SendAmountBodyState();
+}
+
+class _SendAmountBodyState extends State<_SendAmountBody> {
+ final _controller = TextEditingController();
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ void _syncController(String text) {
+ if (_controller.text == text) return;
+ _controller.value = TextEditingValue(
+ text: text,
+ selection: TextSelection.collapsed(offset: text.length),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: Text(S.of(context).sendAmountTitle)),
+ body: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
+ child: BlocConsumer(
+ listenWhen: (previous, current) => previous.text != current.text,
+ listener: (context, state) => _syncController(state.text),
+ builder: (context, state) {
+ // The available hint tracks the live balance directly so it stays
+ // in sync with the stream.
+ final available = context.watch().state.balance;
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ spacing: 16,
+ children: [
+ Text(
+ S.of(context).sendAmountAvailable(available.toString()),
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: RealUnitColors.neutral500,
+ ),
+ ),
+ TextField(
+ controller: _controller,
+ keyboardType: TextInputType.number,
+ inputFormatters: [FilteringTextInputFormatter.digitsOnly],
+ decoration: InputDecoration(
+ labelText: S.of(context).sendAmountLabel,
+ errorText: _errorText(context, state.status),
+ suffixIcon: TextButton(
+ onPressed: () => context.read().useMax(),
+ child: Text(S.of(context).max.toUpperCase()),
+ ),
+ ),
+ onChanged: (value) => context.read().amountChanged(value),
+ ),
+ const Spacer(),
+ FilledButton(
+ onPressed: state.isValid
+ ? () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => SendConfirmPage(
+ recipient: widget.recipient,
+ amount: state.amount!,
+ ),
+ ),
+ )
+ : null,
+ child: Text(S.of(context).next),
+ ),
+ ],
+ );
+ },
+ ),
+ ),
+ ),
+ );
+ }
+
+ String? _errorText(BuildContext context, SendAmountStatus status) => switch (status) {
+ SendAmountStatus.empty || SendAmountStatus.valid => null,
+ SendAmountStatus.invalid => S.of(context).sendAmountInvalid,
+ SendAmountStatus.insufficientBalance => S.of(context).sendAmountInsufficient,
+ };
+}
diff --git a/lib/screens/send/send_confirm_page.dart b/lib/screens/send/send_confirm_page.dart
new file mode 100644
index 000000000..f2ee52b63
--- /dev/null
+++ b/lib/screens/send/send_confirm_page.dart
@@ -0,0 +1,85 @@
+import 'package:flutter/material.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/screens/send/send_process_page.dart';
+import 'package:realunit_wallet/styles/colors.dart';
+
+/// Third step: review the recipient + amount before signing. Confirming starts
+/// the on-chain process step.
+class SendConfirmPage extends StatelessWidget {
+ final String recipient;
+ final int amount;
+
+ const SendConfirmPage({super.key, required this.recipient, required this.amount});
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: Text(S.of(context).sendConfirmTitle)),
+ body: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ spacing: 24,
+ children: [
+ const Spacer(),
+ Text(
+ S.of(context).sendConfirmSummary(amount.toString()),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.headlineMedium,
+ ),
+ _SummaryRow(
+ label: S.of(context).sendConfirmAmount,
+ value: S.of(context).sendShares(amount.toString()),
+ ),
+ _SummaryRow(
+ label: S.of(context).sendConfirmRecipient,
+ value: recipient,
+ ),
+ const Spacer(),
+ FilledButton(
+ onPressed: () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => SendProcessPage(recipient: recipient, amount: amount),
+ ),
+ ),
+ child: Text(S.of(context).sendConfirmButton),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _SummaryRow extends StatelessWidget {
+ final String label;
+ final String value;
+
+ const _SummaryRow({required this.label, required this.value});
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: RealUnitColors.neutral500,
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Text(
+ value,
+ textAlign: TextAlign.end,
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/screens/send/send_process_page.dart b/lib/screens/send/send_process_page.dart
new file mode 100644
index 000000000..dc79c47a5
--- /dev/null
+++ b/lib/screens/send/send_process_page.dart
@@ -0,0 +1,134 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart';
+import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart';
+import 'package:realunit_wallet/setup/di.dart';
+import 'package:realunit_wallet/styles/colors.dart';
+
+/// Final step: prepare → sign (EIP-712 delegation + EIP-7702 authorization) →
+/// confirm, then render the txHash success or a typed failure. The cubit drives
+/// every outcome as a state — no error-string parsing in the view.
+class SendProcessPage extends StatelessWidget {
+ final String recipient;
+ final int amount;
+
+ const SendProcessPage({super.key, required this.recipient, required this.amount});
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocProvider(
+ create: (_) => SendProcessCubit(
+ transferService: getIt(),
+ appStore: getIt(),
+ recipient: recipient,
+ amount: amount,
+ )..start(),
+ child: const SendProcessView(),
+ );
+ }
+}
+
+class SendProcessView extends StatelessWidget {
+ const SendProcessView({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocConsumer(
+ listenWhen: (previous, current) =>
+ current is SendProcessSuccess || current is SendProcessFailure,
+ listener: (context, state) async {
+ if (state is SendProcessSuccess) {
+ await _showResultSheet(
+ context,
+ icon: Icons.check_circle_rounded,
+ title: S.of(context).sendSuccess,
+ description: S.of(context).sendSuccessDescription,
+ );
+ } else if (state is SendProcessFailure) {
+ await _showResultSheet(
+ context,
+ icon: Icons.error_rounded,
+ title: S.of(context).sendFailureTitle,
+ description: _failureMessage(context, state.reason),
+ );
+ }
+ },
+ builder: (context, state) {
+ return Scaffold(
+ appBar: AppBar(title: Text(S.of(context).sendProcessTitle)),
+ body: SafeArea(
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ spacing: 24,
+ children: [
+ const CupertinoActivityIndicator(radius: 16),
+ Text(
+ _progressLabel(context, state),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ }
+
+ String _progressLabel(BuildContext context, SendProcessState state) => switch (state) {
+ SendProcessInitial() || SendProcessPreparing() => S.of(context).sendPreparing,
+ SendProcessSigning() => S.of(context).sendSigning,
+ SendProcessSuccess() => S.of(context).sendSuccess,
+ SendProcessFailure() => S.of(context).sendFailureTitle,
+ };
+
+ String _failureMessage(BuildContext context, SendProcessFailureReason reason) => switch (reason) {
+ SendProcessFailureReason.signatureUnsupported => S.of(context).sendFailureSignatureUnsupported,
+ SendProcessFailureReason.signatureCancelled => S.of(context).sendFailureSignatureCancelled,
+ SendProcessFailureReason.gasFundingUnavailable => S.of(context).sendFailureGasUnavailable,
+ SendProcessFailureReason.invalidRequest => S.of(context).sendFailureInvalidRequest,
+ SendProcessFailureReason.generic => S.of(context).sendFailureGeneric,
+ };
+
+ Future _showResultSheet(
+ BuildContext context, {
+ required IconData icon,
+ required String title,
+ required String description,
+ }) async {
+ await showModalBottomSheet(
+ context: context,
+ isDismissible: false,
+ builder: (_) => SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ spacing: 24,
+ children: [
+ Icon(icon, color: RealUnitColors.realUnitBlue, size: 64),
+ Text(title, style: Theme.of(context).textTheme.headlineMedium),
+ Text(
+ description,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: RealUnitColors.neutral500,
+ ),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: Text(S.of(context).close),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ if (context.mounted) Navigator.of(context).pop();
+ }
+}
diff --git a/lib/screens/send/send_recipient_page.dart b/lib/screens/send/send_recipient_page.dart
new file mode 100644
index 000000000..989e424ee
--- /dev/null
+++ b/lib/screens/send/send_recipient_page.dart
@@ -0,0 +1,114 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/screens/send/cubits/send_recipient/send_recipient_cubit.dart';
+import 'package:realunit_wallet/screens/send/send_amount_page.dart';
+import 'package:realunit_wallet/styles/colors.dart';
+import 'package:realunit_wallet/widgets/scanner/qr_scanner_view.dart';
+
+/// First step of the wallet-to-wallet send flow: pick the recipient by scanning
+/// a wallet QR or pasting/typing the address. Reuses the shared
+/// [QrScannerView] (same camera wrapper the OCP pay flow uses) so the scanner is
+/// not duplicated; the EVM-address decode lives in [SendRecipientCubit].
+class SendRecipientPage extends StatelessWidget {
+ const SendRecipientPage({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocProvider(
+ create: (_) => SendRecipientCubit(),
+ child: const SendRecipientView(),
+ );
+ }
+}
+
+class SendRecipientView extends StatefulWidget {
+ const SendRecipientView({super.key});
+
+ @override
+ State createState() => _SendRecipientViewState();
+}
+
+class _SendRecipientViewState extends State {
+ final _controller = TextEditingController();
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocListener(
+ listener: (context, state) {
+ if (state is SendRecipientValid) {
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => SendAmountPage(recipient: state.address),
+ ),
+ );
+ context.read().reset();
+ }
+ if (state is SendRecipientInvalid) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(S.of(context).sendRecipientInvalid),
+ backgroundColor: RealUnitColors.status.red600,
+ ),
+ );
+ }
+ },
+ child: Scaffold(
+ appBar: AppBar(title: Text(S.of(context).sendRecipientTitle)),
+ body: SafeArea(
+ child: Column(
+ children: [
+ Expanded(
+ child: QrScannerView(
+ onDetect: (raw) => context.read().onCodeDetected(raw),
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ spacing: 12,
+ children: [
+ Text(
+ S.of(context).sendRecipientManualHint,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: RealUnitColors.neutral500,
+ ),
+ ),
+ TextField(
+ controller: _controller,
+ autocorrect: false,
+ decoration: InputDecoration(
+ labelText: S.of(context).sendRecipientLabel,
+ suffixIcon: IconButton(
+ icon: const Icon(Icons.paste_rounded),
+ tooltip: S.of(context).sendPaste,
+ onPressed: () async {
+ final data = await Clipboard.getData(Clipboard.kTextPlain);
+ final text = data?.text;
+ if (text != null) _controller.text = text.trim();
+ },
+ ),
+ ),
+ ),
+ FilledButton(
+ onPressed: () => context.read().submit(_controller.text),
+ child: Text(S.of(context).next),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/setup/di.dart b/lib/setup/di.dart
index fa7b20f12..e553209c8 100644
--- a/lib/setup/di.dart
+++ b/lib/setup/di.dart
@@ -29,9 +29,11 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_support_service.dart';
import 'package:realunit_wallet/packages/service/dfx/dfx_widget_service.dart';
import 'package:realunit_wallet/packages/service/dfx/real_unit_account_service.dart';
import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart';
import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart';
import 'package:realunit_wallet/packages/service/dfx/real_unit_sell_payment_info_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart';
import 'package:realunit_wallet/packages/service/session_cache.dart';
import 'package:realunit_wallet/packages/service/settings_service.dart';
import 'package:realunit_wallet/packages/service/transaction_history_service.dart';
@@ -174,6 +176,9 @@ void setupServices() {
getIt.registerFactory(
() => RealUnitBuyPaymentInfoService(getIt(), getIt()),
);
+ getIt.registerFactory(
+ () => RealUnitPayService(getIt(), getIt()),
+ );
getIt.registerFactory(
() => RealUnitPdfService(getIt(), getIt()),
);
@@ -183,6 +188,9 @@ void setupServices() {
getIt.registerFactory(
() => RealUnitSellPaymentInfoService(getIt(), getIt()),
);
+ getIt.registerFactory(
+ () => RealUnitTransferService(getIt(), getIt()),
+ );
getIt.registerFactory(() => SettingsService(getIt()));
getIt.registerFactory(
() => DebugAuthService(getIt(), getIt()),
diff --git a/lib/setup/routing/router_config.dart b/lib/setup/routing/router_config.dart
index 3d0b9e263..d56aef7ee 100644
--- a/lib/setup/routing/router_config.dart
+++ b/lib/setup/routing/router_config.dart
@@ -11,6 +11,7 @@ import 'package:realunit_wallet/screens/kyc/kyc_page_manager.dart';
import 'package:realunit_wallet/screens/legal/legal_disclaimer_page.dart';
import 'package:realunit_wallet/screens/legal/subpages/legal_document_page.dart';
import 'package:realunit_wallet/screens/onboarding/onboarding_completed_page.dart';
+import 'package:realunit_wallet/screens/pay/pay_scan_page.dart';
import 'package:realunit_wallet/screens/pin/setup_pin_page.dart';
import 'package:realunit_wallet/screens/pin/verify_pin_page.dart';
import 'package:realunit_wallet/screens/receive/receive_page.dart';
@@ -18,6 +19,7 @@ import 'package:realunit_wallet/screens/restore_wallet/restore_wallet_page.dart'
import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/sell_payment_info.dart';
import 'package:realunit_wallet/screens/sell/sell_page.dart';
import 'package:realunit_wallet/screens/sell_bitbox/sell_bitbox_page.dart';
+import 'package:realunit_wallet/screens/send/send_recipient_page.dart';
import 'package:realunit_wallet/screens/settings/settings_page.dart';
import 'package:realunit_wallet/screens/settings_contact/settings_contact_page.dart';
import 'package:realunit_wallet/screens/settings_currencies/settings_currencies_page.dart';
@@ -150,6 +152,18 @@ final GoRouter routerConfig = GoRouter(
builder: (_, state) => SellBitboxPage(paymentInfo: state.extra as SellPaymentInfo),
),
+ GoRoute(
+ name: AppRoutes.pay,
+ path: '/pay',
+ builder: (_, _) => const PayScanPage(),
+ ),
+
+ GoRoute(
+ name: AppRoutes.send,
+ path: '/send',
+ builder: (_, _) => const SendRecipientPage(),
+ ),
+
GoRoute(
name: LegalRoutes.disclaimer,
path: '/legalDisclaimer',
diff --git a/lib/setup/routing/routes/app_routes.dart b/lib/setup/routing/routes/app_routes.dart
index 1ec2a07a9..bb7491a48 100644
--- a/lib/setup/routing/routes/app_routes.dart
+++ b/lib/setup/routing/routes/app_routes.dart
@@ -5,6 +5,8 @@ abstract final class AppRoutes {
static const buy = 'buy';
static const sell = 'sell';
static const sellBitbox = 'sellBitbox';
+ static const pay = 'pay';
+ static const send = 'send';
static const kyc = 'kyc';
static const receive = 'receive';
diff --git a/lib/widgets/scanner/qr_scanner_view.dart b/lib/widgets/scanner/qr_scanner_view.dart
new file mode 100644
index 000000000..9bf6020b6
--- /dev/null
+++ b/lib/widgets/scanner/qr_scanner_view.dart
@@ -0,0 +1,30 @@
+// @no-integration-test: the QR scanner is camera/MethodChannel-coupled
+// (mobile_scanner) and can only be exercised on a real device with a live
+// camera. This widget is the shared camera-preview wrapper reused by the OCP
+// pay flow (LNURL decode) and the wallet-to-wallet send flow (EVM address
+// decode); the per-flow decode logic it feeds is unit-tested in the respective
+// cubit tests.
+import 'package:flutter/widgets.dart';
+import 'package:mobile_scanner/mobile_scanner.dart';
+
+/// Thin wrapper around [MobileScanner] that surfaces the first raw barcode
+/// value of each capture via [onDetect]. Keeping the camera/MethodChannel
+/// wiring in one widget lets every flow reuse the scanner without duplicating
+/// it — each flow decides what the scanned payload means in its own cubit.
+class QrScannerView extends StatelessWidget {
+ /// Invoked with the raw string value of the first detected barcode in a
+ /// capture. Null raw values are filtered out before this is called.
+ final ValueChanged onDetect;
+
+ const QrScannerView({super.key, required this.onDetect});
+
+ @override
+ Widget build(BuildContext context) {
+ return MobileScanner(
+ onDetect: (capture) {
+ final raw = capture.barcodes.firstOrNull?.rawValue;
+ if (raw != null) onDetect(raw);
+ },
+ );
+ }
+}
diff --git a/pubspec.lock b/pubspec.lock
index bf626bd73..ef7333d8a 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -951,6 +951,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
+ mobile_scanner:
+ dependency: "direct main"
+ description:
+ name: mobile_scanner
+ sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.2.3"
mocktail:
dependency: "direct dev"
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 9877d9640..cf0a2b4dd 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -65,6 +65,7 @@ dependencies:
http: ^1.1.0
intl: any
local_auth: ^3.0.0
+ mobile_scanner: ^5.2.3
no_screenshot: ^1.1.0
open_file: ^3.5.11
path: ^1.9.0
diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png
index 02631d184..61341c27b 100644
Binary files a/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png and b/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png differ
diff --git a/test/goldens/screens/home/goldens/macos/home_page_loaded.png b/test/goldens/screens/home/goldens/macos/home_page_loaded.png
index 84de91247..309a55088 100644
Binary files a/test/goldens/screens/home/goldens/macos/home_page_loaded.png and b/test/goldens/screens/home/goldens/macos/home_page_loaded.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_process_page_awaiting_settlement.png b/test/goldens/screens/pay/goldens/macos/pay_process_page_awaiting_settlement.png
new file mode 100644
index 000000000..f180456ff
Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_process_page_awaiting_settlement.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_process_page_pay_retry.png b/test/goldens/screens/pay/goldens/macos/pay_process_page_pay_retry.png
new file mode 100644
index 000000000..9c782538d
Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_process_page_pay_retry.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_process_page_swapping.png b/test/goldens/screens/pay/goldens/macos/pay_process_page_swapping.png
new file mode 100644
index 000000000..18c71c3ad
Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_process_page_swapping.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_quote_page_expired.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_expired.png
new file mode 100644
index 000000000..0ce937c0b
Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_quote_page_expired.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_quote_page_loading.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_loading.png
new file mode 100644
index 000000000..66fa82dc2
Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_quote_page_loading.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png
new file mode 100644
index 000000000..c3ed67d3c
Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_quote_page_unsupported_environment.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_unsupported_environment.png
new file mode 100644
index 000000000..4c79db848
Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_quote_page_unsupported_environment.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_scan_page_scanning.png b/test/goldens/screens/pay/goldens/macos/pay_scan_page_scanning.png
new file mode 100644
index 000000000..5f249d68a
Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_scan_page_scanning.png differ
diff --git a/test/goldens/screens/pay/pay_process_golden_test.dart b/test/goldens/screens/pay/pay_process_golden_test.dart
new file mode 100644
index 000000000..80f8d4f30
--- /dev/null
+++ b/test/goldens/screens/pay/pay_process_golden_test.dart
@@ -0,0 +1,78 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart';
+import 'package:realunit_wallet/screens/pay/pay_process_page.dart';
+
+import '../../../helper/helper.dart';
+
+class _MockPayProcessCubit extends MockCubit implements PayProcessCubit {}
+
+void main() {
+ late _MockPayProcessCubit processCubit;
+
+ setUp(() {
+ processCubit = _MockPayProcessCubit();
+ when(() => processCubit.state).thenReturn(const PayProcessInitial());
+ });
+
+ // PayProcessPage resolves its cubit from getIt and calls start(); the golden
+ // renders PayProcessView directly with a mocked cubit. Terminal states
+ // (success/failure/retry) are surfaced via modal sheets from the listener,
+ // not the build tree — exercised in the widget test. The build tree shows the
+ // in-progress indicator with a per-state label, captured here.
+ group('$PayProcessView', () {
+ goldenTest(
+ 'in-progress swapping state',
+ fileName: 'pay_process_page_swapping',
+ constraints: phoneConstraints,
+ // The CupertinoActivityIndicator animates forever, so pumpAndSettle would
+ // time out; pumpOnce captures the first frame.
+ pumpBeforeTest: pumpOnce,
+ builder: () {
+ when(() => processCubit.state).thenReturn(const PayProcessSwapping());
+ return wrapForGolden(
+ BlocProvider.value(
+ value: processCubit,
+ child: const PayProcessView(),
+ ),
+ );
+ },
+ );
+
+ goldenTest(
+ 'awaiting settlement state',
+ fileName: 'pay_process_page_awaiting_settlement',
+ constraints: phoneConstraints,
+ pumpBeforeTest: pumpOnce,
+ builder: () {
+ when(() => processCubit.state).thenReturn(const PayProcessAwaitingSettlement('0xtx'));
+ return wrapForGolden(
+ BlocProvider.value(
+ value: processCubit,
+ child: const PayProcessView(),
+ ),
+ );
+ },
+ );
+
+ goldenTest(
+ 'pay-retry state label',
+ fileName: 'pay_process_page_pay_retry',
+ constraints: phoneConstraints,
+ pumpBeforeTest: pumpOnce,
+ builder: () {
+ when(
+ () => processCubit.state,
+ ).thenReturn(const PayProcessPayRetry(PayRetryReason.quoteExpired));
+ return wrapForGolden(
+ BlocProvider.value(
+ value: processCubit,
+ child: const PayProcessView(),
+ ),
+ );
+ },
+ );
+ });
+}
diff --git a/test/goldens/screens/pay/pay_quote_golden_test.dart b/test/goldens/screens/pay/pay_quote_golden_test.dart
new file mode 100644
index 000000000..f0172da37
--- /dev/null
+++ b/test/goldens/screens/pay/pay_quote_golden_test.dart
@@ -0,0 +1,92 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart';
+import 'package:realunit_wallet/screens/pay/pay_quote_page.dart';
+
+import '../../../helper/helper.dart';
+
+class _MockPayQuoteCubit extends MockCubit implements PayQuoteCubit {}
+
+void main() {
+ late _MockPayQuoteCubit quoteCubit;
+
+ setUp(() {
+ quoteCubit = _MockPayQuoteCubit();
+ when(() => quoteCubit.state).thenReturn(const PayQuoteLoading());
+ });
+
+ // PayQuotePage resolves its cubit from getIt and calls load(); the golden
+ // renders PayQuoteView directly with a mocked cubit so every state is
+ // deterministic without the service/DI graph.
+ group('$PayQuoteView', () {
+ goldenTest(
+ 'loading state',
+ fileName: 'pay_quote_page_loading',
+ constraints: phoneConstraints,
+ // The CupertinoActivityIndicator animates forever, so pumpAndSettle
+ // would time out; pumpOnce captures the first frame.
+ pumpBeforeTest: pumpOnce,
+ builder: () => wrapForGolden(
+ BlocProvider.value(
+ value: quoteCubit,
+ child: const PayQuoteView(),
+ ),
+ ),
+ );
+
+ goldenTest(
+ 'ready quote with CHF amount and ZCHF needed',
+ fileName: 'pay_quote_page_ready',
+ constraints: phoneConstraints,
+ builder: () {
+ when(() => quoteCubit.state).thenReturn(
+ const PayQuoteReady(
+ paymentLinkId: 'pl_abc',
+ quoteId: 'quote_xyz',
+ fiatAsset: 'CHF',
+ fiatAmount: 42.5,
+ zchfAmount: 42.7,
+ ),
+ );
+ return wrapForGolden(
+ BlocProvider.value(
+ value: quoteCubit,
+ child: const PayQuoteView(),
+ ),
+ );
+ },
+ );
+
+ goldenTest(
+ 'unsupported environment message',
+ fileName: 'pay_quote_page_unsupported_environment',
+ constraints: phoneConstraints,
+ builder: () {
+ when(() => quoteCubit.state).thenReturn(const PayQuoteUnsupportedEnvironment());
+ return wrapForGolden(
+ BlocProvider.value(
+ value: quoteCubit,
+ child: const PayQuoteView(),
+ ),
+ );
+ },
+ );
+
+ goldenTest(
+ 'expired quote message',
+ fileName: 'pay_quote_page_expired',
+ constraints: phoneConstraints,
+ builder: () {
+ when(() => quoteCubit.state).thenReturn(const PayQuoteExpired());
+ return wrapForGolden(
+ BlocProvider.value(
+ value: quoteCubit,
+ child: const PayQuoteView(),
+ ),
+ );
+ },
+ );
+ });
+}
diff --git a/test/goldens/screens/pay/pay_scan_golden_test.dart b/test/goldens/screens/pay/pay_scan_golden_test.dart
new file mode 100644
index 000000000..dacdb4306
--- /dev/null
+++ b/test/goldens/screens/pay/pay_scan_golden_test.dart
@@ -0,0 +1,45 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart';
+import 'package:realunit_wallet/screens/pay/pay_scan_page.dart';
+
+import '../../../helper/helper.dart';
+
+class _MockPayScanCubit extends MockCubit implements PayScanCubit {}
+
+void main() {
+ late _MockPayScanCubit scanCubit;
+
+ setUpAll(() {
+ // The QR scanner is camera-coupled (mobile_scanner). The stub answers the
+ // permission handshake so the preview settles into its deterministic
+ // placeholder state instead of throwing MissingPluginException — the live
+ // camera carries the `@no-integration-test` note on pay_scan_page.dart.
+ stubMobileScannerChannel();
+ });
+
+ setUp(() {
+ scanCubit = _MockPayScanCubit();
+ when(() => scanCubit.state).thenReturn(const PayScanScanning());
+ });
+
+ group('$PayScanView', () {
+ goldenTest(
+ 'scanning state with camera preview placeholder',
+ fileName: 'pay_scan_page_scanning',
+ constraints: phoneConstraints,
+ // The camera preview never reaches an `isInitialized` frame headlessly,
+ // so pumpAndSettle (default in precacheImages) would await a settle that
+ // never comes. pumpOnce captures the deterministic placeholder frame.
+ pumpBeforeTest: pumpOnce,
+ builder: () => wrapForGolden(
+ BlocProvider.value(
+ value: scanCubit,
+ child: const PayScanView(),
+ ),
+ ),
+ );
+ });
+}
diff --git a/test/goldens/screens/send/goldens/macos/send_amount_page_empty.png b/test/goldens/screens/send/goldens/macos/send_amount_page_empty.png
new file mode 100644
index 000000000..caeb2d35b
Binary files /dev/null and b/test/goldens/screens/send/goldens/macos/send_amount_page_empty.png differ
diff --git a/test/goldens/screens/send/goldens/macos/send_amount_page_insufficient.png b/test/goldens/screens/send/goldens/macos/send_amount_page_insufficient.png
new file mode 100644
index 000000000..cfefba140
Binary files /dev/null and b/test/goldens/screens/send/goldens/macos/send_amount_page_insufficient.png differ
diff --git a/test/goldens/screens/send/goldens/macos/send_confirm_page.png b/test/goldens/screens/send/goldens/macos/send_confirm_page.png
new file mode 100644
index 000000000..25a4df850
Binary files /dev/null and b/test/goldens/screens/send/goldens/macos/send_confirm_page.png differ
diff --git a/test/goldens/screens/send/goldens/macos/send_process_page_signing.png b/test/goldens/screens/send/goldens/macos/send_process_page_signing.png
new file mode 100644
index 000000000..fc553f688
Binary files /dev/null and b/test/goldens/screens/send/goldens/macos/send_process_page_signing.png differ
diff --git a/test/goldens/screens/send/goldens/macos/send_recipient_page_empty.png b/test/goldens/screens/send/goldens/macos/send_recipient_page_empty.png
new file mode 100644
index 000000000..c22791cb5
Binary files /dev/null and b/test/goldens/screens/send/goldens/macos/send_recipient_page_empty.png differ
diff --git a/test/goldens/screens/send/send_golden_test.dart b/test/goldens/screens/send/send_golden_test.dart
new file mode 100644
index 000000000..71c63cbff
--- /dev/null
+++ b/test/goldens/screens/send/send_golden_test.dart
@@ -0,0 +1,161 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/models/balance.dart';
+import 'package:realunit_wallet/packages/utils/default_assets.dart';
+import 'package:realunit_wallet/screens/sell/cubits/sell_balance/sell_balance_cubit.dart';
+import 'package:realunit_wallet/screens/send/cubits/send_amount/send_amount_cubit.dart';
+import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart';
+import 'package:realunit_wallet/screens/send/cubits/send_recipient/send_recipient_cubit.dart';
+import 'package:realunit_wallet/screens/send/send_amount_page.dart';
+import 'package:realunit_wallet/screens/send/send_confirm_page.dart';
+import 'package:realunit_wallet/screens/send/send_process_page.dart';
+import 'package:realunit_wallet/screens/send/send_recipient_page.dart';
+
+import '../../../helper/helper.dart';
+
+class _MockSendRecipientCubit extends MockCubit implements SendRecipientCubit {}
+
+class _MockSellBalanceCubit extends MockCubit implements SellBalanceCubit {}
+
+class _MockSendAmountCubit extends MockCubit implements SendAmountCubit {}
+
+class _MockSendProcessCubit extends MockCubit implements SendProcessCubit {}
+
+Balance _balance(int shares) => Balance(
+ chainId: realUnitAsset.chainId,
+ contractAddress: realUnitAsset.address,
+ walletAddress: '0xwallet',
+ balance: BigInt.from(shares),
+ asset: realUnitAsset,
+);
+
+void main() {
+ setUpAll(() {
+ registerFallbackValue(BigInt.zero);
+ stubMobileScannerChannel();
+ });
+
+ group('$SendRecipientView', () {
+ late _MockSendRecipientCubit recipientCubit;
+
+ setUp(() {
+ recipientCubit = _MockSendRecipientCubit();
+ when(() => recipientCubit.state).thenReturn(const SendRecipientEmpty());
+ });
+
+ goldenTest(
+ 'scan + manual-entry state',
+ fileName: 'send_recipient_page_empty',
+ constraints: phoneConstraints,
+ // The camera preview never reaches an isInitialized frame headlessly, so
+ // pumpAndSettle would await a settle that never comes. pumpOnce captures
+ // the deterministic placeholder frame.
+ pumpBeforeTest: pumpOnce,
+ builder: () => wrapForGolden(
+ BlocProvider.value(
+ value: recipientCubit,
+ child: const SendRecipientView(),
+ ),
+ ),
+ );
+ });
+
+ group('$SendAmountView', () {
+ late _MockSellBalanceCubit balanceCubit;
+ late _MockSendAmountCubit amountCubit;
+
+ setUp(() {
+ balanceCubit = _MockSellBalanceCubit();
+ amountCubit = _MockSendAmountCubit();
+ when(() => balanceCubit.state).thenReturn(_balance(42));
+ when(() => amountCubit.availableShares).thenReturn(BigInt.from(42));
+ when(() => amountCubit.availableSharesChanged(any())).thenReturn(null);
+ when(() => amountCubit.state).thenReturn(const SendAmountState());
+ });
+
+ goldenTest(
+ 'amount entry with available balance',
+ fileName: 'send_amount_page_empty',
+ constraints: phoneConstraints,
+ builder: () => wrapForGolden(
+ MultiBlocProvider(
+ providers: [
+ BlocProvider.value(value: balanceCubit),
+ BlocProvider.value(value: amountCubit),
+ ],
+ child: const SendAmountView(recipient: '0xRecipient'),
+ ),
+ ),
+ );
+
+ goldenTest(
+ 'over-balance amount shows the insufficient error',
+ fileName: 'send_amount_page_insufficient',
+ constraints: phoneConstraints,
+ builder: () {
+ when(() => amountCubit.state).thenReturn(
+ const SendAmountState(
+ text: '99',
+ amount: 99,
+ status: SendAmountStatus.insufficientBalance,
+ ),
+ );
+ return wrapForGolden(
+ MultiBlocProvider(
+ providers: [
+ BlocProvider.value(value: balanceCubit),
+ BlocProvider.value(value: amountCubit),
+ ],
+ child: const SendAmountView(recipient: '0xRecipient'),
+ ),
+ );
+ },
+ );
+ });
+
+ group('$SendConfirmPage', () {
+ goldenTest(
+ 'transfer summary',
+ fileName: 'send_confirm_page',
+ constraints: phoneConstraints,
+ builder: () => wrapForGolden(
+ const SendConfirmPage(
+ recipient: '0x9F5713DEacB8e9CAB6c2d3FaE1AFc2715F8D2D71',
+ amount: 5,
+ ),
+ ),
+ );
+ });
+
+ group('$SendProcessView', () {
+ late _MockSendProcessCubit processCubit;
+
+ setUp(() {
+ processCubit = _MockSendProcessCubit();
+ when(() => processCubit.state).thenReturn(const SendProcessInitial());
+ });
+
+ // Terminal states (success/failure) are surfaced via a modal sheet from the
+ // listener, not the build tree — exercised in the widget test. The build
+ // tree shows the in-progress indicator with a per-state label.
+ goldenTest(
+ 'in-progress signing state',
+ fileName: 'send_process_page_signing',
+ constraints: phoneConstraints,
+ // The CupertinoActivityIndicator animates forever; pumpOnce captures the
+ // first frame.
+ pumpBeforeTest: pumpOnce,
+ builder: () {
+ when(() => processCubit.state).thenReturn(const SendProcessSigning());
+ return wrapForGolden(
+ BlocProvider.value(
+ value: processCubit,
+ child: const SendProcessView(),
+ ),
+ );
+ },
+ );
+ });
+}
diff --git a/test/helper/golden_plugin_stubs.dart b/test/helper/golden_plugin_stubs.dart
index c1e4e2f54..22754bc89 100644
--- a/test/helper/golden_plugin_stubs.dart
+++ b/test/helper/golden_plugin_stubs.dart
@@ -7,9 +7,51 @@ import 'package:flutter_test/flutter_test.dart';
///
/// Call from `setUpAll`.
void stubNoScreenshotChannel() {
- TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
- .setMockMethodCallHandler(
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('com.flutterplaza.no_screenshot_methods'),
(call) async => true,
);
}
+
+/// Stub the `mobile_scanner` plugin's method + event channels so the camera
+/// preview renders a deterministic state in headless widget/golden tests.
+///
+/// The QR scanner is camera/MethodChannel-coupled — the live preview has no
+/// headless representation and `MobileScanner.initState` fires
+/// `controller.start()` against the platform channel. This stub answers the
+/// permission handshake (`state` → undetermined, `request` → not granted) so
+/// `MobileScannerController.start()` settles into its permission-denied error
+/// state. The widget then paints its default error placeholder (a black
+/// `ColoredBox` with a centered error icon) instead of throwing
+/// `MissingPluginException` — a stable, deterministic preview-placeholder
+/// state that mirrors the `@no-integration-test` note on `pay_scan_page.dart`
+/// (the live camera is exercised only on a real device).
+///
+/// Call from `setUpAll`.
+void stubMobileScannerChannel() {
+ final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
+ messenger.setMockMethodCallHandler(
+ const MethodChannel('dev.steenbakker.mobile_scanner/scanner/method'),
+ (call) async {
+ switch (call.method) {
+ // Camera authorization is undetermined …
+ case 'state':
+ return 0;
+ // … and the follow-up request is not granted, so start() settles into
+ // the permission-denied placeholder without touching a real camera.
+ case 'request':
+ return false;
+ default:
+ return null;
+ }
+ },
+ );
+ // PayScanView wires an onDetect callback, so MobileScanner subscribes to the
+ // controller's barcode stream (the event channel) in initState. Install a
+ // no-op stream handler that never emits, so the `listen` does not throw
+ // MissingPluginException and no synthetic barcode ever fires.
+ messenger.setMockStreamHandler(
+ const EventChannel('dev.steenbakker.mobile_scanner/scanner/event'),
+ MockStreamHandler.inline(onListen: (arguments, sink) {}),
+ );
+}
diff --git a/test/packages/service/dfx/exceptions/exception_surface_test.dart b/test/packages/service/dfx/exceptions/exception_surface_test.dart
index d187e5a66..2c6a5cc8e 100644
--- a/test/packages/service/dfx/exceptions/exception_surface_test.dart
+++ b/test/packages/service/dfx/exceptions/exception_surface_test.dart
@@ -2,6 +2,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart';
import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart';
// Guard against a recurring failure mode: an Exception subclass without a
@@ -24,6 +26,12 @@ void main() {
requiredLevel: 1,
currentLevel: 0,
),
+ const InvalidPaymentLinkException('test'),
+ const PayUnsupportedEnvironmentException(),
+ const PaySignatureUnsupportedException(),
+ const InvalidRecipientAddressException('test'),
+ const TransferSignatureUnsupportedException(),
+ const TransferGasFundingUnavailableException(),
];
for (final ex in exceptions) {
diff --git a/test/packages/service/dfx/lnurl_decoder_test.dart b/test/packages/service/dfx/lnurl_decoder_test.dart
new file mode 100644
index 000000000..bd660bf64
--- /dev/null
+++ b/test/packages/service/dfx/lnurl_decoder_test.dart
@@ -0,0 +1,110 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart';
+import 'package:realunit_wallet/packages/service/dfx/lnurl_decoder.dart';
+
+void main() {
+ group('LnurlDecoder.decode', () {
+ // LUD-01 bech32 of `https://api.dfx.swiss/v1/lnurlp/pl_abc123`.
+ const lnurl = 'LNURL1DP68GURN8GHJ7CTSDYHXGENC9EEHW6TNWVHHVVF0D3H82UNVWQHHQMZLV93XXVFJXV5T0E5A';
+
+ test('decodes a bech32 LNURL to the api lnurlp url + id', () {
+ final result = LnurlDecoder.decode(lnurl);
+
+ expect(result.lnurlpUrl.toString(), 'https://api.dfx.swiss/v1/lnurlp/pl_abc123');
+ expect(result.id, 'pl_abc123');
+ });
+
+ test('decodes the lightning= query param of an app.dfx.swiss wrapper URL', () {
+ final result = LnurlDecoder.decode('https://app.dfx.swiss/pl/?lightning=$lnurl');
+
+ expect(result.lnurlpUrl.host, 'api.dfx.swiss');
+ expect(result.id, 'pl_abc123');
+ });
+
+ test('accepts a lowercase lnurl and a lightning: scheme prefix', () {
+ final result = LnurlDecoder.decode('lightning:${lnurl.toLowerCase()}');
+
+ expect(result.id, 'pl_abc123');
+ });
+
+ test('rewrites a plain app.dfx.swiss lnurlp url to the api host', () {
+ final result = LnurlDecoder.decode('https://app.dfx.swiss/v1/lnurlp/pl_xyz');
+
+ expect(result.lnurlpUrl.toString(), 'https://api.dfx.swiss/v1/lnurlp/pl_xyz');
+ expect(result.id, 'pl_xyz');
+ });
+
+ test('keeps an already-api lnurlp url and forces https', () {
+ final result = LnurlDecoder.decode('http://api.dfx.swiss/v1/lnurlp/plp_123');
+
+ expect(result.lnurlpUrl.scheme, 'https');
+ expect(result.id, 'plp_123');
+ });
+
+ test('rewrites the dev testnet host twin', () {
+ final result = LnurlDecoder.decode('https://dev.app.dfx.swiss/v1/lnurlp/pl_dev');
+
+ expect(result.lnurlpUrl.host, 'dev.api.dfx.swiss');
+ expect(result.id, 'pl_dev');
+ });
+
+ test('rejects an empty code', () {
+ expect(
+ () => LnurlDecoder.decode(' '),
+ throwsA(isA()),
+ );
+ });
+
+ test('rejects a non-DFX host', () {
+ expect(
+ () => LnurlDecoder.decode('https://evil.example.com/v1/lnurlp/pl_x'),
+ throwsA(isA()),
+ );
+ });
+
+ test('rejects a non-http payload', () {
+ expect(
+ () => LnurlDecoder.decode('not-a-url'),
+ throwsA(isA()),
+ );
+ });
+
+ test('rejects a bech32 with an invalid checksum', () {
+ // Flip the last data character to break the checksum.
+ final broken = '${lnurl.substring(0, lnurl.length - 1)}Q';
+ expect(
+ () => LnurlDecoder.decode(broken),
+ throwsA(isA()),
+ );
+ });
+
+ test('rejects a bech32 with an invalid character in the data part', () {
+ // Replace a data char with 'b' (not in the bech32 charset) while keeping
+ // the overall length valid, so the per-character guard fires.
+ final withBadChar = '${lnurl.substring(0, 20)}B${lnurl.substring(21)}';
+ expect(
+ () => LnurlDecoder.decode(withBadChar),
+ throwsA(isA()),
+ );
+ });
+
+ test('rejects a too-short bech32', () {
+ expect(
+ () => LnurlDecoder.decode('LNURL1bbb'),
+ throwsA(isA()),
+ );
+ });
+
+ test('falls back to the last path segment when there is no lnurlp segment', () {
+ final result = LnurlDecoder.decode('https://api.dfx.swiss/pl_direct');
+ expect(result.id, 'pl_direct');
+ });
+
+ test('rejects a DFX url with an empty path', () {
+ expect(
+ () => LnurlDecoder.decode('https://api.dfx.swiss/'),
+ throwsA(isA()),
+ );
+ });
+ });
+}
diff --git a/test/packages/service/dfx/models/payment/pay/pay_dtos_test.dart b/test/packages/service/dfx/models/payment/pay/pay_dtos_test.dart
new file mode 100644
index 000000000..eab27d41a
--- /dev/null
+++ b/test/packages/service/dfx/models/payment/pay/pay_dtos_test.dart
@@ -0,0 +1,305 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart';
+
+void main() {
+ group('RealUnitSwapDto', () {
+ test('fromTargetAmount serialises only targetAmount (no amount key)', () {
+ expect(
+ const RealUnitSwapDto.fromTargetAmount(95.5).toJson(),
+ {'targetAmount': 95.5},
+ );
+ });
+ });
+
+ group('RealUnitSwapPaymentInfoDto.fromJson', () {
+ test('maps every field with no dynamic access', () {
+ final dto = RealUnitSwapPaymentInfoDto.fromJson({
+ 'id': 99,
+ 'uid': 'MOCK-UID',
+ 'routeId': 7,
+ 'timestamp': '2026-06-03T00:00:00.000Z',
+ 'amount': 10,
+ 'estimatedAmount': 960,
+ 'targetAsset': 'ZCHF',
+ 'fees': {'dfx': 1, 'network': 0.5, 'total': 1.5},
+ 'minVolume': 1,
+ 'maxVolume': 1000,
+ 'minVolumeTarget': 95,
+ 'maxVolumeTarget': 95000,
+ 'ethBalance': 1.0,
+ 'requiredGasEth': 0.001,
+ 'isValid': true,
+ });
+
+ expect(dto.id, 99);
+ expect(dto.uid, 'MOCK-UID');
+ expect(dto.routeId, 7);
+ expect(dto.targetAsset, 'ZCHF');
+ expect(dto.estimatedAmount, 960);
+ expect(dto.minVolumeTarget, 95);
+ expect(dto.isValid, isTrue);
+ expect(dto.error, isNull);
+ });
+
+ test('maps the error code when isValid is false', () {
+ final dto = RealUnitSwapPaymentInfoDto.fromJson({
+ 'id': 1,
+ 'uid': 'u',
+ 'routeId': 1,
+ 'timestamp': '2026-06-03T00:00:00.000Z',
+ 'amount': 1,
+ 'estimatedAmount': 1,
+ 'targetAsset': 'ZCHF',
+ 'minVolume': 1,
+ 'maxVolume': 2,
+ 'minVolumeTarget': 1,
+ 'maxVolumeTarget': 2,
+ 'ethBalance': 0,
+ 'requiredGasEth': 0.001,
+ 'isValid': false,
+ 'error': 'LIMIT_EXCEEDED',
+ });
+
+ expect(dto.isValid, isFalse);
+ expect(dto.error, 'LIMIT_EXCEEDED');
+ });
+ });
+
+ test('SwapPaymentInfo.fromDto carries the swap-relevant fields', () {
+ final dto = RealUnitSwapPaymentInfoDto.fromJson({
+ 'id': 5,
+ 'uid': 'u',
+ 'routeId': 2,
+ 'timestamp': '2026-06-03T00:00:00.000Z',
+ 'amount': 10,
+ 'estimatedAmount': 960,
+ 'targetAsset': 'ZCHF',
+ 'minVolume': 1,
+ 'maxVolume': 1000,
+ 'minVolumeTarget': 95,
+ 'maxVolumeTarget': 95000,
+ 'ethBalance': 0.4,
+ 'requiredGasEth': 0.002,
+ 'isValid': true,
+ });
+
+ final info = SwapPaymentInfo.fromDto(dto);
+
+ expect(info.id, 5);
+ expect(info.estimatedAmount, 960);
+ expect(info.ethBalance, 0.4);
+ expect(info.requiredGasEth, 0.002);
+ expect(info.isValid, isTrue);
+ });
+
+ test('SwapPaymentInfo equality is value-based (Equatable props)', () {
+ const a = SwapPaymentInfo(
+ id: 1,
+ amount: 10,
+ estimatedAmount: 960,
+ targetAsset: 'ZCHF',
+ ethBalance: 1,
+ requiredGasEth: 0.001,
+ isValid: true,
+ );
+ const same = SwapPaymentInfo(
+ id: 1,
+ amount: 10,
+ estimatedAmount: 960,
+ targetAsset: 'ZCHF',
+ ethBalance: 1,
+ requiredGasEth: 0.001,
+ isValid: true,
+ );
+ const different = SwapPaymentInfo(
+ id: 2,
+ amount: 10,
+ estimatedAmount: 960,
+ targetAsset: 'ZCHF',
+ ethBalance: 1,
+ requiredGasEth: 0.001,
+ isValid: true,
+ error: 'LIMIT_EXCEEDED',
+ );
+
+ expect(a, equals(same));
+ expect(a, isNot(equals(different)));
+ });
+
+ test('RealUnitSwapUnsignedTransactionDto.fromJson', () {
+ final dto = RealUnitSwapUnsignedTransactionDto.fromJson({'swap': '0xswap'});
+ expect(dto.swap, '0xswap');
+ });
+
+ test('RealUnitOcpPayDto.toJson', () {
+ const dto = RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q1');
+ expect(dto.toJson(), {'paymentLinkId': 'pl_abc', 'quoteId': 'q1'});
+ });
+
+ test('RealUnitOcpPayUnsignedTransactionDto.fromJson', () {
+ final dto = RealUnitOcpPayUnsignedTransactionDto.fromJson({
+ 'unsignedTx': '0xtx',
+ 'tokenAddress': '0xzchf',
+ 'recipient': '0xrecipient',
+ 'amountWei': '5000000000000000000',
+ 'chainId': 1,
+ });
+
+ expect(dto.unsignedTx, '0xtx');
+ expect(dto.tokenAddress, '0xzchf');
+ expect(dto.recipient, '0xrecipient');
+ expect(dto.amountWei, '5000000000000000000');
+ expect(dto.chainId, 1);
+ });
+
+ test('RealUnitOcpPaySubmitDto.toJson carries the signed envelope + refs', () {
+ const dto = RealUnitOcpPaySubmitDto(
+ unsignedTx: '0xtx',
+ r: '0xr',
+ s: '0xs',
+ v: 27,
+ paymentLinkId: 'pl_abc',
+ quoteId: 'q1',
+ );
+
+ expect(dto.toJson(), {
+ 'unsignedTx': '0xtx',
+ 'r': '0xr',
+ 's': '0xs',
+ 'v': 27,
+ 'paymentLinkId': 'pl_abc',
+ 'quoteId': 'q1',
+ });
+ });
+
+ test('RealUnitOcpPayResultDto.fromJson', () {
+ expect(RealUnitOcpPayResultDto.fromJson({'txId': '0xTxId'}).txId, '0xTxId');
+ });
+
+ group('RealUnitOcpPayStatusDto.fromJson', () {
+ test('maps each known status', () {
+ expect(
+ RealUnitOcpPayStatusDto.fromJson({'status': 'Completed'}).status,
+ OcpPaymentStatus.completed,
+ );
+ expect(
+ RealUnitOcpPayStatusDto.fromJson({'status': 'Pending'}).status,
+ OcpPaymentStatus.pending,
+ );
+ expect(
+ RealUnitOcpPayStatusDto.fromJson({'status': 'Cancelled'}).status,
+ OcpPaymentStatus.cancelled,
+ );
+ expect(
+ RealUnitOcpPayStatusDto.fromJson({'status': 'Expired'}).status,
+ OcpPaymentStatus.expired,
+ );
+ });
+
+ test('falls back to unknown for an unmapped status', () {
+ expect(
+ RealUnitOcpPayStatusDto.fromJson({'status': 'Whatever'}).status,
+ OcpPaymentStatus.unknown,
+ );
+ });
+
+ test('isTerminal / isCompleted predicates', () {
+ expect(OcpPaymentStatus.completed.isTerminal, isTrue);
+ expect(OcpPaymentStatus.completed.isCompleted, isTrue);
+ expect(OcpPaymentStatus.cancelled.isTerminal, isTrue);
+ expect(OcpPaymentStatus.cancelled.isCompleted, isFalse);
+ expect(OcpPaymentStatus.expired.isTerminal, isTrue);
+ expect(OcpPaymentStatus.pending.isTerminal, isFalse);
+ expect(OcpPaymentStatus.unknown.isTerminal, isFalse);
+ });
+ });
+
+ group('LnurlpPaymentDto.fromJson', () {
+ test('maps requestedAmount, quote and ZCHF transfer amounts', () {
+ final dto = LnurlpPaymentDto.fromJson({
+ 'requestedAmount': {'asset': 'CHF', 'amount': 42.5},
+ 'quote': {'id': 'quote_xyz', 'expiration': '2026-06-03T12:00:00.000Z'},
+ 'transferAmounts': [
+ {
+ 'method': 'Ethereum',
+ 'assets': [
+ {'asset': 'ZCHF', 'amount': 42.7},
+ ],
+ },
+ {
+ 'method': 'Bitcoin',
+ 'assets': [
+ {'asset': 'BTC', 'amount': 0.0005},
+ ],
+ },
+ ],
+ });
+
+ expect(dto.requestedAmount.asset, 'CHF');
+ expect(dto.requestedAmount.amount, 42.5);
+ expect(dto.quote.id, 'quote_xyz');
+ expect(dto.transferAmounts, hasLength(2));
+ expect(dto.transferAmounts.first.method, 'Ethereum');
+ expect(dto.transferAmounts.first.assets.first.asset, 'ZCHF');
+ expect(dto.transferAmounts.first.assets.first.amount, 42.7);
+ });
+
+ test('tolerates a missing transferAmounts list', () {
+ final dto = LnurlpPaymentDto.fromJson({
+ 'requestedAmount': {'asset': 'CHF', 'amount': 1.0},
+ 'quote': {'id': 'q', 'expiration': '2026-06-03T12:00:00.000Z'},
+ });
+
+ expect(dto.transferAmounts, isEmpty);
+ });
+
+ test('parses amount-less asset entries (non-priced display path) as null', () {
+ final dto = LnurlpPaymentDto.fromJson({
+ 'requestedAmount': {'asset': 'CHF', 'amount': 1.0},
+ 'quote': {'id': 'q', 'expiration': '2026-06-03T12:00:00.000Z'},
+ 'transferAmounts': [
+ {
+ 'method': 'Ethereum',
+ 'assets': [
+ // Optional `amount?` omitted by the backend.
+ {'asset': 'ZCHF'},
+ ],
+ },
+ ],
+ });
+
+ expect(dto.transferAmounts.first.assets.first.asset, 'ZCHF');
+ expect(dto.transferAmounts.first.assets.first.amount, isNull);
+ });
+
+ test('ignores the structured recipient object instead of throwing on it', () {
+ // The backend `recipient` is a PaymentLinkRecipientDto object, not a
+ // String; reading the quote must not throw on it (the field is unused).
+ final dto = LnurlpPaymentDto.fromJson({
+ 'recipient': {'name': 'Acme GmbH', 'address': 'Bahnhofstrasse 1'},
+ 'requestedAmount': {'asset': 'CHF', 'amount': 42.5},
+ 'quote': {'id': 'quote_xyz', 'expiration': '2026-06-03T12:00:00.000Z'},
+ 'transferAmounts': [
+ {
+ 'method': 'Ethereum',
+ 'assets': [
+ {'asset': 'ZCHF', 'amount': 42.7},
+ ],
+ },
+ ],
+ });
+
+ expect(dto.quote.id, 'quote_xyz');
+ expect(dto.transferAmounts.first.assets.first.amount, 42.7);
+ });
+ });
+}
diff --git a/test/packages/service/dfx/models/payment/transfer/transfer_dtos_test.dart b/test/packages/service/dfx/models/payment/transfer/transfer_dtos_test.dart
new file mode 100644
index 000000000..444f6bd34
--- /dev/null
+++ b/test/packages/service/dfx/models/payment/transfer/transfer_dtos_test.dart
@@ -0,0 +1,118 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_eip7702_data_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart';
+
+Map _eip7702Json() => {
+ 'relayerAddress': '0xRelayer',
+ 'delegationManagerAddress': '0xManager',
+ 'delegatorAddress': '0xDelegator',
+ 'userNonce': 7,
+ 'domain': {
+ 'name': 'DelegationManager',
+ 'version': '1',
+ 'chainId': 11155111,
+ 'verifyingContract': '0xManager',
+ },
+ 'types': {
+ 'Delegation': [
+ {'name': 'delegate', 'type': 'address'},
+ ],
+ 'Caveat': [
+ {'name': 'enforcer', 'type': 'address'},
+ ],
+ },
+ 'message': {
+ 'delegate': '0xRelayer',
+ 'delegator': '0xSender',
+ 'authority': '0xRoot',
+ 'caveats': [],
+ 'salt': 3,
+ },
+ 'tokenAddress': '0xRealu',
+ 'amountWei': '5',
+ 'recipient': '0xRecipient',
+};
+
+void main() {
+ group('RealUnitTransferDto', () {
+ test('toJson carries toAddress + amount', () {
+ const dto = RealUnitTransferDto(toAddress: '0xRecipient', amount: 5);
+
+ expect(dto.toJson(), {'toAddress': '0xRecipient', 'amount': 5});
+ });
+ });
+
+ group('RealUnitTransferEip7702Data', () {
+ test('fromJson parses the recipient (transfer) shape', () {
+ final data = RealUnitTransferEip7702Data.fromJson(_eip7702Json());
+
+ expect(data.relayerAddress, '0xRelayer');
+ expect(data.delegatorAddress, '0xDelegator');
+ expect(data.userNonce, 7);
+ expect(data.domain.chainId, 11155111);
+ expect(data.types.delegation.first.name, 'delegate');
+ expect(data.types.caveat.first.type, 'address');
+ expect(data.message.delegator, '0xSender');
+ expect(data.message.salt, 3);
+ expect(data.tokenAddress, '0xRealu');
+ expect(data.amountWei, '5');
+ expect(data.recipient, '0xRecipient');
+ });
+
+ test('toEip7702Data maps recipient into the shared signer DTO depositAddress', () {
+ final data = RealUnitTransferEip7702Data.fromJson(_eip7702Json());
+
+ final shared = data.toEip7702Data();
+
+ // The recipient flows through depositAddress (the signers never read it),
+ // while every signed field is preserved verbatim.
+ expect(shared.depositAddress, '0xRecipient');
+ expect(shared.relayerAddress, '0xRelayer');
+ expect(shared.delegatorAddress, '0xDelegator');
+ expect(shared.userNonce, 7);
+ expect(shared.domain.chainId, 11155111);
+ expect(shared.message.delegator, '0xSender');
+ expect(shared.message.salt, 3);
+ expect(shared.tokenAddress, '0xRealu');
+ expect(shared.amountWei, '5');
+ });
+ });
+
+ group('RealUnitTransferPaymentInfoDto', () {
+ test('fromJson parses the full prepare response', () {
+ final dto = RealUnitTransferPaymentInfoDto.fromJson({
+ 'id': 99,
+ 'uid': 'RTabc',
+ 'toAddress': '0xRecipient',
+ 'amount': 5,
+ 'tokenAddress': '0xRealu',
+ 'chainId': 11155111,
+ 'eip7702': _eip7702Json(),
+ });
+
+ expect(dto.id, 99);
+ expect(dto.uid, 'RTabc');
+ expect(dto.toAddress, '0xRecipient');
+ expect(dto.amount, 5);
+ expect(dto.tokenAddress, '0xRealu');
+ expect(dto.chainId, 11155111);
+ expect(dto.eip7702.recipient, '0xRecipient');
+ expect(dto.eip7702.amountWei, '5');
+ });
+
+ test('fromJson tolerates a numeric (double) amount from the API', () {
+ final dto = RealUnitTransferPaymentInfoDto.fromJson({
+ 'id': 1,
+ 'uid': 'RTx',
+ 'toAddress': '0xRecipient',
+ 'amount': 5.0,
+ 'tokenAddress': '0xRealu',
+ 'chainId': 1,
+ 'eip7702': _eip7702Json(),
+ });
+
+ expect(dto.amount, 5);
+ });
+ });
+}
diff --git a/test/packages/service/dfx/real_unit_pay_service_test.dart b/test/packages/service/dfx/real_unit_pay_service_test.dart
new file mode 100644
index 000000000..98d8f772a
--- /dev/null
+++ b/test/packages/service/dfx/real_unit_pay_service_test.dart
@@ -0,0 +1,393 @@
+import 'dart:convert';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
+import 'package:http/testing.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/packages/config/api_config.dart';
+import 'package:realunit_wallet/packages/config/network_mode.dart';
+import 'package:realunit_wallet/packages/repository/cache_repository.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_request_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/packages/service/session_cache.dart';
+import 'package:realunit_wallet/packages/service/wallet_service.dart';
+import 'package:realunit_wallet/packages/wallet/wallet.dart';
+import 'package:realunit_wallet/packages/wallet/wallet_account.dart';
+import 'package:web3dart/web3dart.dart';
+
+class _MockAppStore extends Mock implements AppStore {}
+
+class _MockWallet extends Mock implements AWallet {}
+
+class _MockAccount extends Mock implements AWalletAccount {}
+
+class _MockCacheRepository extends Mock implements CacheRepository {}
+
+class _MockWalletService extends Mock implements WalletService {}
+
+class _StubCreds extends Fake implements CredentialsWithKnownAddress {
+ @override
+ EthereumAddress get address =>
+ EthereumAddress.fromHex('0x0000000000000000000000000000000000000001');
+}
+
+Map _swapInfoJson() => {
+ 'id': 99,
+ 'uid': 'MOCK-UID',
+ 'routeId': 7,
+ 'timestamp': '2026-06-03T00:00:00.000Z',
+ 'amount': 10,
+ 'estimatedAmount': 960,
+ 'targetAsset': 'ZCHF',
+ 'fees': {'dfx': 1, 'network': 0.5, 'total': 1.5},
+ 'minVolume': 1,
+ 'maxVolume': 1000,
+ 'minVolumeTarget': 95,
+ 'maxVolumeTarget': 95000,
+ 'ethBalance': 1.0,
+ 'requiredGasEth': 0.001,
+ 'isValid': true,
+};
+
+void main() {
+ late _MockAppStore appStore;
+ late _MockWallet wallet;
+ late _MockAccount account;
+ late _MockWalletService walletService;
+ late SessionCache session;
+
+ setUp(() {
+ appStore = _MockAppStore();
+ wallet = _MockWallet();
+ account = _MockAccount();
+ walletService = _MockWalletService();
+ session = SessionCache(_MockCacheRepository());
+ session.setAuthToken('jwt-1');
+
+ when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet));
+ when(() => appStore.sessionCache).thenReturn(session);
+ when(() => appStore.wallet).thenReturn(wallet);
+ when(() => wallet.currentAccount).thenReturn(account);
+ when(() => account.primaryAddress).thenReturn(_StubCreds());
+ });
+
+ RealUnitPayService build(http.Client client) {
+ when(() => appStore.httpClient).thenReturn(client);
+ return RealUnitPayService(appStore, walletService);
+ }
+
+ group('getPaymentDetails', () {
+ test('GETs the public lnurlp endpoint (no auth header) and parses it', () async {
+ Uri? sentUri;
+ Map? headers;
+ final client = MockClient((request) async {
+ sentUri = request.url;
+ headers = request.headers;
+ return http.Response(
+ jsonEncode({
+ 'requestedAmount': {'asset': 'CHF', 'amount': 42.5},
+ 'quote': {'id': 'quote_xyz', 'expiration': '2026-06-03T12:00:00.000Z'},
+ 'transferAmounts': [
+ {
+ 'method': 'Ethereum',
+ 'assets': [
+ {'asset': 'ZCHF', 'amount': 42.7},
+ ],
+ },
+ ],
+ }),
+ 200,
+ );
+ });
+
+ final dto = await build(client).getPaymentDetails('pl_abc');
+
+ expect(sentUri!.path, '/v1/lnurlp/pl_abc');
+ expect(headers!.containsKey('Authorization'), isFalse);
+ expect(dto.quote.id, 'quote_xyz');
+ expect(dto.transferAmounts.first.assets.first.amount, 42.7);
+ });
+
+ test('non-200 → ApiException', () async {
+ final client = MockClient(
+ (_) async => http.Response(jsonEncode({'statusCode': 404, 'message': 'gone'}), 404),
+ );
+ expect(
+ () => build(client).getPaymentDetails('pl_abc'),
+ throwsA(isA()),
+ );
+ });
+ });
+
+ group('getSwapPaymentInfo', () {
+ test('PUTs /swap with targetAmount and parses the quote', () async {
+ Uri? sentUri;
+ Map? body;
+ final client = MockClient((request) async {
+ sentUri = request.url;
+ body = jsonDecode(request.body) as Map;
+ return http.Response(jsonEncode(_swapInfoJson()), 200);
+ });
+
+ final info = await build(
+ client,
+ ).getSwapPaymentInfo(const RealUnitSwapDto.fromTargetAmount(95.5));
+
+ expect(sentUri!.path, '/v1/realunit/swap');
+ expect(body!['targetAmount'], 95.5);
+ expect(body!.containsKey('amount'), isFalse);
+ expect(info.id, 99);
+ expect(info.estimatedAmount, 960);
+ expect(info.isValid, isTrue);
+ });
+
+ test('non-200 → ApiException', () async {
+ final client = MockClient(
+ (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'bad'}), 400),
+ );
+ expect(
+ () => build(client).getSwapPaymentInfo(const RealUnitSwapDto.fromTargetAmount(95.5)),
+ throwsA(isA()),
+ );
+ });
+ });
+
+ group('createSwapUnsignedTransaction', () {
+ test('200 → parses swap hex', () async {
+ Uri? sentUri;
+ final client = MockClient((request) async {
+ sentUri = request.url;
+ return http.Response(jsonEncode({'swap': '0xswap'}), 200);
+ });
+
+ final dto = await build(client).createSwapUnsignedTransaction(42);
+
+ expect(sentUri!.path, '/v1/realunit/swap/42/unsigned-transaction');
+ expect(dto.swap, '0xswap');
+ });
+
+ test('non-200 → ApiException', () async {
+ final client = MockClient(
+ (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'no eth'}), 400),
+ );
+ expect(
+ () => build(client).createSwapUnsignedTransaction(42),
+ throwsA(isA()),
+ );
+ });
+ });
+
+ group('broadcastSwapTransaction', () {
+ test('201 → returns txHash', () async {
+ Uri? sentUri;
+ Map? body;
+ final client = MockClient((request) async {
+ sentUri = request.url;
+ body = jsonDecode(request.body) as Map;
+ return http.Response(jsonEncode({'txHash': '0xabc'}), 201);
+ });
+
+ final txHash = await build(client).broadcastSwapTransaction(
+ 42,
+ const BroadcastTransactionRequestDto(unsignedTx: '0xtx', r: '0xr', s: '0xs', v: 27),
+ );
+
+ expect(sentUri!.path, '/v1/realunit/swap/42/broadcast');
+ expect(body!['unsignedTx'], '0xtx');
+ expect(body!['v'], 27);
+ expect(txHash, '0xabc');
+ });
+
+ test('non-200 → ApiException', () async {
+ final client = MockClient(
+ (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'bad sig'}), 400),
+ );
+ expect(
+ () => build(client).broadcastSwapTransaction(
+ 42,
+ const BroadcastTransactionRequestDto(unsignedTx: '0xtx', r: '0xr', s: '0xs', v: 27),
+ ),
+ throwsA(isA()),
+ );
+ });
+ });
+
+ group('createPayUnsignedTransaction', () {
+ test('PUTs /pay/unsigned-transaction with the payment refs', () async {
+ Uri? sentUri;
+ Map? body;
+ final client = MockClient((request) async {
+ sentUri = request.url;
+ body = jsonDecode(request.body) as Map;
+ return http.Response(
+ jsonEncode({
+ 'unsignedTx': '0xtx',
+ 'tokenAddress': '0xzchf',
+ 'recipient': '0xrecipient',
+ 'amountWei': '5000000000000000000',
+ 'chainId': 1,
+ }),
+ 200,
+ );
+ });
+
+ final dto = await build(client).createPayUnsignedTransaction(
+ const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q1'),
+ );
+
+ expect(sentUri!.path, '/v1/realunit/pay/unsigned-transaction');
+ expect(body!['paymentLinkId'], 'pl_abc');
+ expect(body!['quoteId'], 'q1');
+ expect(dto.recipient, '0xrecipient');
+ expect(dto.amountWei, '5000000000000000000');
+ });
+
+ test('400 (mainnet-only fail-fast on testnet) → ApiException', () async {
+ final client = MockClient(
+ (_) async =>
+ http.Response(jsonEncode({'statusCode': 400, 'message': 'unsupported method'}), 400),
+ );
+ expect(
+ () => build(client).createPayUnsignedTransaction(
+ const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q1'),
+ ),
+ throwsA(isA()),
+ );
+ });
+ });
+
+ group('submitPay', () {
+ test('PUTs /pay/submit and returns txId', () async {
+ Uri? sentUri;
+ Map? body;
+ final client = MockClient((request) async {
+ sentUri = request.url;
+ body = jsonDecode(request.body) as Map;
+ return http.Response(jsonEncode({'txId': '0xTxId'}), 200);
+ });
+
+ final txId = await build(client).submitPay(
+ const RealUnitOcpPaySubmitDto(
+ unsignedTx: '0xtx',
+ r: '0xr',
+ s: '0xs',
+ v: 27,
+ paymentLinkId: 'pl_abc',
+ quoteId: 'q1',
+ ),
+ );
+
+ expect(sentUri!.path, '/v1/realunit/pay/submit');
+ expect(body!['paymentLinkId'], 'pl_abc');
+ expect(txId, '0xTxId');
+ });
+
+ test('non-200 → ApiException', () async {
+ final client = MockClient(
+ (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'fail'}), 400),
+ );
+ expect(
+ () => build(client).submitPay(
+ const RealUnitOcpPaySubmitDto(
+ unsignedTx: '0xtx',
+ r: '0xr',
+ s: '0xs',
+ v: 27,
+ paymentLinkId: 'pl_abc',
+ quoteId: 'q1',
+ ),
+ ),
+ throwsA(isA()),
+ );
+ });
+ });
+
+ group('isPaySupportedEnvironment (up-front capability gate)', () {
+ test('is true on mainnet', () {
+ when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet));
+ final client = MockClient((_) async => http.Response('{}', 200));
+ expect(build(client).isPaySupportedEnvironment, isTrue);
+ });
+
+ test('is false on testnet', () {
+ when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet));
+ final client = MockClient((_) async => http.Response('{}', 200));
+ expect(build(client).isPaySupportedEnvironment, isFalse);
+ });
+ });
+
+ group('testnet fail-fast (mainnet-only OCP settlement)', () {
+ test('createPayUnsignedTransaction throws PayUnsupportedEnvironmentException', () async {
+ when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet));
+ var clientCalled = false;
+ final client = MockClient((_) async {
+ clientCalled = true;
+ return http.Response('{}', 200);
+ });
+
+ await expectLater(
+ build(client).createPayUnsignedTransaction(
+ const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q1'),
+ ),
+ throwsA(isA()),
+ );
+ expect(clientCalled, isFalse);
+ });
+
+ test('submitPay throws PayUnsupportedEnvironmentException without a round-trip', () async {
+ when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet));
+ var clientCalled = false;
+ final client = MockClient((_) async {
+ clientCalled = true;
+ return http.Response('{}', 200);
+ });
+
+ await expectLater(
+ build(client).submitPay(
+ const RealUnitOcpPaySubmitDto(
+ unsignedTx: '0xtx',
+ r: '0xr',
+ s: '0xs',
+ v: 27,
+ paymentLinkId: 'pl_abc',
+ quoteId: 'q1',
+ ),
+ ),
+ throwsA(isA()),
+ );
+ expect(clientCalled, isFalse);
+ });
+ });
+
+ group('getPayStatus', () {
+ test('GETs /pay/:id/status and parses the status', () async {
+ Uri? sentUri;
+ final client = MockClient((request) async {
+ sentUri = request.url;
+ return http.Response(jsonEncode({'status': 'Completed'}), 200);
+ });
+
+ final dto = await build(client).getPayStatus('pl_abc');
+
+ expect(sentUri!.path, '/v1/realunit/pay/pl_abc/status');
+ expect(dto.status, OcpPaymentStatus.completed);
+ });
+
+ test('non-200 → ApiException', () async {
+ final client = MockClient(
+ (_) async => http.Response(jsonEncode({'statusCode': 404, 'message': 'none'}), 404),
+ );
+ expect(
+ () => build(client).getPayStatus('pl_abc'),
+ throwsA(isA()),
+ );
+ });
+ });
+}
diff --git a/test/packages/service/dfx/real_unit_transfer_service_test.dart b/test/packages/service/dfx/real_unit_transfer_service_test.dart
new file mode 100644
index 000000000..8c9975d14
--- /dev/null
+++ b/test/packages/service/dfx/real_unit_transfer_service_test.dart
@@ -0,0 +1,342 @@
+import 'dart:convert';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
+import 'package:http/testing.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/packages/config/api_config.dart';
+import 'package:realunit_wallet/packages/config/network_mode.dart';
+import 'package:realunit_wallet/packages/repository/cache_repository.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart';
+import 'package:realunit_wallet/packages/service/session_cache.dart';
+import 'package:realunit_wallet/packages/service/wallet_service.dart';
+import 'package:realunit_wallet/packages/wallet/wallet.dart';
+import 'package:realunit_wallet/packages/wallet/wallet_account.dart';
+import 'package:web3dart/web3dart.dart';
+
+class _MockAppStore extends Mock implements AppStore {}
+
+class _MockWallet extends Mock implements AWallet {}
+
+class _MockAccount extends Mock implements AWalletAccount {}
+
+class _MockCacheRepository extends Mock implements CacheRepository {}
+
+class _MockWalletService extends Mock implements WalletService {}
+
+// Deterministic test private key — a real EthPrivateKey credential the
+// EIP-712 / EIP-7702 signers accept directly (they reject anything that isn't
+// BitboxCredentials or EthPrivateKey).
+const _testPrivateKeyHex = 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612';
+
+final _privKey = EthPrivateKey.fromHex(_testPrivateKeyHex);
+final _walletAddress = _privKey.address.hexEip55;
+
+const _metaMaskDelegator = '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b';
+const _delegationManager = '0xdb9b1e94b5b69df7e401ddbede43491141047db3';
+
+/// Debug-wallet style credential: it is neither BitboxCredentials nor
+/// EthPrivateKey, so `Eip712Signer.signDelegation` hits its `_ => throw
+/// UnsupportedError(...)` branch — exactly the debug-wallet capability gap.
+class _UnsupportedCreds extends Fake implements CredentialsWithKnownAddress {
+ @override
+ EthereumAddress get address => _privKey.address;
+}
+
+Map _eip7702Json({int chainId = 1}) => {
+ 'relayerAddress': '0xrelay',
+ 'delegationManagerAddress': _delegationManager,
+ 'delegatorAddress': _metaMaskDelegator,
+ 'userNonce': 7,
+ 'domain': {
+ 'name': 'RealUnit',
+ 'version': '1',
+ 'chainId': chainId,
+ 'verifyingContract': _delegationManager,
+ },
+ 'types': {
+ 'Delegation': >[],
+ 'Caveat': >[],
+ },
+ 'message': {
+ 'delegate': '0xrelay',
+ 'delegator': _walletAddress,
+ 'authority': '0xauth',
+ 'caveats': >[],
+ 'salt': 0,
+ },
+ // The asset address the mainnet RealUnit token resolves to in this fixture.
+ 'tokenAddress': '0x553C7f9C780316FC1D34b8e14ac2465Ab22a090B',
+ 'amountWei': '5',
+ 'recipient': '0xRecipient',
+};
+
+RealUnitTransferPaymentInfoDto _info() => RealUnitTransferPaymentInfoDto.fromJson({
+ 'id': 42,
+ 'uid': 'RTabc',
+ 'toAddress': '0xRecipient',
+ 'amount': 5,
+ 'tokenAddress': '0xRealu',
+ 'chainId': 1,
+ 'eip7702': _eip7702Json(),
+});
+
+void main() {
+ late _MockAppStore appStore;
+ late _MockWallet wallet;
+ late _MockAccount account;
+ late _MockWalletService walletService;
+ late SessionCache session;
+
+ setUp(() {
+ appStore = _MockAppStore();
+ wallet = _MockWallet();
+ account = _MockAccount();
+ walletService = _MockWalletService();
+ session = SessionCache(_MockCacheRepository());
+ session.setAuthToken('jwt-1');
+
+ when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet));
+ when(() => appStore.sessionCache).thenReturn(session);
+ when(() => appStore.wallet).thenReturn(wallet);
+ when(() => wallet.currentAccount).thenReturn(account);
+ when(() => account.primaryAddress).thenReturn(_privKey);
+ when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {});
+ when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {});
+ });
+
+ RealUnitTransferService build(http.Client client) {
+ when(() => appStore.httpClient).thenReturn(client);
+ return RealUnitTransferService(appStore, walletService);
+ }
+
+ group('prepareTransfer', () {
+ test('200 → parses the payment-info DTO and PUTs toAddress + amount', () async {
+ Uri? sentUri;
+ Map? body;
+ final client = MockClient((request) async {
+ sentUri = request.url;
+ body = jsonDecode(request.body) as Map;
+ return http.Response(
+ jsonEncode({
+ 'id': 42,
+ 'uid': 'RTabc',
+ 'toAddress': '0xRecipient',
+ 'amount': 5,
+ 'tokenAddress': '0xRealu',
+ 'chainId': 1,
+ 'eip7702': _eip7702Json(),
+ }),
+ 200,
+ );
+ });
+
+ final info = await build(client).prepareTransfer(
+ const RealUnitTransferDto(toAddress: '0xRecipient', amount: 5),
+ );
+
+ expect(sentUri!.path, '/v1/realunit/transfer');
+ expect(body, {'toAddress': '0xRecipient', 'amount': 5});
+ expect(info.id, 42);
+ expect(info.eip7702.recipient, '0xRecipient');
+ });
+
+ test('503 → TransferGasFundingUnavailableException', () async {
+ final client = MockClient(
+ (_) async => http.Response(
+ jsonEncode({'statusCode': 503, 'message': 'W2W gas funding temporarily unavailable'}),
+ 503,
+ ),
+ );
+
+ expect(
+ () => build(client).prepareTransfer(
+ const RealUnitTransferDto(toAddress: '0xRecipient', amount: 5),
+ ),
+ throwsA(isA()),
+ );
+ });
+
+ test('400 (invalid recipient / insufficient REALU) → ApiException', () async {
+ final client = MockClient(
+ (_) async => http.Response(
+ jsonEncode({'statusCode': 400, 'code': 'X', 'message': 'Invalid recipient address'}),
+ 400,
+ ),
+ );
+
+ expect(
+ () => build(client).prepareTransfer(
+ const RealUnitTransferDto(toAddress: 'bad', amount: 5),
+ ),
+ throwsA(isA()),
+ );
+ });
+ });
+
+ group('confirmTransfer (software wallet happy path)', () {
+ test('signs delegation + authorization, PUTs the envelope, returns txHash', () async {
+ Uri? sentUri;
+ Map? body;
+ final client = MockClient((request) async {
+ sentUri = request.url;
+ body = jsonDecode(request.body) as Map;
+ return http.Response(jsonEncode({'txHash': '0xdeadbeef'}), 200);
+ });
+
+ final txHash = await build(client).confirmTransfer(_info());
+
+ expect(txHash, '0xdeadbeef');
+ expect(sentUri!.path, '/v1/realunit/transfer/42/confirm');
+
+ final envelope = body!;
+ expect(envelope.containsKey('delegation'), isTrue);
+ expect(envelope.containsKey('authorization'), isTrue);
+
+ final delegation = envelope['delegation'] as Map;
+ expect(delegation['delegate'], '0xrelay');
+ expect(delegation['delegator'], _walletAddress);
+ expect(delegation['authority'], '0xauth');
+ expect(delegation['salt'], '0');
+ expect((delegation['signature'] as String).length, 132);
+
+ final authorization = envelope['authorization'] as Map;
+ expect(authorization['chainId'], 1);
+ expect(authorization['address'], _metaMaskDelegator);
+ expect(authorization['nonce'], 7);
+ // r/s are always full 32-byte (64 hex char) big-endian values.
+ expect((authorization['r'] as String).substring(2).length, 64);
+ expect((authorization['s'] as String).substring(2).length, 64);
+ expect(authorization['yParity'], anyOf(0, 1));
+ });
+
+ test('locks the wallet after signing (key never left resident)', () async {
+ final client = MockClient((_) async => http.Response(jsonEncode({'txHash': '0x1'}), 200));
+
+ await build(client).confirmTransfer(_info());
+
+ verify(() => walletService.ensureCurrentWalletUnlocked()).called(1);
+ verify(() => walletService.lockCurrentWallet()).called(1);
+ });
+
+ test('debug-wallet credentials → TransferSignatureUnsupportedException', () async {
+ when(() => account.primaryAddress).thenReturn(_UnsupportedCreds());
+ final client = MockClient((_) async => http.Response('{}', 200));
+
+ expect(
+ () => build(client).confirmTransfer(_info()),
+ throwsA(isA()),
+ );
+ });
+
+ test('503 on confirm → TransferGasFundingUnavailableException', () async {
+ final client = MockClient(
+ (_) async => http.Response(jsonEncode({'message': 'unavailable'}), 503),
+ );
+
+ expect(
+ () => build(client).confirmTransfer(_info()),
+ throwsA(isA()),
+ );
+ });
+
+ test('4xx on confirm → ApiException', () async {
+ final client = MockClient(
+ (_) async =>
+ http.Response(jsonEncode({'statusCode': 409, 'code': 'X', 'message': 'no'}), 409),
+ );
+
+ expect(
+ () => build(client).confirmTransfer(_info()),
+ throwsA(isA()),
+ );
+ });
+
+ // Every pinned contract/field is rejected before signing, mirroring the
+ // sell software-confirm guard. Each case mutates one field of the otherwise
+ // valid eip7702 payload and asserts validation throws WITHOUT any PUT.
+ group('eip7702 validation pins (throw before any PUT)', () {
+ RealUnitTransferPaymentInfoDto infoWith(Map Function() mutate) =>
+ RealUnitTransferPaymentInfoDto.fromJson({
+ 'id': 42,
+ 'uid': 'RTabc',
+ 'toAddress': '0xRecipient',
+ 'amount': 5,
+ 'tokenAddress': '0xRealu',
+ 'chainId': 1,
+ 'eip7702': mutate(),
+ });
+
+ Future expectRejected(RealUnitTransferPaymentInfoDto info) async {
+ var called = false;
+ final client = MockClient((_) async {
+ called = true;
+ return http.Response('{}', 200);
+ });
+ await expectLater(() => build(client).confirmTransfer(info), throwsException);
+ expect(called, isFalse, reason: 'no PUT must happen when validation rejects the payload');
+ }
+
+ test('wrong delegator (MetaMask delegator) contract', () async {
+ await expectRejected(infoWith(() => _eip7702Json()..['delegatorAddress'] = '0xWrong'));
+ });
+
+ test('wrong delegation manager contract', () async {
+ await expectRejected(
+ infoWith(() => _eip7702Json()..['delegationManagerAddress'] = '0xWrong'),
+ );
+ });
+
+ test('verifying contract != delegation manager', () async {
+ await expectRejected(
+ infoWith(() {
+ final json = _eip7702Json();
+ (json['domain'] as Map)['verifyingContract'] = '0xWrong';
+ return json;
+ }),
+ );
+ });
+
+ test('message delegator != wallet address', () async {
+ await expectRejected(
+ infoWith(() {
+ final json = _eip7702Json();
+ (json['message'] as Map)['delegator'] = '0xSomeoneElse';
+ return json;
+ }),
+ );
+ });
+
+ test('chain id mismatch', () async {
+ await expectRejected(infoWith(() => _eip7702Json(chainId: 999)));
+ });
+
+ test('message delegate != relayer address', () async {
+ await expectRejected(
+ infoWith(() {
+ final json = _eip7702Json();
+ (json['message'] as Map)['delegate'] = '0xOtherRelayer';
+ return json;
+ }),
+ );
+ });
+
+ test('token address != RealUnit token', () async {
+ await expectRejected(infoWith(() => _eip7702Json()..['tokenAddress'] = '0xNotRealu'));
+ });
+
+ test('amount-wei mismatch', () async {
+ await expectRejected(infoWith(() => _eip7702Json()..['amountWei'] = '6'));
+ });
+
+ test('unparseable amount-wei', () async {
+ await expectRejected(infoWith(() => _eip7702Json()..['amountWei'] = 'not-a-number'));
+ });
+ });
+ });
+}
diff --git a/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart
new file mode 100644
index 000000000..0192a2e36
--- /dev/null
+++ b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart
@@ -0,0 +1,123 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_localizations/flutter_localizations.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:go_router/go_router.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/screens/dashboard/widgets/sections/dashboard_actions.dart';
+import 'package:realunit_wallet/setup/routing/routes/app_routes.dart';
+import 'package:realunit_wallet/widgets/action_button.dart';
+
+void main() {
+ late List pushedRoutes;
+
+ setUp(() {
+ pushedRoutes = [];
+ });
+
+ // Routes the four action buttons can push. Each target records the pushed
+ // route name so the `onPressed` closures are both executed and asserted,
+ // instead of only painted.
+ GoRouter buildRouter() {
+ GoRoute target(String name, String path) => GoRoute(
+ name: name,
+ path: path,
+ builder: (_, _) {
+ pushedRoutes.add(name);
+ return Scaffold(body: Text('ROUTE:$name'));
+ },
+ );
+
+ return GoRouter(
+ initialLocation: '/',
+ routes: [
+ GoRoute(
+ path: '/',
+ builder: (_, _) => const Scaffold(body: DashboardActions()),
+ ),
+ target(AppRoutes.buy, '/buy'),
+ target(AppRoutes.sell, '/sell'),
+ target(AppRoutes.pay, '/pay'),
+ target(AppRoutes.send, '/send'),
+ ],
+ );
+ }
+
+ Future pumpActions(WidgetTester tester) async {
+ final router = buildRouter();
+ addTearDown(router.dispose);
+ await tester.pumpWidget(
+ MaterialApp.router(
+ routerConfig: router,
+ localizationsDelegates: const [
+ S.delegate,
+ GlobalMaterialLocalizations.delegate,
+ ],
+ supportedLocales: S.delegate.supportedLocales,
+ ),
+ );
+ await tester.pumpAndSettle();
+ }
+
+ Finder actionButtonByLabel(String label) => find.byWidgetPredicate(
+ (w) => w is ActionButton && w.label == label,
+ );
+
+ group('$DashboardActions', () {
+ testWidgets('renders the buy, sell, pay and send action buttons', (tester) async {
+ await pumpActions(tester);
+
+ expect(actionButtonByLabel(S.current.buy), findsOneWidget);
+ expect(actionButtonByLabel(S.current.sell), findsOneWidget);
+ expect(actionButtonByLabel(S.current.pay), findsOneWidget);
+ expect(actionButtonByLabel(S.current.send), findsOneWidget);
+ // Each button is laid out inside an Expanded so the row divides the
+ // available width into four equal slots.
+ expect(find.byType(Expanded), findsNWidgets(4));
+ });
+
+ testWidgets('renders the expected icons for each action', (tester) async {
+ await pumpActions(tester);
+
+ expect(find.byIcon(Icons.add_circle_rounded), findsOneWidget);
+ expect(find.byIcon(Icons.do_not_disturb_on_rounded), findsOneWidget);
+ expect(find.byIcon(Icons.qr_code_scanner_rounded), findsOneWidget);
+ expect(find.byIcon(Icons.send_rounded), findsOneWidget);
+ });
+
+ testWidgets('buy button pushes the buy route', (tester) async {
+ await pumpActions(tester);
+
+ await tester.tap(actionButtonByLabel(S.current.buy));
+ await tester.pumpAndSettle();
+
+ expect(pushedRoutes, [AppRoutes.buy]);
+ });
+
+ testWidgets('sell button pushes the sell route', (tester) async {
+ await pumpActions(tester);
+
+ await tester.tap(actionButtonByLabel(S.current.sell));
+ await tester.pumpAndSettle();
+
+ expect(pushedRoutes, [AppRoutes.sell]);
+ });
+
+ testWidgets('pay button pushes the pay route', (tester) async {
+ await pumpActions(tester);
+
+ await tester.tap(actionButtonByLabel(S.current.pay));
+ await tester.pumpAndSettle();
+
+ expect(pushedRoutes, [AppRoutes.pay]);
+ });
+
+ testWidgets('send button pushes the send route', (tester) async {
+ await pumpActions(tester);
+
+ await tester.tap(actionButtonByLabel(S.current.send));
+ await tester.pumpAndSettle();
+
+ expect(pushedRoutes, [AppRoutes.send]);
+ });
+ });
+}
diff --git a/test/screens/pay/pay_process_cubit_test.dart b/test/screens/pay/pay_process_cubit_test.dart
new file mode 100644
index 000000000..21027f0d5
--- /dev/null
+++ b/test/screens/pay/pay_process_cubit_test.dart
@@ -0,0 +1,652 @@
+import 'dart:typed_data';
+
+import 'package:fake_async/fake_async.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/packages/config/api_config.dart';
+import 'package:realunit_wallet/packages/config/network_mode.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/faucet/faucet_response_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_request_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/packages/service/wallet_service.dart';
+import 'package:realunit_wallet/packages/wallet/wallet.dart';
+import 'package:realunit_wallet/packages/wallet/wallet_account.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart';
+import 'package:web3dart/crypto.dart';
+import 'package:web3dart/web3dart.dart';
+
+import '../../helper/fake_bitbox_credentials.dart';
+
+class _MockPayService extends Mock implements RealUnitPayService {}
+
+class _MockFaucet extends Mock implements DfxFaucetService {}
+
+class _MockBlockchain extends Mock implements DfxBlockchainApiService {}
+
+class _MockWalletService extends Mock implements WalletService {}
+
+class _MockAppStore extends Mock implements AppStore {}
+
+class _MockWallet extends Mock implements AWallet {}
+
+class _MockAccount extends Mock implements AWalletAccount {}
+
+/// Credentials whose `signToSignature` throws [UnsupportedError] — the debug
+/// wallet's behaviour, used to exercise the in-sign defensive guard.
+class _UnsupportedCreds extends Fake implements CredentialsWithKnownAddress {
+ @override
+ Future signToSignature(Uint8List payload, {int? chainId, bool isEIP1559 = false}) =>
+ throw UnsupportedError('Debug wallet cannot sign');
+}
+
+SwapPaymentInfo _swap({
+ double ethBalance = 1.0,
+ double requiredGasEth = 0.001,
+ bool isValid = true,
+}) {
+ return SwapPaymentInfo.fromDto(
+ RealUnitSwapPaymentInfoDto(
+ id: 99,
+ uid: 'u',
+ routeId: 7,
+ timestamp: DateTime.parse('2026-06-03T00:00:00.000Z'),
+ amount: 10,
+ estimatedAmount: 960,
+ targetAsset: 'ZCHF',
+ minVolume: 1,
+ maxVolume: 1000,
+ minVolumeTarget: 95,
+ maxVolumeTarget: 95000,
+ ethBalance: ethBalance,
+ requiredGasEth: requiredGasEth,
+ isValid: isValid,
+ ),
+ );
+}
+
+LnurlpPaymentDto _details({
+ required DateTime expiration,
+ String quoteId = 'quote_fresh',
+ double zchf = 42.7,
+}) {
+ return LnurlpPaymentDto(
+ requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 42.5),
+ quote: LnurlpQuoteDto(id: quoteId, expiration: expiration),
+ transferAmounts: [
+ LnurlpTransferAmountDto(
+ method: 'Ethereum',
+ assets: [LnurlpTransferAssetDto(asset: 'ZCHF', amount: zchf)],
+ ),
+ ],
+ );
+}
+
+const _unsignedPay = RealUnitOcpPayUnsignedTransactionDto(
+ // A short EIP-1559-style payload; signToSignature only keccak-hashes it.
+ unsignedTx: '0x02f8',
+ tokenAddress: '0xzchf',
+ recipient: '0xrecipient',
+ amountWei: '5000000000000000000',
+ chainId: 11155111,
+);
+
+void main() {
+ late _MockPayService payService;
+ late _MockFaucet faucet;
+ late _MockBlockchain blockchain;
+ late _MockWalletService walletService;
+ late _MockAppStore appStore;
+ late _MockWallet wallet;
+ late _MockAccount account;
+
+ setUpAll(() {
+ registerFallbackValue(const RealUnitSwapDto.fromTargetAmount(1));
+ registerFallbackValue(const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q'));
+ registerFallbackValue(
+ const BroadcastTransactionRequestDto(unsignedTx: '', r: '', s: '', v: 0),
+ );
+ registerFallbackValue(
+ const RealUnitOcpPaySubmitDto(
+ unsignedTx: '',
+ r: '',
+ s: '',
+ v: 0,
+ paymentLinkId: 'pl_abc',
+ quoteId: 'q',
+ ),
+ );
+ });
+
+ setUp(() {
+ payService = _MockPayService();
+ faucet = _MockFaucet();
+ blockchain = _MockBlockchain();
+ walletService = _MockWalletService();
+ appStore = _MockAppStore();
+ wallet = _MockWallet();
+ account = _MockAccount();
+
+ when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet));
+ when(() => appStore.primaryAddress).thenReturn('0xwallet');
+ // Default: the environment can settle OCP (mainnet). The up-front gate in
+ // start() reads this before any on-chain action.
+ when(() => payService.isPaySupportedEnvironment).thenReturn(true);
+ when(() => appStore.wallet).thenReturn(wallet);
+ when(() => wallet.walletType).thenReturn(WalletType.software);
+ when(() => wallet.currentAccount).thenReturn(account);
+ when(() => account.primaryAddress).thenReturn(FakeBitboxCredentials(signDelay: Duration.zero));
+ when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {});
+ when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {});
+ });
+
+ PayProcessCubit build({double zchfNeeded = 42.7}) => PayProcessCubit(
+ payService: payService,
+ faucetService: faucet,
+ blockchainService: blockchain,
+ walletService: walletService,
+ appStore: appStore,
+ paymentLinkId: 'pl_abc',
+ zchfNeeded: zchfNeeded,
+ );
+
+ void wireHappyPath() {
+ when(() => payService.getSwapPaymentInfo(any())).thenAnswer((_) async => _swap());
+ when(() => payService.createSwapUnsignedTransaction(any())).thenAnswer(
+ (_) async => const RealUnitSwapUnsignedTransactionDto(swap: '0x02f8aa'),
+ );
+ when(
+ () => payService.broadcastSwapTransaction(any(), any()),
+ ).thenAnswer((_) async => '0xswaptx');
+ when(() => payService.getPaymentDetails('pl_abc')).thenAnswer(
+ (_) async => _details(expiration: DateTime.now().add(const Duration(minutes: 5))),
+ );
+ when(
+ () => payService.createPayUnsignedTransaction(any()),
+ ).thenAnswer((_) async => _unsignedPay);
+ when(() => payService.submitPay(any())).thenAnswer((_) async => '0xpaytx');
+ }
+
+ // The sign step uses `Future.delayed(Duration.zero)` (FakeBitboxCredentials),
+ // which is a zero-duration *timer* under fakeAsync — `flushMicrotasks` alone
+ // does not fire it. Elapsing zero repeatedly drains the whole await chain
+ // (each mock `thenAnswer` future + every zero-delay sign timer) until the
+ // cubit settles.
+ void drain(FakeAsync async) {
+ for (var i = 0; i < 40; i++) {
+ async.flushMicrotasks();
+ async.elapse(Duration.zero);
+ }
+ }
+
+ test('debug wallet → signatureUnsupported before any network call', () async {
+ when(() => wallet.walletType).thenReturn(WalletType.debug);
+
+ final cubit = build();
+ await cubit.start();
+
+ final state = cubit.state as PayProcessFailure;
+ expect(state.reason, PayProcessFailureReason.signatureUnsupported);
+ verifyNever(() => payService.getSwapPaymentInfo(any()));
+ await cubit.close();
+ });
+
+ test('invalid swap quote → insufficientZchf', () async {
+ when(() => payService.getSwapPaymentInfo(any())).thenAnswer((_) async => _swap(isValid: false));
+
+ final cubit = build();
+ await cubit.start();
+
+ final state = cubit.state as PayProcessFailure;
+ expect(state.reason, PayProcessFailureReason.insufficientZchf);
+ await cubit.close();
+ });
+
+ test('swap sizes the target with a slippage buffer over the ZCHF needed', () async {
+ wireHappyPath();
+ when(() => payService.getPayStatus('pl_abc')).thenAnswer(
+ (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed),
+ );
+ RealUnitSwapDto? sentDto;
+ when(() => payService.getSwapPaymentInfo(any())).thenAnswer((invocation) async {
+ sentDto = invocation.positionalArguments.first as RealUnitSwapDto;
+ return _swap();
+ });
+
+ final cubit = build(zchfNeeded: 100);
+ await cubit.start();
+
+ // After start() resolves the chain the pay tx has been submitted.
+ expect(cubit.state, isA());
+ // 100 * 1.03 swap headroom buffer (covers ordinary CHF→ZCHF / swap-rate
+ // drift between scan and settle).
+ expect(sentDto!.targetAmount, closeTo(103, 0.0001));
+ expect(sentDto!.amount, isNull);
+ await cubit.close();
+ });
+
+ test('happy path: swap → refresh quote → pay → polled Completed → success', () async {
+ fakeAsync((async) {
+ wireHappyPath();
+ when(() => payService.getPayStatus('pl_abc')).thenAnswer(
+ (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed),
+ );
+
+ final cubit = build();
+ cubit.start();
+ drain(async);
+
+ // Pay submitted → polling status.
+ expect(cubit.state, isA());
+
+ // First status poll @ 3s returns Completed → success.
+ async.elapse(const Duration(seconds: 3));
+ drain(async);
+ expect(cubit.state, isA());
+
+ cubit.close();
+ async.flushTimers();
+ });
+ });
+
+ test('re-fetched quote sends a fresh quoteId into the pay step', () async {
+ wireHappyPath();
+ when(() => payService.getPaymentDetails('pl_abc')).thenAnswer(
+ (_) async =>
+ _details(expiration: DateTime.now().add(const Duration(minutes: 5)), quoteId: 'q_fresh2'),
+ );
+ RealUnitOcpPayDto? payDto;
+ when(() => payService.createPayUnsignedTransaction(any())).thenAnswer((invocation) async {
+ payDto = invocation.positionalArguments.first as RealUnitOcpPayDto;
+ return _unsignedPay;
+ });
+
+ final cubit = build();
+ final settled = cubit.stream.firstWhere((s) => s is PayProcessAwaitingSettlement);
+ await cubit.start();
+ await settled;
+
+ expect(payDto!.quoteId, 'q_fresh2');
+ await cubit.close();
+ });
+
+ test('quote expired between swap and pay → pay-only retry (no re-scan)', () async {
+ wireHappyPath();
+ when(() => payService.getPaymentDetails('pl_abc')).thenAnswer(
+ (_) async => _details(expiration: DateTime.now().subtract(const Duration(minutes: 1))),
+ );
+
+ final cubit = build();
+ final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry);
+ await cubit.start();
+ final state = await retry as PayProcessPayRetry;
+
+ // Genuine expiry surfaces as a retryable state — NOT a terminal failure —
+ // because the swap already ran. The pay leg is never submitted here.
+ expect(state.reason, PayRetryReason.quoteExpired);
+ verifyNever(() => payService.createPayUnsignedTransaction(any()));
+ await cubit.close();
+ });
+
+ test('pay submit failure after swap → retry (transient), not terminal', () async {
+ wireHappyPath();
+ when(() => payService.submitPay(any())).thenThrow(Exception('settlement rejected'));
+
+ final cubit = build();
+ final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry);
+ await cubit.start();
+ final state = await retry as PayProcessPayRetry;
+
+ // The swap is done; a failed pay must NOT force a re-swap.
+ expect(state.reason, PayRetryReason.transient);
+ expect(cubit.state, isNot(isA()));
+ await cubit.close();
+ });
+
+ test('terminal non-completed status (Cancelled) → pay-only retry', () async {
+ fakeAsync((async) {
+ wireHappyPath();
+ when(() => payService.getPayStatus('pl_abc')).thenAnswer(
+ (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.cancelled),
+ );
+
+ final cubit = build();
+ cubit.start();
+ drain(async);
+ expect(cubit.state, isA());
+
+ async.elapse(const Duration(seconds: 3));
+ drain(async);
+ // A cancelled settlement after the swap leaves the user holding ZCHF — it
+ // is recoverable by retrying the pay leg, not a terminal failure.
+ final state = cubit.state as PayProcessPayRetry;
+ expect(state.reason, PayRetryReason.transient);
+
+ cubit.close();
+ async.flushTimers();
+ });
+ });
+
+ test('status polling ignores a transient error then settles', () async {
+ fakeAsync((async) {
+ wireHappyPath();
+ var call = 0;
+ when(() => payService.getPayStatus('pl_abc')).thenAnswer((_) async {
+ call++;
+ if (call == 1) throw Exception('rpc 503');
+ return const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed);
+ });
+
+ final cubit = build();
+ cubit.start();
+ drain(async);
+
+ // 1st poll throws → still awaiting.
+ async.elapse(const Duration(seconds: 3));
+ drain(async);
+ expect(cubit.state, isA());
+
+ // 2nd poll completes → success.
+ async.elapse(const Duration(seconds: 3));
+ drain(async);
+ expect(cubit.state, isA());
+
+ cubit.close();
+ async.flushTimers();
+ });
+ });
+
+ test('low ETH balance → faucet → eth polling crosses threshold → swap proceeds', () async {
+ fakeAsync((async) {
+ wireHappyPath();
+ when(
+ () => payService.getSwapPaymentInfo(any()),
+ ).thenAnswer((_) async => _swap(ethBalance: 0, requiredGasEth: 0.001));
+ when(
+ () => faucet.requestFaucet(),
+ ).thenAnswer((_) async => const FaucetResponseDto(txId: '0xf', amount: 0.01));
+ var balanceCall = 0;
+ when(() => blockchain.getEthBalance(any())).thenAnswer((_) async {
+ balanceCall++;
+ return balanceCall == 1 ? 0.0 : 0.01;
+ });
+ when(() => payService.getPayStatus('pl_abc')).thenAnswer(
+ (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed),
+ );
+
+ final cubit = build();
+ cubit.start();
+ drain(async);
+ expect(cubit.state, isA());
+
+ // 1st eth poll @ 5s — still 0.
+ async.elapse(const Duration(seconds: 5));
+ drain(async);
+ expect(cubit.state, isA());
+
+ // 2nd eth poll @ 10s — funded → swap runs through to settlement polling.
+ async.elapse(const Duration(seconds: 5));
+ drain(async);
+ expect(cubit.state, isA());
+
+ // status poll completes the flow.
+ async.elapse(const Duration(seconds: 3));
+ drain(async);
+ expect(cubit.state, isA());
+
+ cubit.close();
+ async.flushTimers();
+ });
+ });
+
+ test('faucet request failure → insufficientEth', () async {
+ when(
+ () => payService.getSwapPaymentInfo(any()),
+ ).thenAnswer((_) async => _swap(ethBalance: 0, requiredGasEth: 0.001));
+ when(() => faucet.requestFaucet()).thenThrow(Exception('faucet down'));
+
+ final cubit = build();
+ final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure);
+ await cubit.start();
+ final state = await failed as PayProcessFailure;
+
+ expect(state.reason, PayProcessFailureReason.insufficientEth);
+ await cubit.close();
+ });
+
+ test('UnsupportedError while signing → signatureUnsupported', () async {
+ wireHappyPath();
+ // Wallet reports software type (passes the start() gate) but the credentials
+ // throw UnsupportedError on sign — exercises the in-sign defensive guard.
+ when(() => account.primaryAddress).thenReturn(_UnsupportedCreds());
+
+ final cubit = build();
+ final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure);
+ await cubit.start();
+ final state = await failed as PayProcessFailure;
+
+ expect(state.reason, PayProcessFailureReason.signatureUnsupported);
+ await cubit.close();
+ });
+
+ test('swap quote fetch failure → generic', () async {
+ when(() => payService.getSwapPaymentInfo(any())).thenThrow(Exception('api 500'));
+
+ final cubit = build();
+ final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure);
+ await cubit.start();
+ final state = await failed as PayProcessFailure;
+
+ expect(state.reason, PayProcessFailureReason.generic);
+ await cubit.close();
+ });
+
+ test('BitBox disconnect during the swap sign → bitboxRequired', () async {
+ wireHappyPath();
+ when(() => account.primaryAddress).thenReturn(
+ FakeBitboxCredentials(behavior: FakeBitboxBehavior.disconnect, signDelay: Duration.zero),
+ );
+
+ final cubit = build();
+ final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure);
+ await cubit.start();
+ final state = await failed as PayProcessFailure;
+
+ expect(state.reason, PayProcessFailureReason.bitboxRequired);
+ await cubit.close();
+ });
+
+ test('generic sign failure during the swap → generic', () async {
+ wireHappyPath();
+ when(() => account.primaryAddress).thenReturn(
+ FakeBitboxCredentials(behavior: FakeBitboxBehavior.malformed, signDelay: Duration.zero),
+ );
+
+ final cubit = build();
+ final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure);
+ await cubit.start();
+ final state = await failed as PayProcessFailure;
+
+ expect(state.reason, PayProcessFailureReason.generic);
+ await cubit.close();
+ });
+
+ test('transient quote re-fetch failure after swap → retry (not re-scan)', () async {
+ wireHappyPath();
+ when(() => payService.getPaymentDetails('pl_abc')).thenThrow(Exception('lnurlp 500'));
+
+ final cubit = build();
+ final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry);
+ await cubit.start();
+ final state = await retry as PayProcessPayRetry;
+
+ // A transient fetch error is NOT a genuine expiry — it routes to the
+ // pay-only retry, never to a re-scan → re-swap.
+ expect(state.reason, PayRetryReason.transient);
+ await cubit.close();
+ });
+
+ test('unsupported environment → fails BEFORE any swap (no on-chain action)', () async {
+ wireHappyPath();
+ when(() => payService.isPaySupportedEnvironment).thenReturn(false);
+
+ final cubit = build();
+ await cubit.start();
+
+ final state = cubit.state as PayProcessFailure;
+ expect(state.reason, PayProcessFailureReason.payUnsupportedEnvironment);
+ // The irreversible swap must never run on an unsupported environment.
+ verifyNever(() => payService.getSwapPaymentInfo(any()));
+ verifyNever(() => payService.createSwapUnsignedTransaction(any()));
+ verifyNever(() => payService.broadcastSwapTransaction(any(), any()));
+ await cubit.close();
+ });
+
+ test('BitBox disconnect during the pay sign (after swap) → pay-only retry', () async {
+ wireHappyPath();
+ // First sign (swap) succeeds; the second sign (pay) reports a dropped BLE
+ // link. Because the swap already happened, this is a retryable pay-leg
+ // failure rather than a terminal one.
+ final creds = _CountingSignCreds(
+ throwOnCall: 2,
+ error: const BitboxNotConnectedException(),
+ );
+ when(() => account.primaryAddress).thenReturn(creds);
+
+ final cubit = build();
+ final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry);
+ await cubit.start();
+ final state = await retry as PayProcessPayRetry;
+
+ expect(creds.calls, 2);
+ expect(state.reason, PayRetryReason.transient);
+ await cubit.close();
+ });
+
+ test('insufficient ZCHF after swap (fresh amount > acquired) → typed retry', () async {
+ wireHappyPath();
+ // Swap acquires estimatedAmount=960 ZCHF, but the fresh quote now demands
+ // 1000 ZCHF — more than was swapped. Surface the typed, retryable state.
+ when(() => payService.getPaymentDetails('pl_abc')).thenAnswer(
+ (_) async => _details(
+ expiration: DateTime.now().add(const Duration(minutes: 5)),
+ zchf: 1000,
+ ),
+ );
+
+ final cubit = build();
+ final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry);
+ await cubit.start();
+ final state = await retry as PayProcessPayRetry;
+
+ expect(state.reason, PayRetryReason.insufficientZchf);
+ // The pay leg is never attempted — the swapped ZCHF stays in the wallet.
+ verifyNever(() => payService.createPayUnsignedTransaction(any()));
+ await cubit.close();
+ });
+
+ test('retryPay re-runs the pay leg only — never re-swaps', () async {
+ wireHappyPath();
+ // First pass: quote re-fetch throws → PayProcessPayRetry.
+ var detailsCall = 0;
+ when(() => payService.getPaymentDetails('pl_abc')).thenAnswer((_) async {
+ detailsCall++;
+ if (detailsCall == 1) throw Exception('lnurlp 500');
+ return _details(expiration: DateTime.now().add(const Duration(minutes: 5)));
+ });
+ when(() => payService.getPayStatus('pl_abc')).thenAnswer(
+ (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed),
+ );
+
+ final cubit = build();
+ final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry);
+ await cubit.start();
+ await retry;
+ expect(cubit.state, isA());
+
+ // Retry the pay leg: it must re-fetch the quote + submit WITHOUT re-swapping.
+ final settled = cubit.stream.firstWhere((s) => s is PayProcessAwaitingSettlement);
+ await cubit.retryPay();
+ await settled;
+
+ expect(cubit.state, isA());
+ // The swap legs ran EXACTLY ONCE over the whole flow — the retry reused the
+ // already-acquired ZCHF and never re-swapped (the key fund-safety guarantee).
+ verify(() => payService.createSwapUnsignedTransaction(any())).called(1);
+ verify(() => payService.broadcastSwapTransaction(any(), any())).called(1);
+ // The pay leg's submit ran once (only on the successful retry).
+ verify(() => payService.submitPay(any())).called(1);
+ await cubit.close();
+ });
+
+ test('retryPay is a no-op before a swap has completed', () async {
+ wireHappyPath();
+
+ final cubit = build();
+ // Never started → swap not completed → retry must not touch the network.
+ await cubit.retryPay();
+
+ verifyNever(() => payService.getPaymentDetails(any()));
+ await cubit.close();
+ });
+
+ test('non-signing wallet detected only at the pay sign (after swap) → retry', () async {
+ wireHappyPath();
+ // Swap sign succeeds; the pay sign hits a non-signing credential
+ // (UnsupportedError). Post-swap, this is a retryable pay-leg failure.
+ final creds = _CountingSignCreds(
+ throwOnCall: 2,
+ error: UnsupportedError('cannot sign'),
+ );
+ when(() => account.primaryAddress).thenReturn(creds);
+
+ final cubit = build();
+ final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry);
+ await cubit.start();
+ final state = await retry as PayProcessPayRetry;
+
+ expect(creds.calls, 2);
+ expect(state.reason, PayRetryReason.transient);
+ await cubit.close();
+ });
+}
+
+/// Credentials that produce a real signature for every sign except the
+/// [throwOnCall]-th, which throws [error]. Lets a test target the swap (call 1)
+/// vs. the pay (call 2) sign deterministically.
+class _CountingSignCreds extends Fake implements CredentialsWithKnownAddress {
+ _CountingSignCreds({required this.throwOnCall, required this.error});
+
+ final int throwOnCall;
+ final Object error;
+ int calls = 0;
+
+ @override
+ EthereumAddress get address =>
+ EthereumAddress.fromHex('0x9F5713dEAcb8e9CaB6c2D3FaE1aFc2715F8D2D71');
+
+ @override
+ Future signToSignature(
+ Uint8List payload, {
+ int? chainId,
+ bool isEIP1559 = false,
+ }) async {
+ calls++;
+ if (calls == throwOnCall) throw error;
+ return EthPrivateKey.fromHex(
+ 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612',
+ ).signToSignature(payload, chainId: chainId, isEIP1559: isEIP1559);
+ }
+}
diff --git a/test/screens/pay/pay_process_page_test.dart b/test/screens/pay/pay_process_page_test.dart
new file mode 100644
index 000000000..291221dd8
--- /dev/null
+++ b/test/screens/pay/pay_process_page_test.dart
@@ -0,0 +1,278 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:get_it/get_it.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/packages/config/api_config.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/packages/service/wallet_service.dart';
+import 'package:realunit_wallet/packages/utils/default_assets.dart';
+import 'package:realunit_wallet/packages/wallet/wallet.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart';
+import 'package:realunit_wallet/screens/pay/pay_process_page.dart';
+
+import '../../helper/helper.dart';
+
+class _MockPayProcessCubit extends MockCubit implements PayProcessCubit {}
+
+class _MockPayService extends Mock implements RealUnitPayService {}
+
+class _MockFaucetService extends Mock implements DfxFaucetService {}
+
+class _MockBlockchainService extends Mock implements DfxBlockchainApiService {}
+
+class _MockWalletService extends Mock implements WalletService {}
+
+class _MockAppStore extends Mock implements AppStore {}
+
+class _MockApiConfig extends Mock implements ApiConfig {}
+
+class _MockWallet extends Mock implements SoftwareWallet {}
+
+void main() {
+ late _MockPayProcessCubit processCubit;
+
+ setUpAll(() {
+ final getIt = GetIt.instance;
+ // PayProcessPage resolves a full service graph from getIt and calls
+ // start(). A debug wallet makes start() settle immediately
+ // (signatureUnsupported) without touching the chain.
+ final payService = _MockPayService();
+ when(() => payService.isPaySupportedEnvironment).thenReturn(true);
+ getIt.registerSingleton(payService);
+ getIt.registerSingleton(_MockFaucetService());
+ getIt.registerSingleton(_MockBlockchainService());
+ getIt.registerSingleton(_MockWalletService());
+ final appStore = _MockAppStore();
+ final apiConfig = _MockApiConfig();
+ when(() => apiConfig.asset).thenReturn(realUnitAsset);
+ final wallet = _MockWallet();
+ when(() => wallet.walletType).thenReturn(WalletType.debug);
+ when(() => appStore.wallet).thenReturn(wallet);
+ when(() => appStore.apiConfig).thenReturn(apiConfig);
+ getIt.registerSingleton(appStore);
+ });
+
+ tearDownAll(() async => GetIt.instance.reset());
+
+ setUp(() {
+ processCubit = _MockPayProcessCubit();
+ when(() => processCubit.state).thenReturn(const PayProcessInitial());
+ when(() => processCubit.retryPay()).thenAnswer((_) async {});
+ });
+
+ Widget buildSubject() => BlocProvider.value(
+ value: processCubit,
+ child: const PayProcessView(),
+ );
+
+ group('$PayProcessPage', () {
+ testWidgets('builds its own cubit and renders $PayProcessView', (tester) async {
+ await tester.pumpApp(const PayProcessPage(paymentLinkId: 'pl_abc', zchfNeeded: 42.7));
+ // start() runs and emits a failure on the debug wallet; pump a frame to
+ // let the cubit settle (the sheet animation is not awaited here).
+ await tester.pump();
+
+ expect(find.byType(PayProcessView), findsOne);
+ });
+ });
+
+ group('$PayProcessView progress labels', () {
+ Future expectLabel(WidgetTester tester, PayProcessState state, String label) async {
+ when(() => processCubit.state).thenReturn(state);
+ await tester.pumpApp(buildSubject());
+
+ expect(find.byType(CupertinoActivityIndicator), findsOne);
+ expect(find.text(label), findsOne);
+ }
+
+ testWidgets('initial shows preparing-swap', (tester) async {
+ await expectLabel(tester, const PayProcessInitial(), S.current.payPreparingSwap);
+ });
+
+ testWidgets('preparing-swap label', (tester) async {
+ await expectLabel(tester, const PayProcessPreparingSwap(), S.current.payPreparingSwap);
+ });
+
+ testWidgets('waiting-for-eth label', (tester) async {
+ await expectLabel(tester, const PayProcessWaitingForEth(), S.current.payWaitingForEth);
+ });
+
+ testWidgets('swapping label', (tester) async {
+ await expectLabel(tester, const PayProcessSwapping(), S.current.paySwapping);
+ });
+
+ testWidgets('refreshing-quote label', (tester) async {
+ await expectLabel(tester, const PayProcessRefreshingQuote(), S.current.payRefreshingQuote);
+ });
+
+ testWidgets('paying label', (tester) async {
+ await expectLabel(tester, const PayProcessPaying(), S.current.payPaying);
+ });
+
+ testWidgets('awaiting-settlement label', (tester) async {
+ await expectLabel(
+ tester,
+ const PayProcessAwaitingSettlement('0xtx'),
+ S.current.payAwaitingSettlement,
+ );
+ });
+
+ testWidgets('success label', (tester) async {
+ await expectLabel(tester, const PayProcessSuccess(), S.current.paySuccess);
+ });
+
+ testWidgets('pay-retry label', (tester) async {
+ await expectLabel(
+ tester,
+ const PayProcessPayRetry(PayRetryReason.quoteExpired),
+ S.current.payRetryTitle,
+ );
+ });
+
+ testWidgets('failure label', (tester) async {
+ await expectLabel(
+ tester,
+ const PayProcessFailure(PayProcessFailureReason.generic),
+ S.current.payFailureTitle,
+ );
+ });
+ });
+
+ // The result/retry sheets are modal bottom sheets shown from the listener.
+ // The PayProcessView keeps a CupertinoActivityIndicator animating behind the
+ // sheet, so pumpAndSettle never settles; pump fixed frames to open the sheet.
+ // A phone-sized surface keeps the taller retry sheet from overflowing the
+ // default 800x600 test viewport (mirrors the logout-sheet test convention).
+ Future pumpWithState(WidgetTester tester, PayProcessState terminal) async {
+ tester.view.physicalSize = const Size(1200, 2400);
+ tester.view.devicePixelRatio = 1.0;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+
+ whenListen(
+ processCubit,
+ Stream.fromIterable([terminal]),
+ initialState: const PayProcessSwapping(),
+ );
+ await tester.pumpApp(buildSubject());
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+ }
+
+ group('$PayProcessView result sheet', () {
+ testWidgets('success emits a success sheet with title + description', (tester) async {
+ await pumpWithState(tester, const PayProcessSuccess());
+
+ expect(find.text(S.current.paySuccessDescription), findsOne);
+ expect(find.byIcon(Icons.check_circle_rounded), findsOne);
+ expect(find.text(S.current.close), findsOne);
+
+ // Tapping close pops the sheet and then pops the page.
+ await tester.tap(find.text(S.current.close));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+
+ expect(find.byIcon(Icons.check_circle_rounded), findsNothing);
+ });
+
+ testWidgets('insufficient-zchf failure emits a failure sheet', (tester) async {
+ await pumpWithState(
+ tester,
+ const PayProcessFailure(PayProcessFailureReason.insufficientZchf),
+ );
+
+ // payFailureTitle also renders as the progress-label behind the sheet,
+ // so it appears twice; the reason message is the sheet-unique assertion.
+ expect(find.text(S.current.payFailureTitle), findsWidgets);
+ expect(find.text(S.current.payFailureInsufficientZchf), findsOne);
+ expect(find.byIcon(Icons.error_rounded), findsOne);
+ });
+
+ testWidgets('insufficient-eth failure message', (tester) async {
+ await pumpWithState(
+ tester,
+ const PayProcessFailure(PayProcessFailureReason.insufficientEth),
+ );
+
+ expect(find.text(S.current.payFailureInsufficientEth), findsOne);
+ });
+
+ testWidgets('unsupported-environment failure message', (tester) async {
+ await pumpWithState(
+ tester,
+ const PayProcessFailure(PayProcessFailureReason.payUnsupportedEnvironment),
+ );
+
+ expect(find.text(S.current.payFailureUnsupportedEnvironment), findsOne);
+ });
+
+ testWidgets('signature-unsupported failure message', (tester) async {
+ await pumpWithState(
+ tester,
+ const PayProcessFailure(PayProcessFailureReason.signatureUnsupported),
+ );
+
+ expect(find.text(S.current.payFailureSignatureUnsupported), findsOne);
+ });
+
+ testWidgets('bitbox-required failure message', (tester) async {
+ await pumpWithState(
+ tester,
+ const PayProcessFailure(PayProcessFailureReason.bitboxRequired),
+ );
+
+ expect(find.text(S.current.payFailureBitboxRequired), findsOne);
+ });
+
+ testWidgets('generic failure message', (tester) async {
+ await pumpWithState(
+ tester,
+ const PayProcessFailure(PayProcessFailureReason.generic),
+ );
+
+ expect(find.text(S.current.payFailureGeneric), findsOne);
+ });
+ });
+
+ group('$PayProcessView retry sheet', () {
+ testWidgets('pay-retry emits a retry sheet whose primary action calls retryPay', (
+ tester,
+ ) async {
+ await pumpWithState(tester, const PayProcessPayRetry(PayRetryReason.quoteExpired));
+
+ expect(find.text(S.current.payRetryQuoteExpired), findsOne);
+ expect(find.byIcon(Icons.replay_rounded), findsOne);
+
+ await tester.tap(find.text(S.current.payRetryButton));
+ await tester.pump();
+
+ verify(() => processCubit.retryPay()).called(1);
+ });
+
+ testWidgets('retry sheet close action dismisses without retrying', (tester) async {
+ await pumpWithState(tester, const PayProcessPayRetry(PayRetryReason.transient));
+
+ expect(find.text(S.current.payRetryTransient), findsOne);
+
+ await tester.tap(find.text(S.current.close));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+
+ verifyNever(() => processCubit.retryPay());
+ expect(find.text(S.current.payRetryTransient), findsNothing);
+ });
+
+ testWidgets('insufficient-zchf retry reason shows its message', (tester) async {
+ await pumpWithState(tester, const PayProcessPayRetry(PayRetryReason.insufficientZchf));
+
+ expect(find.text(S.current.payRetryInsufficientZchf), findsOne);
+ });
+ });
+}
diff --git a/test/screens/pay/pay_process_state_test.dart b/test/screens/pay/pay_process_state_test.dart
new file mode 100644
index 000000000..354720b60
--- /dev/null
+++ b/test/screens/pay/pay_process_state_test.dart
@@ -0,0 +1,64 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart';
+
+void main() {
+ group('PayProcessState equality (Equatable props)', () {
+ test('progress states with no fields expose empty props and compare by type', () {
+ // Reading `.props` directly evaluates the inherited base getter (const
+ // canonicalization would otherwise make `==` short-circuit via identical).
+ expect(const PayProcessPreparingSwap().props, isEmpty);
+ expect(const PayProcessWaitingForEth().props, isEmpty);
+ expect(const PayProcessInitial().props, isEmpty);
+ expect(const PayProcessSwapping().props, isEmpty);
+ expect(const PayProcessRefreshingQuote().props, isEmpty);
+ expect(const PayProcessPaying().props, isEmpty);
+ expect(const PayProcessSuccess().props, isEmpty);
+ expect(
+ const PayProcessPreparingSwap(),
+ isNot(equals(const PayProcessWaitingForEth())),
+ );
+ });
+
+ test('PayProcessAwaitingSettlement is keyed on txId', () {
+ expect(
+ const PayProcessAwaitingSettlement('0xtx'),
+ const PayProcessAwaitingSettlement('0xtx'),
+ );
+ expect(
+ const PayProcessAwaitingSettlement('0xtx'),
+ isNot(equals(const PayProcessAwaitingSettlement('0xother'))),
+ );
+ expect(const PayProcessAwaitingSettlement('0xtx').props, ['0xtx']);
+ });
+
+ test('PayProcessFailure is keyed on reason + message', () {
+ expect(
+ const PayProcessFailure(PayProcessFailureReason.generic),
+ const PayProcessFailure(PayProcessFailureReason.generic),
+ );
+ expect(
+ const PayProcessFailure(PayProcessFailureReason.generic),
+ isNot(equals(const PayProcessFailure(PayProcessFailureReason.insufficientEth))),
+ );
+ expect(
+ const PayProcessFailure(PayProcessFailureReason.generic, message: 'boom').props,
+ [PayProcessFailureReason.generic, 'boom'],
+ );
+ });
+
+ test('PayProcessPayRetry is keyed on reason + message', () {
+ expect(
+ const PayProcessPayRetry(PayRetryReason.transient),
+ const PayProcessPayRetry(PayRetryReason.transient),
+ );
+ expect(
+ const PayProcessPayRetry(PayRetryReason.transient),
+ isNot(equals(const PayProcessPayRetry(PayRetryReason.quoteExpired))),
+ );
+ expect(
+ const PayProcessPayRetry(PayRetryReason.insufficientZchf, message: 'short').props,
+ [PayRetryReason.insufficientZchf, 'short'],
+ );
+ });
+ });
+}
diff --git a/test/screens/pay/pay_quote_cubit_test.dart b/test/screens/pay/pay_quote_cubit_test.dart
new file mode 100644
index 000000000..d0579eea5
--- /dev/null
+++ b/test/screens/pay/pay_quote_cubit_test.dart
@@ -0,0 +1,114 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart';
+
+class _MockPayService extends Mock implements RealUnitPayService {}
+
+LnurlpPaymentDto _details({
+ required DateTime expiration,
+ bool withEthZchf = true,
+ double zchf = 42.7,
+}) {
+ return LnurlpPaymentDto(
+ requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 42.5),
+ quote: LnurlpQuoteDto(id: 'quote_xyz', expiration: expiration),
+ transferAmounts: [
+ if (withEthZchf)
+ LnurlpTransferAmountDto(
+ method: 'Ethereum',
+ assets: [LnurlpTransferAssetDto(asset: 'ZCHF', amount: zchf)],
+ )
+ else
+ const LnurlpTransferAmountDto(
+ method: 'Bitcoin',
+ assets: [LnurlpTransferAssetDto(asset: 'BTC', amount: 0.0005)],
+ ),
+ ],
+ );
+}
+
+void main() {
+ late _MockPayService payService;
+
+ setUp(() {
+ payService = _MockPayService();
+ // Default: the environment can settle OCP (mainnet). load() checks this
+ // up-front before fetching the quote.
+ when(() => payService.isPaySupportedEnvironment).thenReturn(true);
+ });
+
+ PayQuoteCubit build() => PayQuoteCubit(payService, 'pl_abc');
+
+ blocTest(
+ 'an unsupported environment emits PayQuoteUnsupportedEnvironment without fetching',
+ build: build,
+ setUp: () {
+ when(() => payService.isPaySupportedEnvironment).thenReturn(false);
+ },
+ act: (cubit) => cubit.load(),
+ expect: () => [isA(), isA()],
+ verify: (_) => verifyNever(() => payService.getPaymentDetails(any())),
+ );
+
+ blocTest(
+ 'a fresh quote with an Ethereum/ZCHF method emits PayQuoteReady',
+ build: build,
+ setUp: () {
+ when(() => payService.getPaymentDetails('pl_abc')).thenAnswer(
+ (_) async => _details(expiration: DateTime.now().add(const Duration(minutes: 5))),
+ );
+ },
+ act: (cubit) => cubit.load(),
+ expect: () => [isA(), isA()],
+ verify: (cubit) {
+ final state = cubit.state as PayQuoteReady;
+ expect(state.quoteId, 'quote_xyz');
+ expect(state.fiatAsset, 'CHF');
+ expect(state.fiatAmount, 42.5);
+ expect(state.zchfAmount, 42.7);
+ },
+ );
+
+ blocTest