diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb
index e3f21595..7784bbea 100644
--- a/assets/languages/strings_de.arb
+++ b/assets/languages/strings_de.arb
@@ -33,8 +33,8 @@
"buyPaymentConfirmFailedAktionariat": "Es gibt ein technisches Problem. Bitte überprüfen Sie Ihr E-Mail-Postfach, möglicherweise fehlt noch eine Bestätigung Ihrer Blockchain-Adresse. Andernfalls versuchen Sie es später erneut. Falls der Fehler weiterhin besteht, kontaktieren Sie unseren Support.",
"buyPaymentInformation": "Zahlungsinformationen",
"buyPaymentInformationDescription": "Bitte überweisen Sie den Kaufbetrag mit diesen Angaben über Ihre Bankanwendung. Der Verwendungszweck ist wichtig!",
- "buyRealUnit": "RealUnit kaufen",
"buyRealu": "RealUnit Token kaufen",
+ "buyRealUnit": "RealUnit kaufen",
"cancel": "Abbrechen",
"changeAddress": "Adresse ändern",
"changeInReview": "Änderung in Prüfung",
@@ -53,11 +53,11 @@
"connectBitboxContent": "Bitte verbinden Sie Ihre BitBox mit Ihrem Smartphone.",
"connectBitboxContentIos": "Bitte verbinden Sie Ihre BitBox mit Ihrem Smartphone und aktivieren Sie zusätzlich Bluetooth.",
"connectBitboxFailed": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
- "connectBitboxSignInHint": "Nach der Code-Überprüfung wird die BitBox um eine zusätzliche Bestätigung zur Anmeldung gebeten.",
"connectBitboxSignatureCapturing": "Bitte bestätigen Sie die Anmeldeanfrage auf Ihrem BitBox-Gerät. Diese Signatur wird einmalig erfasst, damit künftige Käufe Ihre BitBox nicht erneut benötigen.",
"connectBitboxSignatureCapturingTitle": "Anmeldung bestätigen",
"connectBitboxSignatureFailed": "Ihre Anmeldesignatur konnte nicht erfasst werden. Sie können es erneut versuchen oder trotzdem fortfahren – Ihre BitBox wird dann möglicherweise für Ihren ersten Kauf erneut benötigt.",
"connectBitboxSignatureFailedTitle": "Anmeldung nicht abgeschlossen",
+ "connectBitboxSignInHint": "Nach der Code-Überprüfung wird die BitBox um eine zusätzliche Bestätigung zur Anmeldung gebeten.",
"connectBitboxTitle": "BitBox verbinden",
"connected": "Verbunden",
"connectedBitboxContent": "Bitte bestätigen Sie und folgen nun den letzten Anweisungen auf Ihrer BitBox.",
@@ -167,10 +167,38 @@
"or": "Oder",
"originalPdf": "Original-PDF",
"pay": "Bezahlen",
+ "payAwaitingSettlement": "Zahlung wird abgeschlossen",
+ "payConfirmButton": "Bezahlen",
+ "payFailureBitboxRequired": "Bitte verbinden Sie Ihre BitBox, um fortzufahren.",
+ "payFailureGeneric": "Bei der Zahlung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
+ "payFailureInsufficientEth": "Es konnten nicht genügend ETH für die Netzwerkgebühren bereitgestellt werden. Bitte versuchen Sie es später erneut.",
+ "payFailureInsufficientZchf": "Ihr REALU-Bestand reicht für diesen Betrag nicht aus.",
+ "payFailureQuoteExpired": "Das Zahlungsangebot ist abgelaufen. Bitte scannen Sie den Code erneut.",
+ "payFailureSignatureUnsupported": "Diese Wallet kann keine Transaktionen signieren. Wechseln Sie zu einer Software- oder BitBox-Wallet.",
+ "payFailureTitle": "Zahlung fehlgeschlagen",
"paymentInformationFailed": "Beim Abrufen der Zahlungsinformationen ist ein Fehler aufgetreten.",
"paymentInformationFailedDescription": "Bitte versuchen Sie es später erneut. Wenn der Fehler weiterhin besteht, wenden Sie sich an unseren Support.",
"payoutAccountAdd": "Auszahlungskonto hinzufügen",
"payoutAccountSelect": "Auszahlungskonto auswählen",
+ "payPaying": "Zahlung wird gesendet",
+ "payPreparingSwap": "Tausch wird vorbereitet",
+ "payQuoteRequested": "Geforderter Betrag",
+ "payQuoteSummary": "Sie bezahlen ${amount} ${asset}",
+ "payQuoteTitle": "Zahlung bestätigen",
+ "payQuoteUnavailable": "Für diesen Zahlungscode ist keine ZCHF-Zahlung verfügbar.",
+ "payQuoteZchfNeeded": "Benötigte ZCHF",
+ "payRefreshingQuote": "Angebot wird aktualisiert",
+ "payRetryButton": "Zahlung erneut versuchen",
+ "payRetryInsufficientZchf": "Der Preis hat sich geändert und die getauschten ZCHF decken diese Zahlung nicht mehr. Ihre ZCHF bleiben in Ihrer Wallet – versuchen Sie es erneut, um ein neues Angebot zu erhalten.",
+ "payRetryQuoteExpired": "Das Zahlungsangebot ist vor dem Abschluss abgelaufen. Ihre getauschten ZCHF bleiben in Ihrer Wallet – versuchen Sie es erneut, um ein neues Angebot zu erhalten und zu bezahlen.",
+ "payRetryTitle": "Schließen Sie Ihre Zahlung ab",
+ "payRetryTransient": "Die Zahlung konnte nicht abgeschlossen werden, aber Ihre getauschten ZCHF bleiben in Ihrer Wallet. Versuchen Sie es erneut, um ohne erneuten Tausch zu bezahlen.",
+ "payScanInvalid": "Dies ist kein gültiger RealUnit-Zahlungscode.",
+ "payScanTitle": "Zahlungscode scannen",
+ "paySuccess": "Zahlung erfolgreich",
+ "paySuccessDescription": "Ihre Zahlung wurde abgeschlossen.",
+ "paySwapping": "REALU wird in ZCHF getauscht",
+ "payWaitingForEth": "Netzwerkgebühren werden angefordert",
"pdf": "PDF",
"pendingTransactions": "Ausstehende Transaktionen",
"personalData": "Persönliche Daten",
@@ -197,8 +225,8 @@
"proofDocument": "Nachweis-Dokument",
"purposeOfPayment": "Verwendungszweck",
"qrCode": "QR-Code",
- "realunitStockToken": "RealUnit Aktientoken",
"realunitStockprice": "RealUnit Aktienkurs",
+ "realunitStockToken": "RealUnit Aktientoken",
"realunitWallet": "RealUnit Wallet",
"realunitWalletLogout": "Aus REALU Wallet abmelden",
"realunitWalletLogoutCheck": "Ich habe meine Wiederherstellungsphrase gesichert.",
@@ -246,18 +274,18 @@
"sellBitboxCheckingEth": "Wallet-Guthaben wird geprüft",
"sellBitboxDepositDescription": "Bestätigen Sie auf der BitBox, um ZCHF an die DFX-Einzahlungsadresse zu überweisen.",
"sellBitboxDepositFrom": "Sie senden",
+ "sellBitboxDepositing": "ZCHF wird gesendet. Bestätigen Sie auf der Bitbox",
"sellBitboxDepositRetryDescription": "Der Tausch wurde abgeschlossen, aber die ZCHF-Einzahlung konnte nicht gesendet werden. Ihre Mittel sind sicher. Tippen Sie auf Wiederholen.",
"sellBitboxDepositRetryTitle": "Einzahlung fehlgeschlagen",
"sellBitboxDepositTitle": "ZCHF an DFX senden",
"sellBitboxDepositTo": "DFX-Einzahlung",
- "sellBitboxDepositing": "ZCHF wird gesendet. Bestätigen Sie auf der Bitbox",
"sellBitboxEthReady": "Wallet bereit",
"sellBitboxEthReadyDescription": "Ihr Wallet hat genug ETH, um mit dem Verkauf fortzufahren.",
"sellBitboxSwapDescription": "Bestätigen Sie auf Ihrem BitBox, um REALU über den BrokerBot in ZCHF zu tauschen.",
"sellBitboxSwapFrom": "Sie senden",
+ "sellBitboxSwapping": "Tausch on-chain. Bestätigen Sie auf der Bitbox.",
"sellBitboxSwapTitle": "REALU → ZCHF tauschen",
"sellBitboxSwapTo": "Sie erhalten",
- "sellBitboxSwapping": "Tausch on-chain. Bestätigen Sie auf der Bitbox.",
"sellBitboxWaitingForEth": "Gasgebühren werden angefordert",
"sellBitboxWaitingForEthDescription": "Ein kleiner ETH-Betrag wird an Ihr Wallet gesendet, um die Transaktionsgebühren zu decken. Dies kann einige Minuten dauern.",
"sellMinAmount": "Mindestbetrag: ${amount} ${currency}",
@@ -282,10 +310,10 @@
"settingsWalletBackupSubtitle1": "Bitte notieren Sie Ihre 12 Wiederherstellungs-Wörter in der exakten Reihenfolge auf einem Blatt Papier und bewahren Sie sie absolut sicher auf.",
"settingsWalletBackupSubtitle2": "Dies ist die einzige Möglichkeit, Ihre Wallet wiederherzustellen.",
"showSeed": "Seed anzeigen",
- "signMessage": "Signierte Nachricht",
- "signMessageGet": "Signierte Nachricht abrufen",
"signature": "Signatur",
"signingCancelled": "Signatur abgebrochen — bitte BitBox erneut bestätigen",
+ "signMessage": "Signierte Nachricht",
+ "signMessageGet": "Signierte Nachricht abrufen",
"skip": "Überspringen",
"softwareTermsText": "Mit der Nutzung dieser App akzeptieren Sie die Nutzungsbedingungen dieser Software.",
"softwareTermsTextHighlighted": "Nutzungsbedingungen",
@@ -329,9 +357,9 @@
"transactionBuy": "Kauf",
"transactionHistory": "Transaktionshistorie",
"transactionPending": "In Bearbeitung",
+ "transactions": "Transaktionen",
"transactionSell": "Verkauf",
"transactionWaitingForPayment": "Warte auf Zahlung",
- "transactions": "Transaktionen",
"twoFa": "2-Faktor Authentifizierung",
"twoFaCodeRequired": "Code ist erforderlich",
"twoFaCodeTooShort": "Der Code sollte 6 Ziffern lang sein",
@@ -356,4 +384,4 @@
"youPay": "Sie bezahlen",
"youReceive": "Sie erhalten",
"youSell": "Sie verkaufen"
-}
+}
\ No newline at end of file
diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb
index 56e4db7f..d6152ae7 100644
--- a/assets/languages/strings_en.arb
+++ b/assets/languages/strings_en.arb
@@ -33,8 +33,8 @@
"buyPaymentConfirmFailedAktionariat": "There is a technical problem. Please check your email inbox — you may still need to confirm your blockchain address. Otherwise, please try again later. If the error persists, contact our support team.",
"buyPaymentInformation": "Payment information",
"buyPaymentInformationDescription": "Please transfer the purchase amount using your banking app with these details. The purpose of payment is important!",
- "buyRealUnit": "Buy RealUnit",
"buyRealu": "Buy RealUnit Token",
+ "buyRealUnit": "Buy RealUnit",
"cancel": "Cancel",
"changeAddress": "Change address",
"changeInReview": "Change in review",
@@ -53,11 +53,11 @@
"connectBitboxContent": "Please connect your BitBox with your Smartphone.",
"connectBitboxContentIos": "Please connect your BitBox with your Smartphone and activate Bluetooth.",
"connectBitboxFailed": "Something went wrong. Please try to connect again.",
- "connectBitboxSignInHint": "After verifying the code, the BitBox will ask for one additional confirmation to sign you in.",
"connectBitboxSignatureCapturing": "Please confirm the sign-in request on your BitBox device. This signature is captured once so future purchases won't need your BitBox again.",
"connectBitboxSignatureCapturingTitle": "Confirm sign-in",
"connectBitboxSignatureFailed": "We couldn't capture your sign-in signature. You can retry, or continue anyway – your BitBox may then be needed again for your first purchase.",
"connectBitboxSignatureFailedTitle": "Sign-in not completed",
+ "connectBitboxSignInHint": "After verifying the code, the BitBox will ask for one additional confirmation to sign you in.",
"connectBitboxTitle": "Connect BitBox",
"connected": "Connected",
"connectedBitboxContent": "Please confirm and follow the last steps on your BitBox.",
@@ -167,10 +167,38 @@
"or": "Or",
"originalPdf": "Original PDF",
"pay": "Pay",
+ "payAwaitingSettlement": "Completing payment",
+ "payConfirmButton": "Pay",
+ "payFailureBitboxRequired": "Please connect your BitBox to continue.",
+ "payFailureGeneric": "Something went wrong with the payment. Please try again.",
+ "payFailureInsufficientEth": "Could not provision enough ETH for network fees. Please try again later.",
+ "payFailureInsufficientZchf": "Your REALU holdings are not enough for this amount.",
+ "payFailureQuoteExpired": "The payment quote expired. Please scan the code again.",
+ "payFailureSignatureUnsupported": "This wallet cannot sign transactions. Switch to a software or BitBox wallet.",
+ "payFailureTitle": "Payment failed",
"paymentInformationFailed": "An error occurred while getting the payment information.",
"paymentInformationFailedDescription": "Please try again later. If the error persists, contact our support team.",
"payoutAccountAdd": "Add payout account",
"payoutAccountSelect": "Select payout account",
+ "payPaying": "Sending payment",
+ "payPreparingSwap": "Preparing swap",
+ "payQuoteRequested": "Requested amount",
+ "payQuoteSummary": "You pay ${amount} ${asset}",
+ "payQuoteTitle": "Confirm payment",
+ "payQuoteUnavailable": "No ZCHF payment is available for this payment code.",
+ "payQuoteZchfNeeded": "ZCHF needed",
+ "payRefreshingQuote": "Refreshing quote",
+ "payRetryButton": "Retry payment",
+ "payRetryInsufficientZchf": "The price moved and the swapped ZCHF no longer covers this payment. Your ZCHF stays in your wallet — retry to fetch a new quote.",
+ "payRetryQuoteExpired": "The payment quote expired before settling. Your swapped ZCHF stays in your wallet — retry to fetch a new quote and pay.",
+ "payRetryTitle": "Finish your payment",
+ "payRetryTransient": "The payment could not be completed, but your swapped ZCHF stays in your wallet. Retry to pay without swapping again.",
+ "payScanInvalid": "This is not a valid RealUnit payment code.",
+ "payScanTitle": "Scan payment code",
+ "paySuccess": "Payment successful",
+ "paySuccessDescription": "Your payment has been completed.",
+ "paySwapping": "Swapping REALU to ZCHF",
+ "payWaitingForEth": "Requesting network fees",
"pdf": "PDF",
"pendingTransactions": "Pending transactions",
"personalData": "Personal data",
@@ -197,8 +225,8 @@
"proofDocument": "Proof document",
"purposeOfPayment": "Purpose of payment",
"qrCode": "QR code",
- "realunitStockToken": "RealUnit Stock Token",
"realunitStockprice": "RealUnit Stockprice",
+ "realunitStockToken": "RealUnit Stock Token",
"realunitWallet": "RealUnit Wallet",
"realunitWalletLogout": "Log out of REALU Wallet",
"realunitWalletLogoutCheck": "I have backed up my recovery phrase.",
@@ -246,18 +274,18 @@
"sellBitboxCheckingEth": "Checking your wallet balance",
"sellBitboxDepositDescription": "Confirm on your BitBox to transfer ZCHF to the DFX deposit address.",
"sellBitboxDepositFrom": "You send",
+ "sellBitboxDepositing": "Sending ZCHF. Please confirm on the Bitbox.",
"sellBitboxDepositRetryDescription": "The swap was completed but the ZCHF deposit could not be sent. Your funds are safe. Tap retry to try again.",
"sellBitboxDepositRetryTitle": "Deposit failed",
"sellBitboxDepositTitle": "Send ZCHF to DFX",
"sellBitboxDepositTo": "DFX deposit",
- "sellBitboxDepositing": "Sending ZCHF. Please confirm on the Bitbox.",
"sellBitboxEthReady": "Wallet ready",
"sellBitboxEthReadyDescription": "Your wallet has enough ETH to proceed with the sale.",
"sellBitboxSwapDescription": "Confirm on your BitBox to swap REALU for ZCHF via the BrokerBot.",
"sellBitboxSwapFrom": "You send",
+ "sellBitboxSwapping": "Swapping on-chain. Please confirm on the Bitbox.",
"sellBitboxSwapTitle": "Swap REALU → ZCHF",
"sellBitboxSwapTo": "You receive",
- "sellBitboxSwapping": "Swapping on-chain. Please confirm on the Bitbox.",
"sellBitboxWaitingForEth": "Requesting gas funds",
"sellBitboxWaitingForEthDescription": "A small amount of ETH is being sent to your wallet to cover transaction fees. This may take a few minutes.",
"sellMinAmount": "Minimum amount: ${amount} ${currency}",
@@ -282,10 +310,10 @@
"settingsWalletBackupSubtitle1": "Please write down your 12 recovery words in the exact order on a piece of paper and keep them in a completely safe place.",
"settingsWalletBackupSubtitle2": "This is the only way to recover your wallet.",
"showSeed": "Show Seed",
- "signMessage": "Sign Message",
- "signMessageGet": "Get Sign Message",
"signature": "Signature",
"signingCancelled": "Signature cancelled — please confirm on the BitBox again",
+ "signMessage": "Sign Message",
+ "signMessageGet": "Get Sign Message",
"skip": "Skip",
"softwareTermsText": "By using this app, you accept the terms of use of this software.",
"softwareTermsTextHighlighted": "terms of use",
@@ -329,9 +357,9 @@
"transactionBuy": "Buy",
"transactionHistory": "Transaction history",
"transactionPending": "Processing",
+ "transactions": "Transactions",
"transactionSell": "Sell",
"transactionWaitingForPayment": "Waiting for payment",
- "transactions": "Transactions",
"twoFa": "Two-factor authentication",
"twoFaCodeRequired": "Code is required",
"twoFaCodeTooShort": "Code should be 6 digits",
@@ -356,4 +384,4 @@
"youPay": "You pay",
"youReceive": "You receive",
"youSell": "You sell"
-}
+}
\ No newline at end of file
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 421d9688..60b32b0c 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -44,7 +44,7 @@
LSRequiresIPhoneOS
NSCameraUsageDescription
- This app needs camera access to verify your identity
+ This app needs camera access to verify your identity and to scan payment codes
NSLocationWhenInUseUsageDescription
Please provide us with your geolocation data to prove your current location
NSMicrophoneUsageDescription
diff --git a/lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart b/lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart
new file mode 100644
index 00000000..13dd475a
--- /dev/null
+++ b/lib/packages/service/dfx/exceptions/payment/pay_exceptions.dart
@@ -0,0 +1,27 @@
+// Typed failures for the OCP pay flow (scan → swap → pay). Each one renders a
+// human-readable string (see `exception_surface_test.dart`) so it can surface
+// cleanly in logs, Sentry, and user-facing error states instead of the Dart
+// default `Instance of '...'`.
+
+/// The scanned QR / pasted code is not a DFX Open CryptoPay payment link.
+class InvalidPaymentLinkException implements Exception {
+ final String reason;
+
+ const InvalidPaymentLinkException(this.reason);
+
+ @override
+ String toString() => 'InvalidPaymentLinkException: $reason';
+}
+
+/// The loaded wallet cannot produce EIP-1559 signatures (today: the debug
+/// wallet). The pay flow needs to sign the swap and pay transactions locally,
+/// so it cannot proceed in this wallet mode.
+class PaySignatureUnsupportedException implements Exception {
+ // Only ever thrown / constructed as a const expression, so the zero-arg
+ // body never registers a runtime line hit; toString() below is exercised.
+ const PaySignatureUnsupportedException(); // coverage:ignore-line
+
+ @override
+ String toString() =>
+ 'PaySignatureUnsupportedException: this wallet mode cannot sign transactions';
+}
diff --git a/lib/packages/service/dfx/lnurl_decoder.dart b/lib/packages/service/dfx/lnurl_decoder.dart
new file mode 100644
index 00000000..6c778110
--- /dev/null
+++ b/lib/packages/service/dfx/lnurl_decoder.dart
@@ -0,0 +1,192 @@
+/// Decodes an Open CryptoPay POS QR into the DFX lnurlp payment-link id and the
+/// API URL the app must read the quote from.
+///
+/// Two encodings are supported, both pointing at the single allowed DFX host:
+/// 1. A LUD-01 bech32 `LNURL1...` string (carried in the `lightning` query
+/// parameter of an `https://app.dfx.swiss/pl/?lightning=LNURL1...` QR).
+/// Decoding the bech32 yields the wrapped `https://api.dfx.swiss/v1/lnurlp/pl_...`
+/// URL directly.
+/// 2. A plain `https://app.dfx.swiss/v1/lnurlp/pl_...` (or `/pl/?...`) URL,
+/// where the `app` host is rewritten to `api` as a fallback.
+///
+/// Only `app.dfx.swiss` / `api.dfx.swiss` (and their `dev.` testnet twins) are
+/// accepted — any other host is rejected so a malicious QR cannot redirect the
+/// authenticated quote read to a third party.
+library;
+
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart';
+
+class DecodedPaymentLink {
+ /// Fully-qualified `https:///v1/lnurlp/` URL the app reads the
+ /// OCP quote from.
+ final Uri lnurlpUrl;
+
+ /// The payment-link id (e.g. `pl_...` / `plp_...`).
+ final String id;
+
+ const DecodedPaymentLink({required this.lnurlpUrl, required this.id});
+}
+
+abstract final class LnurlDecoder {
+ static const _allowedHosts = {
+ 'api.dfx.swiss',
+ 'app.dfx.swiss',
+ 'dev.api.dfx.swiss',
+ 'dev.app.dfx.swiss',
+ };
+
+ // bech32 character set (BIP-173). Index in this string is the 5-bit value.
+ static const _charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
+
+ /// Decodes [raw] — the full scanned QR payload — into a [DecodedPaymentLink].
+ ///
+ /// Throws [InvalidPaymentLinkException] when the payload is neither a
+ /// DFX-hosted lnurlp URL nor a bech32 LNURL wrapping one.
+ static DecodedPaymentLink decode(String raw) {
+ final input = raw.trim();
+ if (input.isEmpty) {
+ throw const InvalidPaymentLinkException('Empty payment code');
+ }
+
+ final lightning = _extractLightningParam(input);
+ final target = lightning != null ? _decodeBech32(lightning) : input;
+
+ final uri = _parseHttpUri(target);
+ final apiUri = _toApiUri(uri);
+ final id = _extractId(apiUri);
+
+ return DecodedPaymentLink(lnurlpUrl: apiUri, id: id);
+ }
+
+ /// Pulls the `lightning=` value out of a wrapper URL/URI, or returns the raw
+ /// bech32 when the scan is a bare `lightning:LNURL1...` / `LNURL1...` string.
+ static String? _extractLightningParam(String input) {
+ final upper = input.toUpperCase();
+ if (upper.startsWith('LNURL1')) return input;
+ if (upper.startsWith('LIGHTNING:')) return input.substring('lightning:'.length);
+
+ final uri = Uri.tryParse(input);
+ final value = uri?.queryParameters['lightning'];
+ if (value != null && value.toUpperCase().startsWith('LNURL1')) return value;
+ return null;
+ }
+
+ static Uri _parseHttpUri(String value) {
+ final uri = Uri.tryParse(value);
+ if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) {
+ throw InvalidPaymentLinkException('Not a payment link: $value');
+ }
+ return uri;
+ }
+
+ /// Rewrites an allowed `app.dfx.swiss` host to its `api.dfx.swiss` twin and
+ /// forces https. Rejects any non-DFX host.
+ static Uri _toApiUri(Uri uri) {
+ if (!_allowedHosts.contains(uri.host)) {
+ throw InvalidPaymentLinkException('Unsupported payment host: ${uri.host}');
+ }
+ final apiHost = uri.host.replaceFirst('app.dfx.swiss', 'api.dfx.swiss');
+ return uri.replace(scheme: 'https', host: apiHost);
+ }
+
+ /// Extracts the `pl_...` / `plp_...` id from the lnurlp path.
+ static String _extractId(Uri uri) {
+ final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList();
+ final lnurlpIndex = segments.indexOf('lnurlp');
+ if (lnurlpIndex != -1 && lnurlpIndex + 1 < segments.length) {
+ return segments[lnurlpIndex + 1];
+ }
+ if (segments.isNotEmpty) return segments.last;
+ throw InvalidPaymentLinkException('No payment id in: $uri');
+ }
+
+ /// Decodes a LUD-01 bech32 `LNURL1...` string to its wrapped UTF-8 URL.
+ ///
+ /// LUD-01 deliberately drops the 90-char BIP-173 length limit, so only the
+ /// charset, the 1-byte-per-char separation, and the 6-char checksum are
+ /// validated here.
+ static String _decodeBech32(String bech) {
+ final lower = bech.toLowerCase();
+ final sepIndex = lower.lastIndexOf('1');
+ if (sepIndex < 1 || sepIndex + 7 > lower.length) {
+ throw InvalidPaymentLinkException('Malformed LNURL: $bech');
+ }
+
+ final hrp = lower.substring(0, sepIndex);
+ final dataPart = lower.substring(sepIndex + 1);
+
+ final data = [];
+ for (final char in dataPart.split('')) {
+ final value = _charset.indexOf(char);
+ if (value == -1) {
+ throw InvalidPaymentLinkException('Invalid LNURL character: $char');
+ }
+ data.add(value);
+ }
+
+ if (!_verifyChecksum(hrp, data)) {
+ throw const InvalidPaymentLinkException('Invalid LNURL checksum');
+ }
+
+ // Drop the 6-symbol checksum, then regroup 5-bit → 8-bit.
+ final payload = data.sublist(0, data.length - 6);
+ final bytes = _convertBitsTo8(payload);
+ return String.fromCharCodes(bytes);
+ }
+
+ /// Regroups 5-bit bech32 symbols into 8-bit bytes (no padding — the LNURL
+ /// payload is always a whole number of bytes). Rejects leftover bits that
+ /// cannot form a full byte, which signals a corrupt data section.
+ static List _convertBitsTo8(List data) {
+ const from = 5;
+ const to = 8;
+ var acc = 0;
+ var bits = 0;
+ final result = [];
+ const maxv = (1 << to) - 1;
+ for (final value in data) {
+ acc = (acc << from) | value;
+ bits += from;
+ while (bits >= to) {
+ bits -= to;
+ result.add((acc >> bits) & maxv);
+ }
+ }
+ // Defensive bech32 invariant: a checksum-valid LNURL payload always
+ // regroups into whole bytes, so this only trips on corrupt-yet-checksum-
+ // passing input, which the preceding checksum verify already rules out.
+ if (bits >= from || ((acc << (to - bits)) & maxv) != 0) {
+ throw const InvalidPaymentLinkException('Invalid LNURL padding'); // coverage:ignore-line
+ }
+ return result;
+ }
+
+ static int _polymod(List values) {
+ const generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
+ var chk = 1;
+ for (final value in values) {
+ final top = chk >> 25;
+ chk = ((chk & 0x1ffffff) << 5) ^ value;
+ for (var i = 0; i < 5; i++) {
+ if (((top >> i) & 1) == 1) chk ^= generator[i];
+ }
+ }
+ return chk;
+ }
+
+ static List _hrpExpand(String hrp) {
+ final result = [];
+ for (final c in hrp.codeUnits) {
+ result.add(c >> 5);
+ }
+ result.add(0);
+ for (final c in hrp.codeUnits) {
+ result.add(c & 31);
+ }
+ return result;
+ }
+
+ static bool _verifyChecksum(String hrp, List data) {
+ return _polymod([..._hrpExpand(hrp), ...data]) == 1;
+ }
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart
new file mode 100644
index 00000000..4f923a57
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart
@@ -0,0 +1,102 @@
+/// Public payment-link read response of `GET /v1/lnurlp/:id` (on api.dfx.swiss,
+/// no auth). Carries the requested fiat amount and the active quote the app
+/// needs to size the swap and later settle the payment. Only the fields the pay
+/// flow consumes are mapped.
+///
+/// The backend `recipient` field is a structured `PaymentLinkRecipientDto`
+/// object, not a string, and the flow never surfaces a merchant name (the real
+/// settlement recipient comes from the pay/unsigned-transaction response). It is
+/// therefore not mapped here — eagerly casting it `as String?` threw a
+/// `TypeError` whenever the backend populated it.
+class LnurlpPaymentDto {
+ final LnurlpRequestedAmountDto requestedAmount;
+ final LnurlpQuoteDto quote;
+
+ /// Per-method/chain transfer amounts. The Ethereum entry lists the exact ZCHF
+ /// amount the app must transfer; the app does not compute it locally.
+ final List transferAmounts;
+
+ const LnurlpPaymentDto({
+ required this.requestedAmount,
+ required this.quote,
+ required this.transferAmounts,
+ });
+
+ factory LnurlpPaymentDto.fromJson(Map json) {
+ final transfers = (json['transferAmounts'] as List?) ?? const [];
+ return LnurlpPaymentDto(
+ requestedAmount: LnurlpRequestedAmountDto.fromJson(
+ json['requestedAmount'] as Map,
+ ),
+ quote: LnurlpQuoteDto.fromJson(json['quote'] as Map),
+ transferAmounts: transfers
+ .map((e) => LnurlpTransferAmountDto.fromJson(e as Map))
+ .toList(),
+ );
+ }
+}
+
+class LnurlpRequestedAmountDto {
+ final String asset;
+ final double amount;
+
+ const LnurlpRequestedAmountDto({required this.asset, required this.amount});
+
+ factory LnurlpRequestedAmountDto.fromJson(Map json) {
+ return LnurlpRequestedAmountDto(
+ asset: json['asset'] as String,
+ amount: (json['amount'] as num).toDouble(),
+ );
+ }
+}
+
+class LnurlpQuoteDto {
+ final String id;
+ final DateTime expiration;
+
+ const LnurlpQuoteDto({required this.id, required this.expiration});
+
+ factory LnurlpQuoteDto.fromJson(Map json) {
+ return LnurlpQuoteDto(
+ id: json['id'] as String,
+ expiration: DateTime.parse(json['expiration'] as String),
+ );
+ }
+}
+
+class LnurlpTransferAmountDto {
+ final String method;
+ final List assets;
+
+ const LnurlpTransferAmountDto({required this.method, required this.assets});
+
+ factory LnurlpTransferAmountDto.fromJson(Map json) {
+ final assets = (json['assets'] as List?) ?? const [];
+ return LnurlpTransferAmountDto(
+ method: json['method'] as String,
+ assets: assets
+ .map((e) => LnurlpTransferAssetDto.fromJson(e as Map))
+ .toList(),
+ );
+ }
+}
+
+class LnurlpTransferAssetDto {
+ final String asset;
+
+ /// Optional on the backend (`amount?`): the non-priced display path emits
+ /// amount-less asset entries. Parsed as nullable so reading the whole quote
+ /// never throws — the pay flow only requires the amount for the asset it
+ /// actually transfers (ZCHF on Ethereum), filtered before it is read.
+ final double? amount;
+
+ const LnurlpTransferAssetDto({required this.asset, this.amount});
+
+ factory LnurlpTransferAssetDto.fromJson(Map json) {
+ final amount = json['amount'] as num?;
+ return LnurlpTransferAssetDto(
+ asset: json['asset'] as String,
+ amount: amount?.toDouble(),
+ );
+ }
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart
new file mode 100644
index 00000000..7393a562
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart
@@ -0,0 +1,14 @@
+/// Request body for `PUT /v1/realunit/pay/unsigned-transaction`. References the
+/// scanned payment link and its active quote so the backend resolves recipient
+/// and exact ZCHF amount.
+class RealUnitOcpPayDto {
+ final String paymentLinkId;
+ final String quoteId;
+
+ const RealUnitOcpPayDto({required this.paymentLinkId, required this.quoteId});
+
+ Map toJson() => {
+ 'paymentLinkId': paymentLinkId,
+ 'quoteId': quoteId,
+ };
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart
new file mode 100644
index 00000000..1a90a2b4
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart
@@ -0,0 +1,11 @@
+/// Response of `PUT /v1/realunit/pay/submit` — the blockchain transaction id of
+/// the submitted ZCHF payment.
+class RealUnitOcpPayResultDto {
+ final String txId;
+
+ const RealUnitOcpPayResultDto({required this.txId});
+
+ factory RealUnitOcpPayResultDto.fromJson(Map json) {
+ return RealUnitOcpPayResultDto(txId: json['txId'] as String);
+ }
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart
new file mode 100644
index 00000000..b03f4e0a
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart
@@ -0,0 +1,41 @@
+/// Mirrors the backend `PaymentLinkPaymentStatus` enum 1:1 (type-safe DTO
+/// mirroring, not local business logic). The backend remains the authority on
+/// the payment status; the app renders it and uses [isTerminal] / [isCompleted]
+/// only to decide when to stop polling and which UI state to show.
+enum OcpPaymentStatus {
+ pending('Pending'),
+ completed('Completed'),
+ cancelled('Cancelled'),
+ expired('Expired'),
+ unknown('')
+ ;
+
+ final String value;
+
+ const OcpPaymentStatus(this.value);
+
+ static OcpPaymentStatus fromValue(String value) {
+ return OcpPaymentStatus.values.firstWhere(
+ (s) => s.value == value,
+ orElse: () => OcpPaymentStatus.unknown,
+ );
+ }
+
+ /// Polling stops once the payment reaches a final state.
+ bool get isTerminal => this == completed || this == cancelled || this == expired;
+
+ bool get isCompleted => this == completed;
+}
+
+/// Response of `GET /v1/realunit/pay/:id/status`.
+class RealUnitOcpPayStatusDto {
+ final OcpPaymentStatus status;
+
+ const RealUnitOcpPayStatusDto({required this.status});
+
+ factory RealUnitOcpPayStatusDto.fromJson(Map json) {
+ return RealUnitOcpPayStatusDto(
+ status: OcpPaymentStatus.fromValue(json['status'] as String),
+ );
+ }
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart
new file mode 100644
index 00000000..acd5740b
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart
@@ -0,0 +1,30 @@
+/// Request body for `PUT /v1/realunit/pay/submit`. The signed-tx envelope
+/// (`unsignedTx` + `r`/`s`/`v`) mirrors the sell/swap broadcast shape, plus the
+/// payment-link/quote references so the backend forwards the hex into the
+/// lnurlp settlement path.
+class RealUnitOcpPaySubmitDto {
+ final String unsignedTx;
+ final String r;
+ final String s;
+ final int v;
+ final String paymentLinkId;
+ final String quoteId;
+
+ const RealUnitOcpPaySubmitDto({
+ required this.unsignedTx,
+ required this.r,
+ required this.s,
+ required this.v,
+ required this.paymentLinkId,
+ required this.quoteId,
+ });
+
+ Map toJson() => {
+ 'unsignedTx': unsignedTx,
+ 'r': r,
+ 's': s,
+ 'v': v,
+ 'paymentLinkId': paymentLinkId,
+ 'quoteId': quoteId,
+ };
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart
new file mode 100644
index 00000000..e887938f
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart
@@ -0,0 +1,28 @@
+/// Response of `PUT /v1/realunit/pay/unsigned-transaction` — the serialized
+/// unsigned EIP-1559 ZCHF transfer transaction to the OCP recipient, plus the
+/// metadata the app shows / can sanity-check before signing.
+class RealUnitOcpPayUnsignedTransactionDto {
+ final String unsignedTx;
+ final String tokenAddress;
+ final String recipient;
+ final String amountWei;
+ final int chainId;
+
+ const RealUnitOcpPayUnsignedTransactionDto({
+ required this.unsignedTx,
+ required this.tokenAddress,
+ required this.recipient,
+ required this.amountWei,
+ required this.chainId,
+ });
+
+ factory RealUnitOcpPayUnsignedTransactionDto.fromJson(Map json) {
+ return RealUnitOcpPayUnsignedTransactionDto(
+ unsignedTx: json['unsignedTx'] as String,
+ tokenAddress: json['tokenAddress'] as String,
+ recipient: json['recipient'] as String,
+ amountWei: json['amountWei'] as String,
+ chainId: json['chainId'] as int,
+ );
+ }
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart
new file mode 100644
index 00000000..e16c9578
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart
@@ -0,0 +1,21 @@
+/// Request body for `PUT /v1/realunit/swap`. The backend enforces the `amount`
+/// XOR `targetAmount` rule; the app sends exactly one. `amount` is in REALU
+/// shares, `targetAmount` is in ZCHF. IBAN-free by design (proceeds stay in the
+/// user wallet).
+class RealUnitSwapDto {
+ /// Amount of REALU shares to swap.
+ final int? amount;
+
+ /// Target amount in ZCHF (alternative to [amount]).
+ final double? targetAmount;
+
+ // The OCP pay flow always sizes the swap by ZCHF target (fromTargetAmount);
+ // `amount` stays in the body only to document the backend's XOR contract and
+ // is always null on the wire here.
+ const RealUnitSwapDto.fromTargetAmount(double this.targetAmount) : amount = null;
+
+ Map toJson() => {
+ if (amount != null) 'amount': amount,
+ if (targetAmount != null) 'targetAmount': targetAmount,
+ };
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart
new file mode 100644
index 00000000..54dee50d
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart
@@ -0,0 +1,58 @@
+/// Response of `PUT /v1/realunit/swap` — the REALU → ZCHF swap quote. The
+/// backend is the authority on validity, limits, fees and the ZCHF estimate;
+/// the app renders these fields and never recomputes them.
+class RealUnitSwapPaymentInfoDto {
+ final int id;
+ final String uid;
+ final int routeId;
+ final DateTime timestamp;
+ final double amount;
+ final double estimatedAmount;
+ final String targetAsset;
+ final double minVolume;
+ final double maxVolume;
+ final double minVolumeTarget;
+ final double maxVolumeTarget;
+ final double ethBalance;
+ final double requiredGasEth;
+ final bool isValid;
+ final String? error;
+
+ const RealUnitSwapPaymentInfoDto({
+ required this.id,
+ required this.uid,
+ required this.routeId,
+ required this.timestamp,
+ required this.amount,
+ required this.estimatedAmount,
+ required this.targetAsset,
+ required this.minVolume,
+ required this.maxVolume,
+ required this.minVolumeTarget,
+ required this.maxVolumeTarget,
+ required this.ethBalance,
+ required this.requiredGasEth,
+ required this.isValid,
+ this.error,
+ });
+
+ factory RealUnitSwapPaymentInfoDto.fromJson(Map json) {
+ return RealUnitSwapPaymentInfoDto(
+ id: json['id'] as int,
+ uid: json['uid'] as String,
+ routeId: json['routeId'] as int,
+ timestamp: DateTime.parse(json['timestamp'] as String),
+ amount: (json['amount'] as num).toDouble(),
+ estimatedAmount: (json['estimatedAmount'] as num).toDouble(),
+ targetAsset: json['targetAsset'] as String,
+ minVolume: (json['minVolume'] as num).toDouble(),
+ maxVolume: (json['maxVolume'] as num).toDouble(),
+ minVolumeTarget: (json['minVolumeTarget'] as num).toDouble(),
+ maxVolumeTarget: (json['maxVolumeTarget'] as num).toDouble(),
+ ethBalance: (json['ethBalance'] as num).toDouble(),
+ requiredGasEth: (json['requiredGasEth'] as num).toDouble(),
+ isValid: json['isValid'] as bool,
+ error: json['error'] as String?,
+ );
+ }
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart
new file mode 100644
index 00000000..5e0fe5b6
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart
@@ -0,0 +1,12 @@
+/// Response of `PUT /v1/realunit/swap/:id/unsigned-transaction` — the
+/// serialized unsigned EIP-1559 REALU `transferAndCall` swap transaction hex
+/// (no deposit sweep; ZCHF lands in the user wallet).
+class RealUnitSwapUnsignedTransactionDto {
+ final String swap;
+
+ const RealUnitSwapUnsignedTransactionDto({required this.swap});
+
+ factory RealUnitSwapUnsignedTransactionDto.fromJson(Map json) {
+ return RealUnitSwapUnsignedTransactionDto(swap: json['swap'] as String);
+ }
+}
diff --git a/lib/packages/service/dfx/models/payment/pay/swap_payment_info.dart b/lib/packages/service/dfx/models/payment/pay/swap_payment_info.dart
new file mode 100644
index 00000000..b1704adc
--- /dev/null
+++ b/lib/packages/service/dfx/models/payment/pay/swap_payment_info.dart
@@ -0,0 +1,50 @@
+import 'package:equatable/equatable.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart';
+
+/// Domain model for an IBAN-free REALU → ZCHF swap quote. The backend decides
+/// validity, limits and the ZCHF estimate; this model only carries those fields
+/// for the flow's cubits to render and to drive the ETH-balance / swap steps.
+class SwapPaymentInfo extends Equatable {
+ final int id;
+ final double amount;
+ final double estimatedAmount;
+ final String targetAsset;
+ final double ethBalance;
+ final double requiredGasEth;
+ final bool isValid;
+ final String? error;
+
+ const SwapPaymentInfo({
+ required this.id,
+ required this.amount,
+ required this.estimatedAmount,
+ required this.targetAsset,
+ required this.ethBalance,
+ required this.requiredGasEth,
+ required this.isValid,
+ this.error,
+ });
+
+ factory SwapPaymentInfo.fromDto(RealUnitSwapPaymentInfoDto dto) => SwapPaymentInfo(
+ id: dto.id,
+ amount: dto.amount,
+ estimatedAmount: dto.estimatedAmount,
+ targetAsset: dto.targetAsset,
+ ethBalance: dto.ethBalance,
+ requiredGasEth: dto.requiredGasEth,
+ isValid: dto.isValid,
+ error: dto.error,
+ );
+
+ @override
+ List get props => [
+ id,
+ amount,
+ estimatedAmount,
+ targetAsset,
+ ethBalance,
+ requiredGasEth,
+ isValid,
+ error,
+ ];
+}
diff --git a/lib/packages/service/dfx/real_unit_pay_service.dart b/lib/packages/service/dfx/real_unit_pay_service.dart
new file mode 100644
index 00000000..535d4f47
--- /dev/null
+++ b/lib/packages/service/dfx/real_unit_pay_service.dart
@@ -0,0 +1,152 @@
+import 'dart:convert';
+
+import 'package:realunit_wallet/packages/config/api_config.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_request_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_response_dto.dart';
+
+/// Backend client for the Open CryptoPay pay flow (DFXswiss/api #3819, all under
+/// `/v1/realunit/...`). Subclasses [DFXAuthService] for the JWT handshake +
+/// retry-on-401 the sell flow already uses; the public lnurlp read is the only
+/// unauthenticated call.
+class RealUnitPayService extends DFXAuthService {
+ static const _lnurlpPath = '/v1/lnurlp';
+ static const _swapPath = '/v1/realunit/swap';
+ static String _swapUnsignedTxPath(int id) => '/v1/realunit/swap/$id/unsigned-transaction';
+ static String _swapBroadcastPath(int id) => '/v1/realunit/swap/$id/broadcast';
+ static const _payUnsignedTxPath = '/v1/realunit/pay/unsigned-transaction';
+ static const _paySubmitPath = '/v1/realunit/pay/submit';
+ static String _payStatusPath(String id) => '/v1/realunit/pay/$id/status';
+
+ static const _httpTimeout = Duration(seconds: 20);
+
+ RealUnitPayService(super.appStore, super.walletService);
+
+ /// Public OCP payment-link read (no auth). Returns the requested fiat amount,
+ /// the active quote (id + expiration) and the per-method transfer amounts.
+ Future getPaymentDetails(String id) async {
+ final uri = buildUri(host, '$_lnurlpPath/$id');
+ final response = await appStore.httpClient
+ .get(uri, headers: {'accept': 'application/json'})
+ .timeout(_httpTimeout);
+
+ if (response.statusCode != 200) {
+ _throwApi(response.body, response.statusCode);
+ }
+ return LnurlpPaymentDto.fromJson(jsonDecode(response.body) as Map);
+ }
+
+ // --- Swap (REALU → ZCHF, proceeds stay in the user wallet) ---
+
+ Future getSwapPaymentInfo(RealUnitSwapDto dto) async {
+ final uri = buildUri(host, _swapPath);
+ final response = await authenticatedPut(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ body: jsonEncode(dto.toJson()),
+ );
+
+ if (response.statusCode != 200 && response.statusCode != 201) {
+ _throwApi(response.body, response.statusCode);
+ }
+ final responseDto = RealUnitSwapPaymentInfoDto.fromJson(
+ jsonDecode(response.body) as Map,
+ );
+ return SwapPaymentInfo.fromDto(responseDto);
+ }
+
+ Future createSwapUnsignedTransaction(int id) async {
+ final uri = buildUri(host, _swapUnsignedTxPath(id));
+ final response = await authenticatedPut(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ );
+
+ if (response.statusCode != 200 && response.statusCode != 201) {
+ _throwApi(response.body, response.statusCode);
+ }
+ return RealUnitSwapUnsignedTransactionDto.fromJson(
+ jsonDecode(response.body) as Map,
+ );
+ }
+
+ Future broadcastSwapTransaction(int id, BroadcastTransactionRequestDto dto) async {
+ final uri = buildUri(host, _swapBroadcastPath(id));
+ final response = await authenticatedPut(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ body: jsonEncode(dto.toJson()),
+ );
+
+ if (response.statusCode != 200 && response.statusCode != 201) {
+ _throwApi(response.body, response.statusCode);
+ }
+ return BroadcastTransactionResponseDto.fromJson(
+ jsonDecode(response.body) as Map,
+ ).txHash;
+ }
+
+ // --- OCP pay (settle a ZCHF payment-link quote via the lnurlp flow) ---
+
+ Future createPayUnsignedTransaction(
+ RealUnitOcpPayDto dto,
+ ) async {
+ final uri = buildUri(host, _payUnsignedTxPath);
+ final response = await authenticatedPut(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ body: jsonEncode(dto.toJson()),
+ );
+
+ if (response.statusCode != 200 && response.statusCode != 201) {
+ _throwApi(response.body, response.statusCode);
+ }
+ return RealUnitOcpPayUnsignedTransactionDto.fromJson(
+ jsonDecode(response.body) as Map,
+ );
+ }
+
+ Future submitPay(RealUnitOcpPaySubmitDto dto) async {
+ final uri = buildUri(host, _paySubmitPath);
+ final response = await authenticatedPut(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ body: jsonEncode(dto.toJson()),
+ );
+
+ if (response.statusCode != 200 && response.statusCode != 201) {
+ _throwApi(response.body, response.statusCode);
+ }
+ return RealUnitOcpPayResultDto.fromJson(
+ jsonDecode(response.body) as Map,
+ ).txId;
+ }
+
+ Future getPayStatus(String id) async {
+ final uri = buildUri(host, _payStatusPath(id));
+ final response = await authenticatedGet(uri);
+
+ if (response.statusCode != 200) {
+ _throwApi(response.body, response.statusCode);
+ }
+ return RealUnitOcpPayStatusDto.fromJson(
+ jsonDecode(response.body) as Map,
+ );
+ }
+
+ Never _throwApi(String body, int statusCode) {
+ final errorJson = jsonDecode(body) as Map;
+ throw ApiException.fromJson(errorJson, httpStatusCode: statusCode);
+ }
+}
diff --git a/lib/screens/dashboard/widgets/sections/dashboard_actions.dart b/lib/screens/dashboard/widgets/sections/dashboard_actions.dart
index 9b68eed6..2fb38a1f 100644
--- a/lib/screens/dashboard/widgets/sections/dashboard_actions.dart
+++ b/lib/screens/dashboard/widgets/sections/dashboard_actions.dart
@@ -13,23 +13,38 @@ class DashboardActions extends StatelessWidget {
return Row(
spacing: 10,
children: [
- ActionButton(
- icon: Icon(
- Icons.add_circle_rounded,
- color: RealUnitColors.basic.white,
- size: 20,
+ Expanded(
+ child: ActionButton(
+ icon: Icon(
+ Icons.add_circle_rounded,
+ color: RealUnitColors.basic.white,
+ size: 20,
+ ),
+ label: S.of(context).buy,
+ onPressed: () => context.pushNamed(AppRoutes.buy),
),
- label: S.of(context).buy,
- onPressed: () => context.pushNamed(AppRoutes.buy),
),
- ActionButton(
- icon: Icon(
- Icons.do_not_disturb_on_rounded,
- color: RealUnitColors.basic.white,
- size: 20,
+ Expanded(
+ child: ActionButton(
+ icon: Icon(
+ Icons.do_not_disturb_on_rounded,
+ color: RealUnitColors.basic.white,
+ size: 20,
+ ),
+ label: S.of(context).sell,
+ onPressed: () => context.pushNamed(AppRoutes.sell),
+ ),
+ ),
+ Expanded(
+ child: ActionButton(
+ icon: Icon(
+ Icons.qr_code_scanner_rounded,
+ color: RealUnitColors.basic.white,
+ size: 20,
+ ),
+ label: S.of(context).pay,
+ onPressed: () => context.pushNamed(AppRoutes.pay),
),
- label: S.of(context).sell,
- onPressed: () => context.pushNamed(AppRoutes.sell),
),
],
);
diff --git a/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart
new file mode 100644
index 00000000..d1d59e81
--- /dev/null
+++ b/lib/screens/pay/cubits/pay_process/pay_process_cubit.dart
@@ -0,0 +1,342 @@
+import 'dart:async';
+import 'dart:typed_data';
+
+import 'package:convert/convert.dart' as convert;
+import 'package:equatable/equatable.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_request_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/packages/service/wallet_service.dart';
+import 'package:realunit_wallet/packages/wallet/wallet.dart';
+import 'package:web3dart/crypto.dart';
+
+part 'pay_process_state.dart';
+
+/// Orchestrates the on-chain half of the OCP pay flow after the user confirms a
+/// quote: check ETH gas → swap REALU→ZCHF (sign + broadcast) → re-fetch the OCP
+/// quote (fresh quoteId, guards expiry between swap and pay) → pay (sign +
+/// submit) → poll status until terminal.
+///
+/// Signing uses the unified raw-payload path (`signToSignature` → r/s/v) for
+/// BOTH software and BitBox wallets — the backend returns the unsigned txs, the
+/// app signs them the same way regardless of wallet mode. The flow is NOT
+/// branched on `walletType`; only the genuine capability gap (a debug wallet
+/// that cannot sign) is gated, surfacing [PaySignaturePending] →
+/// [PaySignatureUnsupportedException].
+class PayProcessCubit extends Cubit {
+ final RealUnitPayService _payService;
+ final DfxFaucetService _faucetService;
+ final DfxBlockchainApiService _blockchainService;
+ final WalletService _walletService;
+ final AppStore _appStore;
+
+ final String _paymentLinkId;
+ final double _zchfNeeded;
+
+ SwapPaymentInfo? _swap;
+
+ /// Set once the REALU→ZCHF swap has been broadcast successfully. From this
+ /// point the user holds ZCHF and recovery must NEVER re-swap — the pay leg is
+ /// retried on its own via [retryPay].
+ bool _swapCompleted = false;
+
+ /// ZCHF acquired by the (completed) swap — the backend `estimatedAmount` of
+ /// the swap quote. Used to detect when a freshly re-fetched settlement amount
+ /// can no longer be covered by what we actually hold.
+ double _acquiredZchf = 0;
+
+ Timer? _ethPollingTimer;
+ Timer? _statusPollingTimer;
+
+ /// Headroom over the OCP ZCHF amount when sizing the swap target. The swap is
+ /// quoted/broadcast against the ORIGINAL OCP quote, but the pay step settles
+ /// the EXACT amount of a FRESHLY re-fetched quote; in between, the OCP price
+ /// (CHF→ZCHF) and the swap rate can both move. A 1% buffer left no margin for
+ /// the common case (a few minutes of drift + the OCP/swap fees), so any
+ /// adverse move stranded the user in ZCHF that could not cover settlement.
+ /// 3% is a pragmatic headroom that absorbs ordinary drift while keeping the
+ /// over-swap small (leftover ZCHF simply stays in the wallet); a larger move
+ /// is caught explicitly and surfaced as a retryable
+ /// [PayRetryReason.insufficientZchf] rather than a server-side failure.
+ static const _slippageBuffer = 1.03;
+
+ static const _ethPollInterval = Duration(seconds: 5);
+ static const _statusPollInterval = Duration(seconds: 3);
+
+ PayProcessCubit({
+ required RealUnitPayService payService,
+ required DfxFaucetService faucetService,
+ required DfxBlockchainApiService blockchainService,
+ required WalletService walletService,
+ required AppStore appStore,
+ required String paymentLinkId,
+ required double zchfNeeded,
+ }) : _payService = payService,
+ _faucetService = faucetService,
+ _blockchainService = blockchainService,
+ _walletService = walletService,
+ _appStore = appStore,
+ _paymentLinkId = paymentLinkId,
+ _zchfNeeded = zchfNeeded,
+ super(const PayProcessInitial());
+
+ /// Entry point — called by the view once the user confirms the quote.
+ Future start() async {
+ // Capability gate — checked BEFORE any on-chain action: the debug wallet
+ // cannot produce EIP-1559 signatures, so the irreversible REALU→ZCHF swap
+ // must never run on it. The backend settles OCP on every environment
+ // (Sepolia off-PRD, mainnet+L2 on PRD), so there is no environment gate
+ // here — the flow requests the real quote and surfaces a typed backend
+ // error if one ever comes back.
+ if (_appStore.wallet.walletType == WalletType.debug) {
+ emit(const PayProcessFailure(PayProcessFailureReason.signatureUnsupported));
+ return;
+ }
+ await _requestSwapQuote();
+ }
+
+ Future _requestSwapQuote() async {
+ try {
+ emit(const PayProcessPreparingSwap());
+ final swap = await _payService.getSwapPaymentInfo(
+ RealUnitSwapDto.fromTargetAmount(_zchfNeeded * _slippageBuffer),
+ );
+ _swap = swap;
+
+ // The API is the authority on whether the swap is fundable; render its
+ // signal rather than recomputing limits locally.
+ if (!swap.isValid) {
+ emit(const PayProcessFailure(PayProcessFailureReason.insufficientZchf));
+ return;
+ }
+
+ await _checkEthBalance(swap);
+ } catch (e) {
+ emit(PayProcessFailure(PayProcessFailureReason.generic, message: e.toString()));
+ }
+ }
+
+ Future _checkEthBalance(SwapPaymentInfo swap) async {
+ if (swap.ethBalance >= swap.requiredGasEth) {
+ await _executeSwap();
+ return;
+ }
+ await _requestFaucet(swap);
+ }
+
+ Future _requestFaucet(SwapPaymentInfo swap) async {
+ try {
+ emit(const PayProcessWaitingForEth());
+ await _faucetService.requestFaucet();
+ _startEthPolling(swap);
+ } catch (e) {
+ emit(PayProcessFailure(PayProcessFailureReason.insufficientEth, message: e.toString()));
+ }
+ }
+
+ void _startEthPolling(SwapPaymentInfo swap) {
+ _ethPollingTimer?.cancel();
+ _ethPollingTimer = Timer.periodic(_ethPollInterval, (_) async {
+ try {
+ final balance = await _blockchainService.getEthBalance(_appStore.primaryAddress);
+ if (balance >= swap.requiredGasEth) {
+ _ethPollingTimer?.cancel();
+ await _executeSwap();
+ }
+ } catch (_) {
+ // keep polling on transient errors
+ }
+ });
+ }
+
+ Future _executeSwap() async {
+ final swap = _swap;
+ if (swap == null) return;
+ try {
+ emit(const PayProcessSwapping());
+ final unsigned = await _payService.createSwapUnsignedTransaction(swap.id);
+ final signed = await _signTransaction(unsigned.swap);
+ await _payService.broadcastSwapTransaction(swap.id, signed);
+ // The swap is now irreversible — the user holds ZCHF. From here every
+ // recovery path retries the PAY leg only; the swap is never redone.
+ _swapCompleted = true;
+ _acquiredZchf = swap.estimatedAmount;
+ await _refreshQuoteAndPay();
+ } on PaySignatureUnsupportedException {
+ emit(const PayProcessFailure(PayProcessFailureReason.signatureUnsupported));
+ } on BitboxNotConnectedException {
+ emit(const PayProcessFailure(PayProcessFailureReason.bitboxRequired));
+ } catch (e) {
+ emit(PayProcessFailure(PayProcessFailureReason.generic, message: e.toString()));
+ }
+ }
+
+ /// Retries the pay leg ONLY, after a successful swap. Re-fetches the OCP quote
+ /// and re-runs sign + submit; it never re-swaps (guarded by [_swapCompleted]),
+ /// so the ZCHF already in the wallet is reused and REALU is never
+ /// double-converted. Wired to the retry action on [PayProcessPayRetry].
+ Future retryPay() async {
+ if (!_swapCompleted) return;
+ await _refreshQuoteAndPay();
+ }
+
+ /// Re-reads the OCP quote so the pay step uses a fresh quoteId — the swap may
+ /// have taken longer than the original quote's validity window. Runs both on
+ /// the first pay attempt (right after the swap) and on every [retryPay].
+ ///
+ /// A GENUINE expiry (the explicit `expiration.isBefore(now)` check) and a
+ /// TRANSIENT fetch error are kept distinct: both are recoverable by retrying
+ /// the pay leg, so neither forces a re-scan → re-swap.
+ Future _refreshQuoteAndPay() async {
+ final LnurlpPaymentDto details;
+ try {
+ emit(const PayProcessRefreshingQuote());
+ details = await _payService.getPaymentDetails(_paymentLinkId);
+ } catch (e) {
+ // Transient/network error fetching the quote — NOT a genuine expiry.
+ // Retry the pay leg; the swapped ZCHF stays in the wallet.
+ emit(PayProcessPayRetry(PayRetryReason.transient, message: e.toString()));
+ return;
+ }
+
+ if (details.quote.expiration.isBefore(DateTime.now())) {
+ emit(const PayProcessPayRetry(PayRetryReason.quoteExpired));
+ return;
+ }
+
+ // Guard the slippage boundary: the swap acquired [_acquiredZchf], but the
+ // fresh quote may now demand more ZCHF than that. Settling it would fail
+ // server-side AFTER the irreversible swap, so surface a typed, retryable
+ // state (re-quote may land within the held ZCHF) instead of an opaque
+ // failure. The leftover ZCHF stays in the wallet.
+ final freshZchf = _zchfTransferAmount(details);
+ if (freshZchf != null && freshZchf > _acquiredZchf) {
+ emit(
+ PayProcessPayRetry(
+ PayRetryReason.insufficientZchf,
+ message: 'fresh settlement $freshZchf ZCHF exceeds acquired $_acquiredZchf ZCHF',
+ ),
+ );
+ return;
+ }
+
+ await _executePay(details.quote.id);
+ }
+
+ Future _executePay(String quoteId) async {
+ try {
+ emit(const PayProcessPaying());
+ final RealUnitOcpPayUnsignedTransactionDto unsigned = await _payService
+ .createPayUnsignedTransaction(
+ RealUnitOcpPayDto(paymentLinkId: _paymentLinkId, quoteId: quoteId),
+ );
+ final signed = await _signTransaction(unsigned.unsignedTx);
+ final txId = await _payService.submitPay(
+ RealUnitOcpPaySubmitDto(
+ unsignedTx: signed.unsignedTx,
+ r: signed.r,
+ s: signed.s,
+ v: signed.v,
+ paymentLinkId: _paymentLinkId,
+ quoteId: quoteId,
+ ),
+ );
+ emit(PayProcessAwaitingSettlement(txId));
+ _startStatusPolling();
+ } catch (e) {
+ // The swap already happened; the user holds ZCHF. Any pay-leg failure
+ // here (signing dropped, BitBox disconnect, transient submit error,
+ // settlement rejected) is recoverable by retrying the pay leg — never by
+ // re-swapping. Surface the retryable state rather than a terminal failure.
+ emit(PayProcessPayRetry(PayRetryReason.transient, message: e.toString()));
+ }
+ }
+
+ /// The ZCHF amount listed for the Ethereum transfer method in a fresh quote,
+ /// or null if the link no longer offers a priced Ethereum/ZCHF method. Mirrors
+ /// [PayQuoteCubit]'s selection — the app never computes the amount locally.
+ static double? _zchfTransferAmount(LnurlpPaymentDto details) {
+ for (final transfer in details.transferAmounts) {
+ if (transfer.method.toLowerCase() != 'ethereum') continue;
+ for (final asset in transfer.assets) {
+ if (asset.asset.toUpperCase() == 'ZCHF') return asset.amount;
+ }
+ }
+ return null;
+ }
+
+ void _startStatusPolling() {
+ _statusPollingTimer?.cancel();
+ _statusPollingTimer = Timer.periodic(_statusPollInterval, (_) async {
+ try {
+ final status = await _payService.getPayStatus(_paymentLinkId);
+ if (!status.status.isTerminal) return;
+ _statusPollingTimer?.cancel();
+ if (status.status.isCompleted) {
+ emit(const PayProcessSuccess());
+ } else {
+ // The engine reached a terminal non-completed status (e.g. the quote
+ // expired or was cancelled before it settled). The user still holds
+ // the swapped ZCHF, so this is recoverable by retrying the pay leg.
+ emit(const PayProcessPayRetry(PayRetryReason.transient));
+ }
+ } catch (_) {
+ // keep polling on transient errors
+ }
+ });
+ }
+
+ /// Signs a serialized unsigned EIP-1559 tx with the active wallet credentials
+ /// and returns the broadcast envelope (`unsignedTx` + r/s/v). Works for
+ /// software and BitBox; a debug wallet's `signToSignature` throws
+ /// [UnsupportedError], normalised here to [PaySignatureUnsupportedException].
+ Future _signTransaction(String rawTransaction) async {
+ await _walletService.ensureCurrentWalletUnlocked();
+ try {
+ final credentials = _appStore.wallet.currentAccount.primaryAddress;
+ final payload = Uint8List.fromList(
+ convert.hex.decode(
+ rawTransaction.startsWith('0x') ? rawTransaction.substring(2) : rawTransaction,
+ ),
+ );
+ final MsgSignature sig;
+ try {
+ sig = await credentials.signToSignature(
+ payload,
+ chainId: _appStore.apiConfig.asset.chainId,
+ isEIP1559: true,
+ );
+ } on UnsupportedError {
+ throw const PaySignatureUnsupportedException();
+ }
+ final r = sig.r.toRadixString(16).padLeft(64, '0');
+ final s = sig.s.toRadixString(16).padLeft(64, '0');
+ return BroadcastTransactionRequestDto(
+ unsignedTx: rawTransaction,
+ r: '0x$r',
+ s: '0x$s',
+ v: sig.v,
+ );
+ } finally {
+ await _walletService.lockCurrentWallet();
+ }
+ }
+
+ @override
+ Future close() {
+ _ethPollingTimer?.cancel();
+ _statusPollingTimer?.cancel();
+ return super.close();
+ }
+}
diff --git a/lib/screens/pay/cubits/pay_process/pay_process_state.dart b/lib/screens/pay/cubits/pay_process/pay_process_state.dart
new file mode 100644
index 00000000..fe836311
--- /dev/null
+++ b/lib/screens/pay/cubits/pay_process/pay_process_state.dart
@@ -0,0 +1,113 @@
+part of 'pay_process_cubit.dart';
+
+/// Why the pay flow failed. Each reason maps to a localized, user-facing
+/// message in the view — the cubit carries the reason, not the copy.
+enum PayProcessFailureReason {
+ /// The swap quote came back invalid (e.g. not fundable for the requested
+ /// ZCHF amount after the slippage buffer).
+ insufficientZchf,
+
+ /// Not enough ETH to cover gas and the faucet top-up did not arrive.
+ insufficientEth,
+
+ /// The active wallet mode cannot sign transactions (debug wallet).
+ signatureUnsupported,
+
+ /// A BitBox is required but not connected.
+ bitboxRequired,
+
+ /// Any other unexpected error.
+ generic,
+}
+
+/// Why the pay leg failed AFTER the REALU→ZCHF swap already succeeded. The user
+/// holds ZCHF, so recovery must retry the pay leg ONLY (re-quote + sign +
+/// submit) — never the swap. Each reason maps to a localized message.
+enum PayRetryReason {
+ /// The OCP quote expired between the swap and the pay step. Re-quoting is
+ /// safe — the swapped ZCHF stays in the wallet.
+ quoteExpired,
+
+ /// A transient/network error while re-fetching the quote or settling. Not a
+ /// genuine expiry; retrying the pay leg is the correct recovery.
+ transient,
+
+ /// The freshly re-fetched settlement amount exceeds the ZCHF acquired by the
+ /// swap (price moved more than the swap headroom buffer). Re-quoting may land
+ /// within the held ZCHF; the leftover ZCHF stays in the wallet meanwhile.
+ insufficientZchf,
+}
+
+sealed class PayProcessState extends Equatable {
+ const PayProcessState();
+
+ @override
+ List get props => [];
+}
+
+class PayProcessInitial extends PayProcessState {
+ const PayProcessInitial();
+}
+
+class PayProcessPreparingSwap extends PayProcessState {
+ const PayProcessPreparingSwap();
+}
+
+class PayProcessWaitingForEth extends PayProcessState {
+ const PayProcessWaitingForEth();
+}
+
+class PayProcessSwapping extends PayProcessState {
+ const PayProcessSwapping();
+}
+
+class PayProcessRefreshingQuote extends PayProcessState {
+ const PayProcessRefreshingQuote();
+}
+
+class PayProcessPaying extends PayProcessState {
+ const PayProcessPaying();
+}
+
+/// Pay tx submitted; polling `/pay/:id/status` until it settles.
+class PayProcessAwaitingSettlement extends PayProcessState {
+ final String txId;
+
+ const PayProcessAwaitingSettlement(this.txId);
+
+ @override
+ List get props => [txId];
+}
+
+class PayProcessSuccess extends PayProcessState {
+ const PayProcessSuccess();
+}
+
+/// The swap succeeded (ZCHF is in the wallet) but the pay leg failed. Recoverable
+/// by retrying the pay leg ONLY — the view calls [PayProcessCubit.retryPay],
+/// which re-quotes + signs + submits without ever re-swapping. This is the key
+/// fund-safety state: a failed pay no longer forces a re-scan → re-swap (which
+/// would double-convert REALU).
+class PayProcessPayRetry extends PayProcessState {
+ final PayRetryReason reason;
+
+ /// Diagnostic detail for logs — not the user-facing copy.
+ final String? message;
+
+ const PayProcessPayRetry(this.reason, {this.message});
+
+ @override
+ List get props => [reason, message];
+}
+
+class PayProcessFailure extends PayProcessState {
+ final PayProcessFailureReason reason;
+
+ /// Diagnostic detail for logs — not the user-facing copy.
+ final String? message;
+
+ const PayProcessFailure(this.reason, {this.message});
+
+ @override
+ List get props => [reason, message];
+}
diff --git a/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart
new file mode 100644
index 00000000..fb75ef48
--- /dev/null
+++ b/lib/screens/pay/cubits/pay_quote/pay_quote_cubit.dart
@@ -0,0 +1,61 @@
+import 'package:equatable/equatable.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+
+part 'pay_quote_state.dart';
+
+/// Reads the public OCP payment-link quote (`GET /v1/lnurlp/:id`) and surfaces
+/// the requested fiat amount + the exact ZCHF amount the Ethereum method
+/// requires. The amount comes from the API `transferAmounts` (ZCHF on the
+/// Ethereum entry) — the app never computes it. An expired quote surfaces as a
+/// typed state so the view can prompt a re-scan.
+class PayQuoteCubit extends Cubit {
+ final RealUnitPayService _payService;
+ final String _paymentLinkId;
+
+ PayQuoteCubit(this._payService, this._paymentLinkId) : super(const PayQuoteLoading());
+
+ Future load() async {
+ emit(const PayQuoteLoading());
+
+ try {
+ final details = await _payService.getPaymentDetails(_paymentLinkId);
+
+ if (details.quote.expiration.isBefore(DateTime.now())) {
+ emit(const PayQuoteExpired());
+ return;
+ }
+
+ final zchfAmount = _zchfTransferAmount(details);
+ if (zchfAmount == null) {
+ emit(const PayQuoteUnavailable());
+ return;
+ }
+
+ emit(
+ PayQuoteReady(
+ paymentLinkId: _paymentLinkId,
+ quoteId: details.quote.id,
+ fiatAsset: details.requestedAmount.asset,
+ fiatAmount: details.requestedAmount.amount,
+ zchfAmount: zchfAmount,
+ ),
+ );
+ } catch (e) {
+ emit(PayQuoteError(e.toString()));
+ }
+ }
+
+ /// The ZCHF amount listed for the Ethereum transfer method, or null if the
+ /// payment link does not offer an Ethereum/ZCHF method.
+ static double? _zchfTransferAmount(LnurlpPaymentDto details) {
+ for (final transfer in details.transferAmounts) {
+ if (transfer.method.toLowerCase() != 'ethereum') continue;
+ for (final asset in transfer.assets) {
+ if (asset.asset.toUpperCase() == 'ZCHF') return asset.amount;
+ }
+ }
+ return null;
+ }
+}
diff --git a/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart b/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart
new file mode 100644
index 00000000..0984f573
--- /dev/null
+++ b/lib/screens/pay/cubits/pay_quote/pay_quote_state.dart
@@ -0,0 +1,50 @@
+part of 'pay_quote_cubit.dart';
+
+sealed class PayQuoteState extends Equatable {
+ const PayQuoteState();
+
+ @override
+ List get props => [];
+}
+
+class PayQuoteLoading extends PayQuoteState {
+ const PayQuoteLoading();
+}
+
+class PayQuoteReady extends PayQuoteState {
+ final String paymentLinkId;
+ final String quoteId;
+ final String fiatAsset;
+ final double fiatAmount;
+ final double zchfAmount;
+
+ const PayQuoteReady({
+ required this.paymentLinkId,
+ required this.quoteId,
+ required this.fiatAsset,
+ required this.fiatAmount,
+ required this.zchfAmount,
+ });
+
+ @override
+ List get props => [paymentLinkId, quoteId, fiatAsset, fiatAmount, zchfAmount];
+}
+
+/// The quote attached to the scanned link has expired — the user must re-scan.
+class PayQuoteExpired extends PayQuoteState {
+ const PayQuoteExpired();
+}
+
+/// The payment link offers no Ethereum/ZCHF transfer method.
+class PayQuoteUnavailable extends PayQuoteState {
+ const PayQuoteUnavailable();
+}
+
+class PayQuoteError extends PayQuoteState {
+ final String message;
+
+ const PayQuoteError(this.message);
+
+ @override
+ List get props => [message];
+}
diff --git a/lib/screens/pay/cubits/pay_scan/pay_scan_cubit.dart b/lib/screens/pay/cubits/pay_scan/pay_scan_cubit.dart
new file mode 100644
index 00000000..d40c3cf6
--- /dev/null
+++ b/lib/screens/pay/cubits/pay_scan/pay_scan_cubit.dart
@@ -0,0 +1,28 @@
+import 'package:equatable/equatable.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart';
+import 'package:realunit_wallet/packages/service/dfx/lnurl_decoder.dart';
+
+part 'pay_scan_state.dart';
+
+/// Decodes a scanned OCP QR into a DFX payment-link id + lnurlp URL. Pure
+/// decode — no network. The view advances to the quote step on
+/// [PayScanDecoded]; a malformed code keeps the scanner open with an error.
+class PayScanCubit extends Cubit {
+ PayScanCubit() : super(const PayScanScanning());
+
+ /// Called once per detected barcode. Guards against re-entry after a
+ /// successful decode so a continuously-detecting scanner does not re-emit.
+ void onCodeDetected(String raw) {
+ if (state is PayScanDecoded) return;
+ try {
+ final decoded = LnurlDecoder.decode(raw);
+ emit(PayScanDecoded(decoded));
+ } on InvalidPaymentLinkException catch (e) {
+ emit(PayScanInvalid(e.reason));
+ }
+ }
+
+ /// Dismiss an error and resume scanning.
+ void reset() => emit(const PayScanScanning());
+}
diff --git a/lib/screens/pay/cubits/pay_scan/pay_scan_state.dart b/lib/screens/pay/cubits/pay_scan/pay_scan_state.dart
new file mode 100644
index 00000000..f3552e5f
--- /dev/null
+++ b/lib/screens/pay/cubits/pay_scan/pay_scan_state.dart
@@ -0,0 +1,30 @@
+part of 'pay_scan_cubit.dart';
+
+sealed class PayScanState extends Equatable {
+ const PayScanState();
+
+ @override
+ List get props => [];
+}
+
+class PayScanScanning extends PayScanState {
+ const PayScanScanning();
+}
+
+class PayScanInvalid extends PayScanState {
+ final String reason;
+
+ const PayScanInvalid(this.reason);
+
+ @override
+ List get props => [reason];
+}
+
+class PayScanDecoded extends PayScanState {
+ final DecodedPaymentLink link;
+
+ const PayScanDecoded(this.link);
+
+ @override
+ List get props => [link.id, link.lnurlpUrl];
+}
diff --git a/lib/screens/pay/pay_process_page.dart b/lib/screens/pay/pay_process_page.dart
new file mode 100644
index 00000000..ace5bdc4
--- /dev/null
+++ b/lib/screens/pay/pay_process_page.dart
@@ -0,0 +1,212 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/packages/service/wallet_service.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart';
+import 'package:realunit_wallet/setup/di.dart';
+import 'package:realunit_wallet/styles/colors.dart';
+
+class PayProcessPage extends StatelessWidget {
+ final String paymentLinkId;
+ final double zchfNeeded;
+
+ const PayProcessPage({
+ super.key,
+ required this.paymentLinkId,
+ required this.zchfNeeded,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocProvider(
+ create: (_) => PayProcessCubit(
+ payService: getIt(),
+ faucetService: getIt(),
+ blockchainService: getIt(),
+ walletService: getIt(),
+ appStore: getIt(),
+ paymentLinkId: paymentLinkId,
+ zchfNeeded: zchfNeeded,
+ )..start(),
+ child: const PayProcessView(),
+ );
+ }
+}
+
+class PayProcessView extends StatelessWidget {
+ const PayProcessView({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocConsumer(
+ listenWhen: (previous, current) =>
+ current is PayProcessSuccess ||
+ current is PayProcessFailure ||
+ current is PayProcessPayRetry,
+ listener: (context, state) async {
+ if (state is PayProcessSuccess) {
+ await _showResultSheet(
+ context,
+ icon: Icons.check_circle_rounded,
+ title: S.of(context).paySuccess,
+ description: S.of(context).paySuccessDescription,
+ );
+ } else if (state is PayProcessPayRetry) {
+ // The swap already succeeded — offer to retry the PAY leg only. The
+ // ZCHF stays in the wallet; this never re-swaps.
+ await _showRetrySheet(context, state.reason);
+ } else if (state is PayProcessFailure) {
+ await _showResultSheet(
+ context,
+ icon: Icons.error_rounded,
+ title: S.of(context).payFailureTitle,
+ description: _failureMessage(context, state.reason),
+ );
+ }
+ },
+ builder: (context, state) {
+ return Scaffold(
+ appBar: AppBar(title: Text(S.of(context).pay)),
+ body: SafeArea(
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ spacing: 24,
+ children: [
+ const CupertinoActivityIndicator(radius: 16),
+ Text(
+ _progressLabel(context, state),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ }
+
+ String _progressLabel(BuildContext context, PayProcessState state) => switch (state) {
+ PayProcessInitial() || PayProcessPreparingSwap() => S.of(context).payPreparingSwap,
+ PayProcessWaitingForEth() => S.of(context).payWaitingForEth,
+ PayProcessSwapping() => S.of(context).paySwapping,
+ PayProcessRefreshingQuote() => S.of(context).payRefreshingQuote,
+ PayProcessPaying() => S.of(context).payPaying,
+ PayProcessAwaitingSettlement() => S.of(context).payAwaitingSettlement,
+ PayProcessSuccess() => S.of(context).paySuccess,
+ PayProcessPayRetry() => S.of(context).payRetryTitle,
+ PayProcessFailure() => S.of(context).payFailureTitle,
+ };
+
+ String _failureMessage(BuildContext context, PayProcessFailureReason reason) => switch (reason) {
+ PayProcessFailureReason.insufficientZchf => S.of(context).payFailureInsufficientZchf,
+ PayProcessFailureReason.insufficientEth => S.of(context).payFailureInsufficientEth,
+ PayProcessFailureReason.signatureUnsupported => S.of(context).payFailureSignatureUnsupported,
+ PayProcessFailureReason.bitboxRequired => S.of(context).payFailureBitboxRequired,
+ PayProcessFailureReason.generic => S.of(context).payFailureGeneric,
+ };
+
+ String _retryMessage(BuildContext context, PayRetryReason reason) => switch (reason) {
+ PayRetryReason.quoteExpired => S.of(context).payRetryQuoteExpired,
+ PayRetryReason.transient => S.of(context).payRetryTransient,
+ PayRetryReason.insufficientZchf => S.of(context).payRetryInsufficientZchf,
+ };
+
+ Future _showResultSheet(
+ BuildContext context, {
+ required IconData icon,
+ required String title,
+ required String description,
+ }) async {
+ await showModalBottomSheet(
+ context: context,
+ isDismissible: false,
+ builder: (_) => SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ spacing: 24,
+ children: [
+ Icon(icon, color: RealUnitColors.realUnitBlue, size: 64),
+ Text(title, style: Theme.of(context).textTheme.headlineMedium),
+ Text(
+ description,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: RealUnitColors.neutral500,
+ ),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: Text(S.of(context).close),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ if (context.mounted) Navigator.of(context).pop();
+ }
+
+ /// Recovery sheet shown after a successful swap when the pay leg failed. The
+ /// primary action retries the PAY leg only ([PayProcessCubit.retryPay]) — the
+ /// swap is never redone, so the ZCHF already held is reused. Dismissing leaves
+ /// that ZCHF safely in the wallet.
+ Future _showRetrySheet(BuildContext context, PayRetryReason reason) async {
+ final cubit = context.read();
+ // The sheet returns true when the user retries (keep the page) and false
+ // when they close (leave the flow); a barrier dismissal yields null.
+ final retry = await showModalBottomSheet(
+ context: context,
+ isDismissible: false,
+ builder: (sheetContext) => SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ spacing: 24,
+ children: [
+ const Icon(Icons.replay_rounded, color: RealUnitColors.realUnitBlue, size: 64),
+ Text(
+ S.of(sheetContext).payRetryTitle,
+ style: Theme.of(sheetContext).textTheme.headlineMedium,
+ ),
+ Text(
+ _retryMessage(sheetContext, reason),
+ textAlign: TextAlign.center,
+ style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith(
+ color: RealUnitColors.neutral500,
+ ),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.of(sheetContext).pop(true),
+ child: Text(S.of(sheetContext).payRetryButton),
+ ),
+ TextButton(
+ onPressed: () => Navigator.of(sheetContext).pop(false),
+ child: Text(S.of(sheetContext).close),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ if (retry == true) {
+ // Retry the PAY leg only — never re-swaps. Keep the page so the next
+ // attempt surfaces its own result.
+ await cubit.retryPay();
+ } else if (context.mounted) {
+ // Closed: leave the flow. The swapped ZCHF stays safely in the wallet.
+ Navigator.of(context).pop();
+ }
+ }
+}
diff --git a/lib/screens/pay/pay_quote_page.dart b/lib/screens/pay/pay_quote_page.dart
new file mode 100644
index 00000000..fc77fed4
--- /dev/null
+++ b/lib/screens/pay/pay_quote_page.dart
@@ -0,0 +1,137 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart';
+import 'package:realunit_wallet/screens/pay/pay_process_page.dart';
+import 'package:realunit_wallet/setup/di.dart';
+import 'package:realunit_wallet/styles/colors.dart';
+
+class PayQuotePage extends StatelessWidget {
+ final String paymentLinkId;
+
+ const PayQuotePage({super.key, required this.paymentLinkId});
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocProvider(
+ create: (_) => PayQuoteCubit(getIt(), paymentLinkId)..load(),
+ child: const PayQuoteView(),
+ );
+ }
+}
+
+class PayQuoteView extends StatelessWidget {
+ const PayQuoteView({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: Text(S.of(context).payQuoteTitle)),
+ body: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
+ child: BlocBuilder(
+ builder: (context, state) => switch (state) {
+ PayQuoteLoading() => const Center(child: CupertinoActivityIndicator()),
+ PayQuoteReady() => _PayQuoteReadyView(state: state),
+ PayQuoteExpired() => _PayQuoteMessage(message: S.of(context).payFailureQuoteExpired),
+ PayQuoteUnavailable() => _PayQuoteMessage(message: S.of(context).payQuoteUnavailable),
+ PayQuoteError() => _PayQuoteMessage(message: S.of(context).payFailureGeneric),
+ },
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _PayQuoteReadyView extends StatelessWidget {
+ final PayQuoteReady state;
+
+ const _PayQuoteReadyView({required this.state});
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ spacing: 24,
+ children: [
+ const Spacer(),
+ Text(
+ S
+ .of(context)
+ .payQuoteSummary(
+ state.fiatAmount.toStringAsFixed(2),
+ state.fiatAsset,
+ ),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.headlineMedium,
+ ),
+ _AmountRow(
+ label: S.of(context).payQuoteRequested,
+ value: '${state.fiatAmount.toStringAsFixed(2)} ${state.fiatAsset}',
+ ),
+ _AmountRow(
+ label: S.of(context).payQuoteZchfNeeded,
+ value: '${state.zchfAmount.toStringAsFixed(2)} ZCHF',
+ ),
+ const Spacer(),
+ FilledButton(
+ onPressed: () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => PayProcessPage(
+ paymentLinkId: state.paymentLinkId,
+ zchfNeeded: state.zchfAmount,
+ ),
+ ),
+ ),
+ child: Text(S.of(context).payConfirmButton),
+ ),
+ ],
+ );
+ }
+}
+
+class _AmountRow extends StatelessWidget {
+ final String label;
+ final String value;
+
+ const _AmountRow({required this.label, required this.value});
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ label,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: RealUnitColors.neutral500,
+ ),
+ ),
+ Text(value, style: Theme.of(context).textTheme.bodyLarge),
+ ],
+ );
+ }
+}
+
+class _PayQuoteMessage extends StatelessWidget {
+ final String message;
+
+ const _PayQuoteMessage({required this.message});
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Text(
+ message,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(
+ color: RealUnitColors.neutral500,
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/pay/pay_scan_page.dart b/lib/screens/pay/pay_scan_page.dart
new file mode 100644
index 00000000..302568c3
--- /dev/null
+++ b/lib/screens/pay/pay_scan_page.dart
@@ -0,0 +1,65 @@
+// @no-integration-test: the QR scanner is camera/MethodChannel-coupled
+// (mobile_scanner) and can only be exercised on a real device with a live
+// camera. The decode logic it feeds is unit-tested in lnurl_decoder_test.dart
+// and the cubit behaviour in pay_scan_cubit_test.dart; the camera preview
+// itself is out of scope for widget tests.
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:mobile_scanner/mobile_scanner.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart';
+import 'package:realunit_wallet/screens/pay/pay_quote_page.dart';
+import 'package:realunit_wallet/styles/colors.dart';
+
+class PayScanPage extends StatelessWidget {
+ const PayScanPage({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocProvider(
+ create: (_) => PayScanCubit(),
+ child: const PayScanView(),
+ );
+ }
+}
+
+class PayScanView extends StatelessWidget {
+ const PayScanView({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocConsumer(
+ listener: (context, state) {
+ if (state is PayScanDecoded) {
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => PayQuotePage(paymentLinkId: state.link.id),
+ ),
+ );
+ // Reset so returning to the scanner re-arms detection.
+ context.read().reset();
+ }
+ if (state is PayScanInvalid) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(S.of(context).payScanInvalid),
+ backgroundColor: RealUnitColors.status.red600,
+ ),
+ );
+ context.read().reset();
+ }
+ },
+ builder: (context, state) {
+ return Scaffold(
+ appBar: AppBar(title: Text(S.of(context).payScanTitle)),
+ body: MobileScanner(
+ onDetect: (capture) {
+ final raw = capture.barcodes.firstOrNull?.rawValue;
+ if (raw != null) context.read().onCodeDetected(raw);
+ },
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/setup/di.dart b/lib/setup/di.dart
index fa7b20f1..60fca67a 100644
--- a/lib/setup/di.dart
+++ b/lib/setup/di.dart
@@ -29,6 +29,7 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_support_service.dart';
import 'package:realunit_wallet/packages/service/dfx/dfx_widget_service.dart';
import 'package:realunit_wallet/packages/service/dfx/real_unit_account_service.dart';
import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart';
import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart';
import 'package:realunit_wallet/packages/service/dfx/real_unit_sell_payment_info_service.dart';
@@ -174,6 +175,9 @@ void setupServices() {
getIt.registerFactory(
() => RealUnitBuyPaymentInfoService(getIt(), getIt()),
);
+ getIt.registerFactory(
+ () => RealUnitPayService(getIt(), getIt()),
+ );
getIt.registerFactory(
() => RealUnitPdfService(getIt(), getIt()),
);
diff --git a/lib/setup/routing/router_config.dart b/lib/setup/routing/router_config.dart
index 3d0b9e26..610318c2 100644
--- a/lib/setup/routing/router_config.dart
+++ b/lib/setup/routing/router_config.dart
@@ -11,6 +11,7 @@ import 'package:realunit_wallet/screens/kyc/kyc_page_manager.dart';
import 'package:realunit_wallet/screens/legal/legal_disclaimer_page.dart';
import 'package:realunit_wallet/screens/legal/subpages/legal_document_page.dart';
import 'package:realunit_wallet/screens/onboarding/onboarding_completed_page.dart';
+import 'package:realunit_wallet/screens/pay/pay_scan_page.dart';
import 'package:realunit_wallet/screens/pin/setup_pin_page.dart';
import 'package:realunit_wallet/screens/pin/verify_pin_page.dart';
import 'package:realunit_wallet/screens/receive/receive_page.dart';
@@ -150,6 +151,12 @@ final GoRouter routerConfig = GoRouter(
builder: (_, state) => SellBitboxPage(paymentInfo: state.extra as SellPaymentInfo),
),
+ GoRoute(
+ name: AppRoutes.pay,
+ path: '/pay',
+ builder: (_, _) => const PayScanPage(),
+ ),
+
GoRoute(
name: LegalRoutes.disclaimer,
path: '/legalDisclaimer',
diff --git a/lib/setup/routing/routes/app_routes.dart b/lib/setup/routing/routes/app_routes.dart
index 1ec2a07a..721e9e91 100644
--- a/lib/setup/routing/routes/app_routes.dart
+++ b/lib/setup/routing/routes/app_routes.dart
@@ -5,6 +5,7 @@ abstract final class AppRoutes {
static const buy = 'buy';
static const sell = 'sell';
static const sellBitbox = 'sellBitbox';
+ static const pay = 'pay';
static const kyc = 'kyc';
static const receive = 'receive';
diff --git a/pubspec.lock b/pubspec.lock
index bf626bd7..ef7333d8 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -951,6 +951,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
+ mobile_scanner:
+ dependency: "direct main"
+ description:
+ name: mobile_scanner
+ sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.2.3"
mocktail:
dependency: "direct dev"
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 9877d964..cf0a2b4d 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -65,6 +65,7 @@ dependencies:
http: ^1.1.0
intl: any
local_auth: ^3.0.0
+ mobile_scanner: ^5.2.3
no_screenshot: ^1.1.0
open_file: ^3.5.11
path: ^1.9.0
diff --git a/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png b/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png
index 02631d18..ff69acbf 100644
Binary files a/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png and b/test/goldens/screens/dashboard/goldens/macos/dashboard_with_balance.png differ
diff --git a/test/goldens/screens/home/goldens/macos/home_page_loaded.png b/test/goldens/screens/home/goldens/macos/home_page_loaded.png
index 84de9124..309a5508 100644
Binary files a/test/goldens/screens/home/goldens/macos/home_page_loaded.png and b/test/goldens/screens/home/goldens/macos/home_page_loaded.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_process_page_awaiting_settlement.png b/test/goldens/screens/pay/goldens/macos/pay_process_page_awaiting_settlement.png
new file mode 100644
index 00000000..f180456f
Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_process_page_awaiting_settlement.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_process_page_pay_retry.png b/test/goldens/screens/pay/goldens/macos/pay_process_page_pay_retry.png
new file mode 100644
index 00000000..9c782538
Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_process_page_pay_retry.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_process_page_swapping.png b/test/goldens/screens/pay/goldens/macos/pay_process_page_swapping.png
new file mode 100644
index 00000000..18c71c3a
Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_process_page_swapping.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_quote_page_expired.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_expired.png
new file mode 100644
index 00000000..0ce937c0
Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_quote_page_expired.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_quote_page_loading.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_loading.png
new file mode 100644
index 00000000..66fa82dc
Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_quote_page_loading.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png b/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png
new file mode 100644
index 00000000..d58c5b28
Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_quote_page_ready.png differ
diff --git a/test/goldens/screens/pay/goldens/macos/pay_scan_page_scanning.png b/test/goldens/screens/pay/goldens/macos/pay_scan_page_scanning.png
new file mode 100644
index 00000000..5f249d68
Binary files /dev/null and b/test/goldens/screens/pay/goldens/macos/pay_scan_page_scanning.png differ
diff --git a/test/goldens/screens/pay/pay_process_golden_test.dart b/test/goldens/screens/pay/pay_process_golden_test.dart
new file mode 100644
index 00000000..80f8d4f3
--- /dev/null
+++ b/test/goldens/screens/pay/pay_process_golden_test.dart
@@ -0,0 +1,78 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart';
+import 'package:realunit_wallet/screens/pay/pay_process_page.dart';
+
+import '../../../helper/helper.dart';
+
+class _MockPayProcessCubit extends MockCubit implements PayProcessCubit {}
+
+void main() {
+ late _MockPayProcessCubit processCubit;
+
+ setUp(() {
+ processCubit = _MockPayProcessCubit();
+ when(() => processCubit.state).thenReturn(const PayProcessInitial());
+ });
+
+ // PayProcessPage resolves its cubit from getIt and calls start(); the golden
+ // renders PayProcessView directly with a mocked cubit. Terminal states
+ // (success/failure/retry) are surfaced via modal sheets from the listener,
+ // not the build tree — exercised in the widget test. The build tree shows the
+ // in-progress indicator with a per-state label, captured here.
+ group('$PayProcessView', () {
+ goldenTest(
+ 'in-progress swapping state',
+ fileName: 'pay_process_page_swapping',
+ constraints: phoneConstraints,
+ // The CupertinoActivityIndicator animates forever, so pumpAndSettle would
+ // time out; pumpOnce captures the first frame.
+ pumpBeforeTest: pumpOnce,
+ builder: () {
+ when(() => processCubit.state).thenReturn(const PayProcessSwapping());
+ return wrapForGolden(
+ BlocProvider.value(
+ value: processCubit,
+ child: const PayProcessView(),
+ ),
+ );
+ },
+ );
+
+ goldenTest(
+ 'awaiting settlement state',
+ fileName: 'pay_process_page_awaiting_settlement',
+ constraints: phoneConstraints,
+ pumpBeforeTest: pumpOnce,
+ builder: () {
+ when(() => processCubit.state).thenReturn(const PayProcessAwaitingSettlement('0xtx'));
+ return wrapForGolden(
+ BlocProvider.value(
+ value: processCubit,
+ child: const PayProcessView(),
+ ),
+ );
+ },
+ );
+
+ goldenTest(
+ 'pay-retry state label',
+ fileName: 'pay_process_page_pay_retry',
+ constraints: phoneConstraints,
+ pumpBeforeTest: pumpOnce,
+ builder: () {
+ when(
+ () => processCubit.state,
+ ).thenReturn(const PayProcessPayRetry(PayRetryReason.quoteExpired));
+ return wrapForGolden(
+ BlocProvider.value(
+ value: processCubit,
+ child: const PayProcessView(),
+ ),
+ );
+ },
+ );
+ });
+}
diff --git a/test/goldens/screens/pay/pay_quote_golden_test.dart b/test/goldens/screens/pay/pay_quote_golden_test.dart
new file mode 100644
index 00000000..8fce767b
--- /dev/null
+++ b/test/goldens/screens/pay/pay_quote_golden_test.dart
@@ -0,0 +1,80 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart';
+import 'package:realunit_wallet/screens/pay/pay_quote_page.dart';
+
+import '../../../helper/helper.dart';
+
+class _MockPayQuoteCubit extends MockCubit implements PayQuoteCubit {}
+
+void main() {
+ late _MockPayQuoteCubit quoteCubit;
+
+ setUp(() {
+ quoteCubit = _MockPayQuoteCubit();
+ when(() => quoteCubit.state).thenReturn(const PayQuoteLoading());
+ });
+
+ // PayQuotePage resolves its cubit from getIt and calls load(); the golden
+ // renders PayQuoteView directly with a mocked cubit so every state is
+ // deterministic without the service/DI graph.
+ group('$PayQuoteView', () {
+ goldenTest(
+ 'loading state',
+ fileName: 'pay_quote_page_loading',
+ constraints: phoneConstraints,
+ // The CupertinoActivityIndicator animates forever, so pumpAndSettle
+ // would time out; pumpOnce captures the first frame.
+ pumpBeforeTest: pumpOnce,
+ builder: () => wrapForGolden(
+ BlocProvider.value(
+ value: quoteCubit,
+ child: const PayQuoteView(),
+ ),
+ ),
+ );
+
+ // Real Sepolia OCP capture (DFXswiss/api #3819): a CHF 2.00 payment link
+ // whose Ethereum method settles 2.0 ZCHF. The screen renders the amounts
+ // straight from the quote — the app never computes them.
+ goldenTest(
+ 'ready quote with CHF amount and ZCHF needed',
+ fileName: 'pay_quote_page_ready',
+ constraints: phoneConstraints,
+ builder: () {
+ when(() => quoteCubit.state).thenReturn(
+ const PayQuoteReady(
+ paymentLinkId: 'pl_realunit_ocp_sepolia',
+ quoteId: 'plq_realunit_ocp_sepolia',
+ fiatAsset: 'CHF',
+ fiatAmount: 2,
+ zchfAmount: 2.0,
+ ),
+ );
+ return wrapForGolden(
+ BlocProvider.value(
+ value: quoteCubit,
+ child: const PayQuoteView(),
+ ),
+ );
+ },
+ );
+
+ goldenTest(
+ 'expired quote message',
+ fileName: 'pay_quote_page_expired',
+ constraints: phoneConstraints,
+ builder: () {
+ when(() => quoteCubit.state).thenReturn(const PayQuoteExpired());
+ return wrapForGolden(
+ BlocProvider.value(
+ value: quoteCubit,
+ child: const PayQuoteView(),
+ ),
+ );
+ },
+ );
+ });
+}
diff --git a/test/goldens/screens/pay/pay_scan_golden_test.dart b/test/goldens/screens/pay/pay_scan_golden_test.dart
new file mode 100644
index 00000000..dacdb430
--- /dev/null
+++ b/test/goldens/screens/pay/pay_scan_golden_test.dart
@@ -0,0 +1,45 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart';
+import 'package:realunit_wallet/screens/pay/pay_scan_page.dart';
+
+import '../../../helper/helper.dart';
+
+class _MockPayScanCubit extends MockCubit implements PayScanCubit {}
+
+void main() {
+ late _MockPayScanCubit scanCubit;
+
+ setUpAll(() {
+ // The QR scanner is camera-coupled (mobile_scanner). The stub answers the
+ // permission handshake so the preview settles into its deterministic
+ // placeholder state instead of throwing MissingPluginException — the live
+ // camera carries the `@no-integration-test` note on pay_scan_page.dart.
+ stubMobileScannerChannel();
+ });
+
+ setUp(() {
+ scanCubit = _MockPayScanCubit();
+ when(() => scanCubit.state).thenReturn(const PayScanScanning());
+ });
+
+ group('$PayScanView', () {
+ goldenTest(
+ 'scanning state with camera preview placeholder',
+ fileName: 'pay_scan_page_scanning',
+ constraints: phoneConstraints,
+ // The camera preview never reaches an `isInitialized` frame headlessly,
+ // so pumpAndSettle (default in precacheImages) would await a settle that
+ // never comes. pumpOnce captures the deterministic placeholder frame.
+ pumpBeforeTest: pumpOnce,
+ builder: () => wrapForGolden(
+ BlocProvider.value(
+ value: scanCubit,
+ child: const PayScanView(),
+ ),
+ ),
+ );
+ });
+}
diff --git a/test/helper/golden_plugin_stubs.dart b/test/helper/golden_plugin_stubs.dart
index c1e4e2f5..22754bc8 100644
--- a/test/helper/golden_plugin_stubs.dart
+++ b/test/helper/golden_plugin_stubs.dart
@@ -7,9 +7,51 @@ import 'package:flutter_test/flutter_test.dart';
///
/// Call from `setUpAll`.
void stubNoScreenshotChannel() {
- TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
- .setMockMethodCallHandler(
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('com.flutterplaza.no_screenshot_methods'),
(call) async => true,
);
}
+
+/// Stub the `mobile_scanner` plugin's method + event channels so the camera
+/// preview renders a deterministic state in headless widget/golden tests.
+///
+/// The QR scanner is camera/MethodChannel-coupled — the live preview has no
+/// headless representation and `MobileScanner.initState` fires
+/// `controller.start()` against the platform channel. This stub answers the
+/// permission handshake (`state` → undetermined, `request` → not granted) so
+/// `MobileScannerController.start()` settles into its permission-denied error
+/// state. The widget then paints its default error placeholder (a black
+/// `ColoredBox` with a centered error icon) instead of throwing
+/// `MissingPluginException` — a stable, deterministic preview-placeholder
+/// state that mirrors the `@no-integration-test` note on `pay_scan_page.dart`
+/// (the live camera is exercised only on a real device).
+///
+/// Call from `setUpAll`.
+void stubMobileScannerChannel() {
+ final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
+ messenger.setMockMethodCallHandler(
+ const MethodChannel('dev.steenbakker.mobile_scanner/scanner/method'),
+ (call) async {
+ switch (call.method) {
+ // Camera authorization is undetermined …
+ case 'state':
+ return 0;
+ // … and the follow-up request is not granted, so start() settles into
+ // the permission-denied placeholder without touching a real camera.
+ case 'request':
+ return false;
+ default:
+ return null;
+ }
+ },
+ );
+ // PayScanView wires an onDetect callback, so MobileScanner subscribes to the
+ // controller's barcode stream (the event channel) in initState. Install a
+ // no-op stream handler that never emits, so the `listen` does not throw
+ // MissingPluginException and no synthetic barcode ever fires.
+ messenger.setMockStreamHandler(
+ const EventChannel('dev.steenbakker.mobile_scanner/scanner/event'),
+ MockStreamHandler.inline(onListen: (arguments, sink) {}),
+ );
+}
diff --git a/test/packages/service/dfx/exceptions/exception_surface_test.dart b/test/packages/service/dfx/exceptions/exception_surface_test.dart
index d187e5a6..48c7994d 100644
--- a/test/packages/service/dfx/exceptions/exception_surface_test.dart
+++ b/test/packages/service/dfx/exceptions/exception_surface_test.dart
@@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart';
import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart';
// Guard against a recurring failure mode: an Exception subclass without a
@@ -24,6 +25,8 @@ void main() {
requiredLevel: 1,
currentLevel: 0,
),
+ const InvalidPaymentLinkException('test'),
+ const PaySignatureUnsupportedException(),
];
for (final ex in exceptions) {
diff --git a/test/packages/service/dfx/lnurl_decoder_test.dart b/test/packages/service/dfx/lnurl_decoder_test.dart
new file mode 100644
index 00000000..bd660bf6
--- /dev/null
+++ b/test/packages/service/dfx/lnurl_decoder_test.dart
@@ -0,0 +1,110 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart';
+import 'package:realunit_wallet/packages/service/dfx/lnurl_decoder.dart';
+
+void main() {
+ group('LnurlDecoder.decode', () {
+ // LUD-01 bech32 of `https://api.dfx.swiss/v1/lnurlp/pl_abc123`.
+ const lnurl = 'LNURL1DP68GURN8GHJ7CTSDYHXGENC9EEHW6TNWVHHVVF0D3H82UNVWQHHQMZLV93XXVFJXV5T0E5A';
+
+ test('decodes a bech32 LNURL to the api lnurlp url + id', () {
+ final result = LnurlDecoder.decode(lnurl);
+
+ expect(result.lnurlpUrl.toString(), 'https://api.dfx.swiss/v1/lnurlp/pl_abc123');
+ expect(result.id, 'pl_abc123');
+ });
+
+ test('decodes the lightning= query param of an app.dfx.swiss wrapper URL', () {
+ final result = LnurlDecoder.decode('https://app.dfx.swiss/pl/?lightning=$lnurl');
+
+ expect(result.lnurlpUrl.host, 'api.dfx.swiss');
+ expect(result.id, 'pl_abc123');
+ });
+
+ test('accepts a lowercase lnurl and a lightning: scheme prefix', () {
+ final result = LnurlDecoder.decode('lightning:${lnurl.toLowerCase()}');
+
+ expect(result.id, 'pl_abc123');
+ });
+
+ test('rewrites a plain app.dfx.swiss lnurlp url to the api host', () {
+ final result = LnurlDecoder.decode('https://app.dfx.swiss/v1/lnurlp/pl_xyz');
+
+ expect(result.lnurlpUrl.toString(), 'https://api.dfx.swiss/v1/lnurlp/pl_xyz');
+ expect(result.id, 'pl_xyz');
+ });
+
+ test('keeps an already-api lnurlp url and forces https', () {
+ final result = LnurlDecoder.decode('http://api.dfx.swiss/v1/lnurlp/plp_123');
+
+ expect(result.lnurlpUrl.scheme, 'https');
+ expect(result.id, 'plp_123');
+ });
+
+ test('rewrites the dev testnet host twin', () {
+ final result = LnurlDecoder.decode('https://dev.app.dfx.swiss/v1/lnurlp/pl_dev');
+
+ expect(result.lnurlpUrl.host, 'dev.api.dfx.swiss');
+ expect(result.id, 'pl_dev');
+ });
+
+ test('rejects an empty code', () {
+ expect(
+ () => LnurlDecoder.decode(' '),
+ throwsA(isA()),
+ );
+ });
+
+ test('rejects a non-DFX host', () {
+ expect(
+ () => LnurlDecoder.decode('https://evil.example.com/v1/lnurlp/pl_x'),
+ throwsA(isA()),
+ );
+ });
+
+ test('rejects a non-http payload', () {
+ expect(
+ () => LnurlDecoder.decode('not-a-url'),
+ throwsA(isA()),
+ );
+ });
+
+ test('rejects a bech32 with an invalid checksum', () {
+ // Flip the last data character to break the checksum.
+ final broken = '${lnurl.substring(0, lnurl.length - 1)}Q';
+ expect(
+ () => LnurlDecoder.decode(broken),
+ throwsA(isA()),
+ );
+ });
+
+ test('rejects a bech32 with an invalid character in the data part', () {
+ // Replace a data char with 'b' (not in the bech32 charset) while keeping
+ // the overall length valid, so the per-character guard fires.
+ final withBadChar = '${lnurl.substring(0, 20)}B${lnurl.substring(21)}';
+ expect(
+ () => LnurlDecoder.decode(withBadChar),
+ throwsA(isA()),
+ );
+ });
+
+ test('rejects a too-short bech32', () {
+ expect(
+ () => LnurlDecoder.decode('LNURL1bbb'),
+ throwsA(isA()),
+ );
+ });
+
+ test('falls back to the last path segment when there is no lnurlp segment', () {
+ final result = LnurlDecoder.decode('https://api.dfx.swiss/pl_direct');
+ expect(result.id, 'pl_direct');
+ });
+
+ test('rejects a DFX url with an empty path', () {
+ expect(
+ () => LnurlDecoder.decode('https://api.dfx.swiss/'),
+ throwsA(isA()),
+ );
+ });
+ });
+}
diff --git a/test/packages/service/dfx/models/payment/pay/pay_dtos_test.dart b/test/packages/service/dfx/models/payment/pay/pay_dtos_test.dart
new file mode 100644
index 00000000..eab27d41
--- /dev/null
+++ b/test/packages/service/dfx/models/payment/pay/pay_dtos_test.dart
@@ -0,0 +1,305 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_result_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart';
+
+void main() {
+ group('RealUnitSwapDto', () {
+ test('fromTargetAmount serialises only targetAmount (no amount key)', () {
+ expect(
+ const RealUnitSwapDto.fromTargetAmount(95.5).toJson(),
+ {'targetAmount': 95.5},
+ );
+ });
+ });
+
+ group('RealUnitSwapPaymentInfoDto.fromJson', () {
+ test('maps every field with no dynamic access', () {
+ final dto = RealUnitSwapPaymentInfoDto.fromJson({
+ 'id': 99,
+ 'uid': 'MOCK-UID',
+ 'routeId': 7,
+ 'timestamp': '2026-06-03T00:00:00.000Z',
+ 'amount': 10,
+ 'estimatedAmount': 960,
+ 'targetAsset': 'ZCHF',
+ 'fees': {'dfx': 1, 'network': 0.5, 'total': 1.5},
+ 'minVolume': 1,
+ 'maxVolume': 1000,
+ 'minVolumeTarget': 95,
+ 'maxVolumeTarget': 95000,
+ 'ethBalance': 1.0,
+ 'requiredGasEth': 0.001,
+ 'isValid': true,
+ });
+
+ expect(dto.id, 99);
+ expect(dto.uid, 'MOCK-UID');
+ expect(dto.routeId, 7);
+ expect(dto.targetAsset, 'ZCHF');
+ expect(dto.estimatedAmount, 960);
+ expect(dto.minVolumeTarget, 95);
+ expect(dto.isValid, isTrue);
+ expect(dto.error, isNull);
+ });
+
+ test('maps the error code when isValid is false', () {
+ final dto = RealUnitSwapPaymentInfoDto.fromJson({
+ 'id': 1,
+ 'uid': 'u',
+ 'routeId': 1,
+ 'timestamp': '2026-06-03T00:00:00.000Z',
+ 'amount': 1,
+ 'estimatedAmount': 1,
+ 'targetAsset': 'ZCHF',
+ 'minVolume': 1,
+ 'maxVolume': 2,
+ 'minVolumeTarget': 1,
+ 'maxVolumeTarget': 2,
+ 'ethBalance': 0,
+ 'requiredGasEth': 0.001,
+ 'isValid': false,
+ 'error': 'LIMIT_EXCEEDED',
+ });
+
+ expect(dto.isValid, isFalse);
+ expect(dto.error, 'LIMIT_EXCEEDED');
+ });
+ });
+
+ test('SwapPaymentInfo.fromDto carries the swap-relevant fields', () {
+ final dto = RealUnitSwapPaymentInfoDto.fromJson({
+ 'id': 5,
+ 'uid': 'u',
+ 'routeId': 2,
+ 'timestamp': '2026-06-03T00:00:00.000Z',
+ 'amount': 10,
+ 'estimatedAmount': 960,
+ 'targetAsset': 'ZCHF',
+ 'minVolume': 1,
+ 'maxVolume': 1000,
+ 'minVolumeTarget': 95,
+ 'maxVolumeTarget': 95000,
+ 'ethBalance': 0.4,
+ 'requiredGasEth': 0.002,
+ 'isValid': true,
+ });
+
+ final info = SwapPaymentInfo.fromDto(dto);
+
+ expect(info.id, 5);
+ expect(info.estimatedAmount, 960);
+ expect(info.ethBalance, 0.4);
+ expect(info.requiredGasEth, 0.002);
+ expect(info.isValid, isTrue);
+ });
+
+ test('SwapPaymentInfo equality is value-based (Equatable props)', () {
+ const a = SwapPaymentInfo(
+ id: 1,
+ amount: 10,
+ estimatedAmount: 960,
+ targetAsset: 'ZCHF',
+ ethBalance: 1,
+ requiredGasEth: 0.001,
+ isValid: true,
+ );
+ const same = SwapPaymentInfo(
+ id: 1,
+ amount: 10,
+ estimatedAmount: 960,
+ targetAsset: 'ZCHF',
+ ethBalance: 1,
+ requiredGasEth: 0.001,
+ isValid: true,
+ );
+ const different = SwapPaymentInfo(
+ id: 2,
+ amount: 10,
+ estimatedAmount: 960,
+ targetAsset: 'ZCHF',
+ ethBalance: 1,
+ requiredGasEth: 0.001,
+ isValid: true,
+ error: 'LIMIT_EXCEEDED',
+ );
+
+ expect(a, equals(same));
+ expect(a, isNot(equals(different)));
+ });
+
+ test('RealUnitSwapUnsignedTransactionDto.fromJson', () {
+ final dto = RealUnitSwapUnsignedTransactionDto.fromJson({'swap': '0xswap'});
+ expect(dto.swap, '0xswap');
+ });
+
+ test('RealUnitOcpPayDto.toJson', () {
+ const dto = RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q1');
+ expect(dto.toJson(), {'paymentLinkId': 'pl_abc', 'quoteId': 'q1'});
+ });
+
+ test('RealUnitOcpPayUnsignedTransactionDto.fromJson', () {
+ final dto = RealUnitOcpPayUnsignedTransactionDto.fromJson({
+ 'unsignedTx': '0xtx',
+ 'tokenAddress': '0xzchf',
+ 'recipient': '0xrecipient',
+ 'amountWei': '5000000000000000000',
+ 'chainId': 1,
+ });
+
+ expect(dto.unsignedTx, '0xtx');
+ expect(dto.tokenAddress, '0xzchf');
+ expect(dto.recipient, '0xrecipient');
+ expect(dto.amountWei, '5000000000000000000');
+ expect(dto.chainId, 1);
+ });
+
+ test('RealUnitOcpPaySubmitDto.toJson carries the signed envelope + refs', () {
+ const dto = RealUnitOcpPaySubmitDto(
+ unsignedTx: '0xtx',
+ r: '0xr',
+ s: '0xs',
+ v: 27,
+ paymentLinkId: 'pl_abc',
+ quoteId: 'q1',
+ );
+
+ expect(dto.toJson(), {
+ 'unsignedTx': '0xtx',
+ 'r': '0xr',
+ 's': '0xs',
+ 'v': 27,
+ 'paymentLinkId': 'pl_abc',
+ 'quoteId': 'q1',
+ });
+ });
+
+ test('RealUnitOcpPayResultDto.fromJson', () {
+ expect(RealUnitOcpPayResultDto.fromJson({'txId': '0xTxId'}).txId, '0xTxId');
+ });
+
+ group('RealUnitOcpPayStatusDto.fromJson', () {
+ test('maps each known status', () {
+ expect(
+ RealUnitOcpPayStatusDto.fromJson({'status': 'Completed'}).status,
+ OcpPaymentStatus.completed,
+ );
+ expect(
+ RealUnitOcpPayStatusDto.fromJson({'status': 'Pending'}).status,
+ OcpPaymentStatus.pending,
+ );
+ expect(
+ RealUnitOcpPayStatusDto.fromJson({'status': 'Cancelled'}).status,
+ OcpPaymentStatus.cancelled,
+ );
+ expect(
+ RealUnitOcpPayStatusDto.fromJson({'status': 'Expired'}).status,
+ OcpPaymentStatus.expired,
+ );
+ });
+
+ test('falls back to unknown for an unmapped status', () {
+ expect(
+ RealUnitOcpPayStatusDto.fromJson({'status': 'Whatever'}).status,
+ OcpPaymentStatus.unknown,
+ );
+ });
+
+ test('isTerminal / isCompleted predicates', () {
+ expect(OcpPaymentStatus.completed.isTerminal, isTrue);
+ expect(OcpPaymentStatus.completed.isCompleted, isTrue);
+ expect(OcpPaymentStatus.cancelled.isTerminal, isTrue);
+ expect(OcpPaymentStatus.cancelled.isCompleted, isFalse);
+ expect(OcpPaymentStatus.expired.isTerminal, isTrue);
+ expect(OcpPaymentStatus.pending.isTerminal, isFalse);
+ expect(OcpPaymentStatus.unknown.isTerminal, isFalse);
+ });
+ });
+
+ group('LnurlpPaymentDto.fromJson', () {
+ test('maps requestedAmount, quote and ZCHF transfer amounts', () {
+ final dto = LnurlpPaymentDto.fromJson({
+ 'requestedAmount': {'asset': 'CHF', 'amount': 42.5},
+ 'quote': {'id': 'quote_xyz', 'expiration': '2026-06-03T12:00:00.000Z'},
+ 'transferAmounts': [
+ {
+ 'method': 'Ethereum',
+ 'assets': [
+ {'asset': 'ZCHF', 'amount': 42.7},
+ ],
+ },
+ {
+ 'method': 'Bitcoin',
+ 'assets': [
+ {'asset': 'BTC', 'amount': 0.0005},
+ ],
+ },
+ ],
+ });
+
+ expect(dto.requestedAmount.asset, 'CHF');
+ expect(dto.requestedAmount.amount, 42.5);
+ expect(dto.quote.id, 'quote_xyz');
+ expect(dto.transferAmounts, hasLength(2));
+ expect(dto.transferAmounts.first.method, 'Ethereum');
+ expect(dto.transferAmounts.first.assets.first.asset, 'ZCHF');
+ expect(dto.transferAmounts.first.assets.first.amount, 42.7);
+ });
+
+ test('tolerates a missing transferAmounts list', () {
+ final dto = LnurlpPaymentDto.fromJson({
+ 'requestedAmount': {'asset': 'CHF', 'amount': 1.0},
+ 'quote': {'id': 'q', 'expiration': '2026-06-03T12:00:00.000Z'},
+ });
+
+ expect(dto.transferAmounts, isEmpty);
+ });
+
+ test('parses amount-less asset entries (non-priced display path) as null', () {
+ final dto = LnurlpPaymentDto.fromJson({
+ 'requestedAmount': {'asset': 'CHF', 'amount': 1.0},
+ 'quote': {'id': 'q', 'expiration': '2026-06-03T12:00:00.000Z'},
+ 'transferAmounts': [
+ {
+ 'method': 'Ethereum',
+ 'assets': [
+ // Optional `amount?` omitted by the backend.
+ {'asset': 'ZCHF'},
+ ],
+ },
+ ],
+ });
+
+ expect(dto.transferAmounts.first.assets.first.asset, 'ZCHF');
+ expect(dto.transferAmounts.first.assets.first.amount, isNull);
+ });
+
+ test('ignores the structured recipient object instead of throwing on it', () {
+ // The backend `recipient` is a PaymentLinkRecipientDto object, not a
+ // String; reading the quote must not throw on it (the field is unused).
+ final dto = LnurlpPaymentDto.fromJson({
+ 'recipient': {'name': 'Acme GmbH', 'address': 'Bahnhofstrasse 1'},
+ 'requestedAmount': {'asset': 'CHF', 'amount': 42.5},
+ 'quote': {'id': 'quote_xyz', 'expiration': '2026-06-03T12:00:00.000Z'},
+ 'transferAmounts': [
+ {
+ 'method': 'Ethereum',
+ 'assets': [
+ {'asset': 'ZCHF', 'amount': 42.7},
+ ],
+ },
+ ],
+ });
+
+ expect(dto.quote.id, 'quote_xyz');
+ expect(dto.transferAmounts.first.assets.first.amount, 42.7);
+ });
+ });
+}
diff --git a/test/packages/service/dfx/real_unit_pay_service_test.dart b/test/packages/service/dfx/real_unit_pay_service_test.dart
new file mode 100644
index 00000000..2717993f
--- /dev/null
+++ b/test/packages/service/dfx/real_unit_pay_service_test.dart
@@ -0,0 +1,341 @@
+import 'dart:convert';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
+import 'package:http/testing.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/packages/config/api_config.dart';
+import 'package:realunit_wallet/packages/config/network_mode.dart';
+import 'package:realunit_wallet/packages/repository/cache_repository.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_request_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/packages/service/session_cache.dart';
+import 'package:realunit_wallet/packages/service/wallet_service.dart';
+import 'package:realunit_wallet/packages/wallet/wallet.dart';
+import 'package:realunit_wallet/packages/wallet/wallet_account.dart';
+import 'package:web3dart/web3dart.dart';
+
+class _MockAppStore extends Mock implements AppStore {}
+
+class _MockWallet extends Mock implements AWallet {}
+
+class _MockAccount extends Mock implements AWalletAccount {}
+
+class _MockCacheRepository extends Mock implements CacheRepository {}
+
+class _MockWalletService extends Mock implements WalletService {}
+
+class _StubCreds extends Fake implements CredentialsWithKnownAddress {
+ @override
+ EthereumAddress get address =>
+ EthereumAddress.fromHex('0x0000000000000000000000000000000000000001');
+}
+
+Map _swapInfoJson() => {
+ 'id': 99,
+ 'uid': 'MOCK-UID',
+ 'routeId': 7,
+ 'timestamp': '2026-06-03T00:00:00.000Z',
+ 'amount': 10,
+ 'estimatedAmount': 960,
+ 'targetAsset': 'ZCHF',
+ 'fees': {'dfx': 1, 'network': 0.5, 'total': 1.5},
+ 'minVolume': 1,
+ 'maxVolume': 1000,
+ 'minVolumeTarget': 95,
+ 'maxVolumeTarget': 95000,
+ 'ethBalance': 1.0,
+ 'requiredGasEth': 0.001,
+ 'isValid': true,
+};
+
+void main() {
+ late _MockAppStore appStore;
+ late _MockWallet wallet;
+ late _MockAccount account;
+ late _MockWalletService walletService;
+ late SessionCache session;
+
+ setUp(() {
+ appStore = _MockAppStore();
+ wallet = _MockWallet();
+ account = _MockAccount();
+ walletService = _MockWalletService();
+ session = SessionCache(_MockCacheRepository());
+ session.setAuthToken('jwt-1');
+
+ when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet));
+ when(() => appStore.sessionCache).thenReturn(session);
+ when(() => appStore.wallet).thenReturn(wallet);
+ when(() => wallet.currentAccount).thenReturn(account);
+ when(() => account.primaryAddress).thenReturn(_StubCreds());
+ });
+
+ RealUnitPayService build(http.Client client) {
+ when(() => appStore.httpClient).thenReturn(client);
+ return RealUnitPayService(appStore, walletService);
+ }
+
+ group('getPaymentDetails', () {
+ test('GETs the public lnurlp endpoint (no auth header) and parses it', () async {
+ Uri? sentUri;
+ Map? headers;
+ final client = MockClient((request) async {
+ sentUri = request.url;
+ headers = request.headers;
+ return http.Response(
+ jsonEncode({
+ 'requestedAmount': {'asset': 'CHF', 'amount': 42.5},
+ 'quote': {'id': 'quote_xyz', 'expiration': '2026-06-03T12:00:00.000Z'},
+ 'transferAmounts': [
+ {
+ 'method': 'Ethereum',
+ 'assets': [
+ {'asset': 'ZCHF', 'amount': 42.7},
+ ],
+ },
+ ],
+ }),
+ 200,
+ );
+ });
+
+ final dto = await build(client).getPaymentDetails('pl_abc');
+
+ expect(sentUri!.path, '/v1/lnurlp/pl_abc');
+ expect(headers!.containsKey('Authorization'), isFalse);
+ expect(dto.quote.id, 'quote_xyz');
+ expect(dto.transferAmounts.first.assets.first.amount, 42.7);
+ });
+
+ test('non-200 → ApiException', () async {
+ final client = MockClient(
+ (_) async => http.Response(jsonEncode({'statusCode': 404, 'message': 'gone'}), 404),
+ );
+ expect(
+ () => build(client).getPaymentDetails('pl_abc'),
+ throwsA(isA()),
+ );
+ });
+ });
+
+ group('getSwapPaymentInfo', () {
+ test('PUTs /swap with targetAmount and parses the quote', () async {
+ Uri? sentUri;
+ Map? body;
+ final client = MockClient((request) async {
+ sentUri = request.url;
+ body = jsonDecode(request.body) as Map;
+ return http.Response(jsonEncode(_swapInfoJson()), 200);
+ });
+
+ final info = await build(
+ client,
+ ).getSwapPaymentInfo(const RealUnitSwapDto.fromTargetAmount(95.5));
+
+ expect(sentUri!.path, '/v1/realunit/swap');
+ expect(body!['targetAmount'], 95.5);
+ expect(body!.containsKey('amount'), isFalse);
+ expect(info.id, 99);
+ expect(info.estimatedAmount, 960);
+ expect(info.isValid, isTrue);
+ });
+
+ test('non-200 → ApiException', () async {
+ final client = MockClient(
+ (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'bad'}), 400),
+ );
+ expect(
+ () => build(client).getSwapPaymentInfo(const RealUnitSwapDto.fromTargetAmount(95.5)),
+ throwsA(isA()),
+ );
+ });
+ });
+
+ group('createSwapUnsignedTransaction', () {
+ test('200 → parses swap hex', () async {
+ Uri? sentUri;
+ final client = MockClient((request) async {
+ sentUri = request.url;
+ return http.Response(jsonEncode({'swap': '0xswap'}), 200);
+ });
+
+ final dto = await build(client).createSwapUnsignedTransaction(42);
+
+ expect(sentUri!.path, '/v1/realunit/swap/42/unsigned-transaction');
+ expect(dto.swap, '0xswap');
+ });
+
+ test('non-200 → ApiException', () async {
+ final client = MockClient(
+ (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'no eth'}), 400),
+ );
+ expect(
+ () => build(client).createSwapUnsignedTransaction(42),
+ throwsA(isA()),
+ );
+ });
+ });
+
+ group('broadcastSwapTransaction', () {
+ test('201 → returns txHash', () async {
+ Uri? sentUri;
+ Map? body;
+ final client = MockClient((request) async {
+ sentUri = request.url;
+ body = jsonDecode(request.body) as Map;
+ return http.Response(jsonEncode({'txHash': '0xabc'}), 201);
+ });
+
+ final txHash = await build(client).broadcastSwapTransaction(
+ 42,
+ const BroadcastTransactionRequestDto(unsignedTx: '0xtx', r: '0xr', s: '0xs', v: 27),
+ );
+
+ expect(sentUri!.path, '/v1/realunit/swap/42/broadcast');
+ expect(body!['unsignedTx'], '0xtx');
+ expect(body!['v'], 27);
+ expect(txHash, '0xabc');
+ });
+
+ test('non-200 → ApiException', () async {
+ final client = MockClient(
+ (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'bad sig'}), 400),
+ );
+ expect(
+ () => build(client).broadcastSwapTransaction(
+ 42,
+ const BroadcastTransactionRequestDto(unsignedTx: '0xtx', r: '0xr', s: '0xs', v: 27),
+ ),
+ throwsA(isA()),
+ );
+ });
+ });
+
+ group('createPayUnsignedTransaction', () {
+ // Values from the real Sepolia OCP testnet run (DFXswiss/api #3819):
+ // a CHF 2.00 link settling 2.0 ZCHF (2000000000000000000 wei) to the DFX
+ // deposit recipient on chain 11155111.
+ test('PUTs /pay/unsigned-transaction with the payment refs', () async {
+ Uri? sentUri;
+ Map? body;
+ final client = MockClient((request) async {
+ sentUri = request.url;
+ body = jsonDecode(request.body) as Map;
+ return http.Response(
+ jsonEncode({
+ 'unsignedTx':
+ '0x02f87483aa36a782019b8502284a84ae8502284a84ae830186a094d3117681ca462268048f57d106d312ba0b1215ea80b844a9059cbb000000000000000000000000fb2a9731cda8b3fca015723ef40f310c1e48366b0000000000000000000000000000000000000000000000001bc16d674ec80000c0',
+ 'tokenAddress': '0xD3117681cA462268048f57D106d312Ba0b1215eA',
+ 'recipient': '0xfB2a9731cdA8b3FCa015723EF40f310C1E48366b',
+ 'amountWei': '2000000000000000000',
+ 'chainId': 11155111,
+ }),
+ 200,
+ );
+ });
+
+ final dto = await build(client).createPayUnsignedTransaction(
+ const RealUnitOcpPayDto(paymentLinkId: 'pl_realunit_ocp_sepolia', quoteId: 'q1'),
+ );
+
+ expect(sentUri!.path, '/v1/realunit/pay/unsigned-transaction');
+ expect(body!['paymentLinkId'], 'pl_realunit_ocp_sepolia');
+ expect(body!['quoteId'], 'q1');
+ expect(dto.recipient, '0xfB2a9731cdA8b3FCa015723EF40f310C1E48366b');
+ expect(dto.tokenAddress, '0xD3117681cA462268048f57D106d312Ba0b1215eA');
+ expect(dto.amountWei, '2000000000000000000');
+ expect(dto.chainId, 11155111);
+ });
+
+ test('non-200 → ApiException', () async {
+ final client = MockClient(
+ (_) async =>
+ http.Response(jsonEncode({'statusCode': 400, 'message': 'unsupported method'}), 400),
+ );
+ expect(
+ () => build(client).createPayUnsignedTransaction(
+ const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q1'),
+ ),
+ throwsA(isA()),
+ );
+ });
+ });
+
+ group('submitPay', () {
+ test('PUTs /pay/submit and returns txId', () async {
+ Uri? sentUri;
+ Map? body;
+ final client = MockClient((request) async {
+ sentUri = request.url;
+ body = jsonDecode(request.body) as Map;
+ return http.Response(jsonEncode({'txId': '0xTxId'}), 200);
+ });
+
+ final txId = await build(client).submitPay(
+ const RealUnitOcpPaySubmitDto(
+ unsignedTx: '0xtx',
+ r: '0xr',
+ s: '0xs',
+ v: 27,
+ paymentLinkId: 'pl_abc',
+ quoteId: 'q1',
+ ),
+ );
+
+ expect(sentUri!.path, '/v1/realunit/pay/submit');
+ expect(body!['paymentLinkId'], 'pl_abc');
+ expect(txId, '0xTxId');
+ });
+
+ test('non-200 → ApiException', () async {
+ final client = MockClient(
+ (_) async => http.Response(jsonEncode({'statusCode': 400, 'message': 'fail'}), 400),
+ );
+ expect(
+ () => build(client).submitPay(
+ const RealUnitOcpPaySubmitDto(
+ unsignedTx: '0xtx',
+ r: '0xr',
+ s: '0xs',
+ v: 27,
+ paymentLinkId: 'pl_abc',
+ quoteId: 'q1',
+ ),
+ ),
+ throwsA(isA()),
+ );
+ });
+ });
+
+ group('getPayStatus', () {
+ test('GETs /pay/:id/status and parses the status', () async {
+ Uri? sentUri;
+ final client = MockClient((request) async {
+ sentUri = request.url;
+ return http.Response(jsonEncode({'status': 'Completed'}), 200);
+ });
+
+ final dto = await build(client).getPayStatus('pl_abc');
+
+ expect(sentUri!.path, '/v1/realunit/pay/pl_abc/status');
+ expect(dto.status, OcpPaymentStatus.completed);
+ });
+
+ test('non-200 → ApiException', () async {
+ final client = MockClient(
+ (_) async => http.Response(jsonEncode({'statusCode': 404, 'message': 'none'}), 404),
+ );
+ expect(
+ () => build(client).getPayStatus('pl_abc'),
+ throwsA(isA()),
+ );
+ });
+ });
+}
diff --git a/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart
new file mode 100644
index 00000000..43eb3975
--- /dev/null
+++ b/test/screens/dashboard/widgets/sections/dashboard_actions_test.dart
@@ -0,0 +1,111 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_localizations/flutter_localizations.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:go_router/go_router.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/screens/dashboard/widgets/sections/dashboard_actions.dart';
+import 'package:realunit_wallet/setup/routing/routes/app_routes.dart';
+import 'package:realunit_wallet/widgets/action_button.dart';
+
+void main() {
+ late List pushedRoutes;
+
+ setUp(() {
+ pushedRoutes = [];
+ });
+
+ // Routes the three action buttons can push. Each target records the
+ // pushed route name so the `onPressed` closures are both executed and
+ // asserted, instead of only painted.
+ GoRouter buildRouter() {
+ GoRoute target(String name, String path) => GoRoute(
+ name: name,
+ path: path,
+ builder: (_, _) {
+ pushedRoutes.add(name);
+ return Scaffold(body: Text('ROUTE:$name'));
+ },
+ );
+
+ return GoRouter(
+ initialLocation: '/',
+ routes: [
+ GoRoute(
+ path: '/',
+ builder: (_, _) => const Scaffold(body: DashboardActions()),
+ ),
+ target(AppRoutes.buy, '/buy'),
+ target(AppRoutes.sell, '/sell'),
+ target(AppRoutes.pay, '/pay'),
+ ],
+ );
+ }
+
+ Future pumpActions(WidgetTester tester) async {
+ final router = buildRouter();
+ addTearDown(router.dispose);
+ await tester.pumpWidget(
+ MaterialApp.router(
+ routerConfig: router,
+ localizationsDelegates: const [
+ S.delegate,
+ GlobalMaterialLocalizations.delegate,
+ ],
+ supportedLocales: S.delegate.supportedLocales,
+ ),
+ );
+ await tester.pumpAndSettle();
+ }
+
+ Finder actionButtonByLabel(String label) => find.byWidgetPredicate(
+ (w) => w is ActionButton && w.label == label,
+ );
+
+ group('$DashboardActions', () {
+ testWidgets('renders the buy, sell and pay action buttons', (tester) async {
+ await pumpActions(tester);
+
+ expect(actionButtonByLabel(S.current.buy), findsOneWidget);
+ expect(actionButtonByLabel(S.current.sell), findsOneWidget);
+ expect(actionButtonByLabel(S.current.pay), findsOneWidget);
+ // Each button is laid out inside an Expanded so the row divides
+ // the available width into three equal slots.
+ expect(find.byType(Expanded), findsNWidgets(3));
+ });
+
+ testWidgets('renders the expected icons for each action', (tester) async {
+ await pumpActions(tester);
+
+ expect(find.byIcon(Icons.add_circle_rounded), findsOneWidget);
+ expect(find.byIcon(Icons.do_not_disturb_on_rounded), findsOneWidget);
+ expect(find.byIcon(Icons.qr_code_scanner_rounded), findsOneWidget);
+ });
+
+ testWidgets('buy button pushes the buy route', (tester) async {
+ await pumpActions(tester);
+
+ await tester.tap(actionButtonByLabel(S.current.buy));
+ await tester.pumpAndSettle();
+
+ expect(pushedRoutes, [AppRoutes.buy]);
+ });
+
+ testWidgets('sell button pushes the sell route', (tester) async {
+ await pumpActions(tester);
+
+ await tester.tap(actionButtonByLabel(S.current.sell));
+ await tester.pumpAndSettle();
+
+ expect(pushedRoutes, [AppRoutes.sell]);
+ });
+
+ testWidgets('pay button pushes the pay route', (tester) async {
+ await pumpActions(tester);
+
+ await tester.tap(actionButtonByLabel(S.current.pay));
+ await tester.pumpAndSettle();
+
+ expect(pushedRoutes, [AppRoutes.pay]);
+ });
+ });
+}
diff --git a/test/screens/pay/pay_process_cubit_test.dart b/test/screens/pay/pay_process_cubit_test.dart
new file mode 100644
index 00000000..ad632b97
--- /dev/null
+++ b/test/screens/pay/pay_process_cubit_test.dart
@@ -0,0 +1,633 @@
+import 'dart:typed_data';
+
+import 'package:fake_async/fake_async.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/packages/config/api_config.dart';
+import 'package:realunit_wallet/packages/config/network_mode.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/faucet/faucet_response_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_status_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_submit_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_ocp_pay_unsigned_transaction_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_payment_info_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/real_unit_swap_unsigned_transaction_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/swap_payment_info.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_request_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/packages/service/wallet_service.dart';
+import 'package:realunit_wallet/packages/wallet/wallet.dart';
+import 'package:realunit_wallet/packages/wallet/wallet_account.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart';
+import 'package:web3dart/crypto.dart';
+import 'package:web3dart/web3dart.dart';
+
+import '../../helper/fake_bitbox_credentials.dart';
+
+class _MockPayService extends Mock implements RealUnitPayService {}
+
+class _MockFaucet extends Mock implements DfxFaucetService {}
+
+class _MockBlockchain extends Mock implements DfxBlockchainApiService {}
+
+class _MockWalletService extends Mock implements WalletService {}
+
+class _MockAppStore extends Mock implements AppStore {}
+
+class _MockWallet extends Mock implements AWallet {}
+
+class _MockAccount extends Mock implements AWalletAccount {}
+
+/// Credentials whose `signToSignature` throws [UnsupportedError] — the debug
+/// wallet's behaviour, used to exercise the in-sign defensive guard.
+class _UnsupportedCreds extends Fake implements CredentialsWithKnownAddress {
+ @override
+ Future signToSignature(Uint8List payload, {int? chainId, bool isEIP1559 = false}) =>
+ throw UnsupportedError('Debug wallet cannot sign');
+}
+
+SwapPaymentInfo _swap({
+ double ethBalance = 1.0,
+ double requiredGasEth = 0.001,
+ bool isValid = true,
+}) {
+ return SwapPaymentInfo.fromDto(
+ RealUnitSwapPaymentInfoDto(
+ id: 99,
+ uid: 'u',
+ routeId: 7,
+ timestamp: DateTime.parse('2026-06-03T00:00:00.000Z'),
+ amount: 10,
+ estimatedAmount: 960,
+ targetAsset: 'ZCHF',
+ minVolume: 1,
+ maxVolume: 1000,
+ minVolumeTarget: 95,
+ maxVolumeTarget: 95000,
+ ethBalance: ethBalance,
+ requiredGasEth: requiredGasEth,
+ isValid: isValid,
+ ),
+ );
+}
+
+LnurlpPaymentDto _details({
+ required DateTime expiration,
+ String quoteId = 'quote_fresh',
+ double zchf = 42.7,
+}) {
+ return LnurlpPaymentDto(
+ requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 42.5),
+ quote: LnurlpQuoteDto(id: quoteId, expiration: expiration),
+ transferAmounts: [
+ LnurlpTransferAmountDto(
+ method: 'Ethereum',
+ assets: [LnurlpTransferAssetDto(asset: 'ZCHF', amount: zchf)],
+ ),
+ ],
+ );
+}
+
+const _unsignedPay = RealUnitOcpPayUnsignedTransactionDto(
+ // A short EIP-1559-style payload; signToSignature only keccak-hashes it.
+ unsignedTx: '0x02f8',
+ tokenAddress: '0xzchf',
+ recipient: '0xrecipient',
+ amountWei: '5000000000000000000',
+ chainId: 11155111,
+);
+
+void main() {
+ late _MockPayService payService;
+ late _MockFaucet faucet;
+ late _MockBlockchain blockchain;
+ late _MockWalletService walletService;
+ late _MockAppStore appStore;
+ late _MockWallet wallet;
+ late _MockAccount account;
+
+ setUpAll(() {
+ registerFallbackValue(const RealUnitSwapDto.fromTargetAmount(1));
+ registerFallbackValue(const RealUnitOcpPayDto(paymentLinkId: 'pl_abc', quoteId: 'q'));
+ registerFallbackValue(
+ const BroadcastTransactionRequestDto(unsignedTx: '', r: '', s: '', v: 0),
+ );
+ registerFallbackValue(
+ const RealUnitOcpPaySubmitDto(
+ unsignedTx: '',
+ r: '',
+ s: '',
+ v: 0,
+ paymentLinkId: 'pl_abc',
+ quoteId: 'q',
+ ),
+ );
+ });
+
+ setUp(() {
+ payService = _MockPayService();
+ faucet = _MockFaucet();
+ blockchain = _MockBlockchain();
+ walletService = _MockWalletService();
+ appStore = _MockAppStore();
+ wallet = _MockWallet();
+ account = _MockAccount();
+
+ when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet));
+ when(() => appStore.primaryAddress).thenReturn('0xwallet');
+ when(() => appStore.wallet).thenReturn(wallet);
+ when(() => wallet.walletType).thenReturn(WalletType.software);
+ when(() => wallet.currentAccount).thenReturn(account);
+ when(() => account.primaryAddress).thenReturn(FakeBitboxCredentials(signDelay: Duration.zero));
+ when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {});
+ when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {});
+ });
+
+ PayProcessCubit build({double zchfNeeded = 42.7}) => PayProcessCubit(
+ payService: payService,
+ faucetService: faucet,
+ blockchainService: blockchain,
+ walletService: walletService,
+ appStore: appStore,
+ paymentLinkId: 'pl_abc',
+ zchfNeeded: zchfNeeded,
+ );
+
+ void wireHappyPath() {
+ when(() => payService.getSwapPaymentInfo(any())).thenAnswer((_) async => _swap());
+ when(() => payService.createSwapUnsignedTransaction(any())).thenAnswer(
+ (_) async => const RealUnitSwapUnsignedTransactionDto(swap: '0x02f8aa'),
+ );
+ when(
+ () => payService.broadcastSwapTransaction(any(), any()),
+ ).thenAnswer((_) async => '0xswaptx');
+ when(() => payService.getPaymentDetails('pl_abc')).thenAnswer(
+ (_) async => _details(expiration: DateTime.now().add(const Duration(minutes: 5))),
+ );
+ when(
+ () => payService.createPayUnsignedTransaction(any()),
+ ).thenAnswer((_) async => _unsignedPay);
+ when(() => payService.submitPay(any())).thenAnswer((_) async => '0xpaytx');
+ }
+
+ // The sign step uses `Future.delayed(Duration.zero)` (FakeBitboxCredentials),
+ // which is a zero-duration *timer* under fakeAsync — `flushMicrotasks` alone
+ // does not fire it. Elapsing zero repeatedly drains the whole await chain
+ // (each mock `thenAnswer` future + every zero-delay sign timer) until the
+ // cubit settles.
+ void drain(FakeAsync async) {
+ for (var i = 0; i < 40; i++) {
+ async.flushMicrotasks();
+ async.elapse(Duration.zero);
+ }
+ }
+
+ test('debug wallet → signatureUnsupported before any network call', () async {
+ when(() => wallet.walletType).thenReturn(WalletType.debug);
+
+ final cubit = build();
+ await cubit.start();
+
+ final state = cubit.state as PayProcessFailure;
+ expect(state.reason, PayProcessFailureReason.signatureUnsupported);
+ verifyNever(() => payService.getSwapPaymentInfo(any()));
+ await cubit.close();
+ });
+
+ test('invalid swap quote → insufficientZchf', () async {
+ when(() => payService.getSwapPaymentInfo(any())).thenAnswer((_) async => _swap(isValid: false));
+
+ final cubit = build();
+ await cubit.start();
+
+ final state = cubit.state as PayProcessFailure;
+ expect(state.reason, PayProcessFailureReason.insufficientZchf);
+ await cubit.close();
+ });
+
+ test('swap sizes the target with a slippage buffer over the ZCHF needed', () async {
+ wireHappyPath();
+ when(() => payService.getPayStatus('pl_abc')).thenAnswer(
+ (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed),
+ );
+ RealUnitSwapDto? sentDto;
+ when(() => payService.getSwapPaymentInfo(any())).thenAnswer((invocation) async {
+ sentDto = invocation.positionalArguments.first as RealUnitSwapDto;
+ return _swap();
+ });
+
+ final cubit = build(zchfNeeded: 100);
+ await cubit.start();
+
+ // After start() resolves the chain the pay tx has been submitted.
+ expect(cubit.state, isA());
+ // 100 * 1.03 swap headroom buffer (covers ordinary CHF→ZCHF / swap-rate
+ // drift between scan and settle).
+ expect(sentDto!.targetAmount, closeTo(103, 0.0001));
+ expect(sentDto!.amount, isNull);
+ await cubit.close();
+ });
+
+ test('happy path: swap → refresh quote → pay → polled Completed → success', () async {
+ fakeAsync((async) {
+ wireHappyPath();
+ when(() => payService.getPayStatus('pl_abc')).thenAnswer(
+ (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed),
+ );
+
+ final cubit = build();
+ cubit.start();
+ drain(async);
+
+ // Pay submitted → polling status.
+ expect(cubit.state, isA());
+
+ // First status poll @ 3s returns Completed → success.
+ async.elapse(const Duration(seconds: 3));
+ drain(async);
+ expect(cubit.state, isA());
+
+ cubit.close();
+ async.flushTimers();
+ });
+ });
+
+ test('re-fetched quote sends a fresh quoteId into the pay step', () async {
+ wireHappyPath();
+ when(() => payService.getPaymentDetails('pl_abc')).thenAnswer(
+ (_) async =>
+ _details(expiration: DateTime.now().add(const Duration(minutes: 5)), quoteId: 'q_fresh2'),
+ );
+ RealUnitOcpPayDto? payDto;
+ when(() => payService.createPayUnsignedTransaction(any())).thenAnswer((invocation) async {
+ payDto = invocation.positionalArguments.first as RealUnitOcpPayDto;
+ return _unsignedPay;
+ });
+
+ final cubit = build();
+ final settled = cubit.stream.firstWhere((s) => s is PayProcessAwaitingSettlement);
+ await cubit.start();
+ await settled;
+
+ expect(payDto!.quoteId, 'q_fresh2');
+ await cubit.close();
+ });
+
+ test('quote expired between swap and pay → pay-only retry (no re-scan)', () async {
+ wireHappyPath();
+ when(() => payService.getPaymentDetails('pl_abc')).thenAnswer(
+ (_) async => _details(expiration: DateTime.now().subtract(const Duration(minutes: 1))),
+ );
+
+ final cubit = build();
+ final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry);
+ await cubit.start();
+ final state = await retry as PayProcessPayRetry;
+
+ // Genuine expiry surfaces as a retryable state — NOT a terminal failure —
+ // because the swap already ran. The pay leg is never submitted here.
+ expect(state.reason, PayRetryReason.quoteExpired);
+ verifyNever(() => payService.createPayUnsignedTransaction(any()));
+ await cubit.close();
+ });
+
+ test('pay submit failure after swap → retry (transient), not terminal', () async {
+ wireHappyPath();
+ when(() => payService.submitPay(any())).thenThrow(Exception('settlement rejected'));
+
+ final cubit = build();
+ final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry);
+ await cubit.start();
+ final state = await retry as PayProcessPayRetry;
+
+ // The swap is done; a failed pay must NOT force a re-swap.
+ expect(state.reason, PayRetryReason.transient);
+ expect(cubit.state, isNot(isA()));
+ await cubit.close();
+ });
+
+ test('terminal non-completed status (Cancelled) → pay-only retry', () async {
+ fakeAsync((async) {
+ wireHappyPath();
+ when(() => payService.getPayStatus('pl_abc')).thenAnswer(
+ (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.cancelled),
+ );
+
+ final cubit = build();
+ cubit.start();
+ drain(async);
+ expect(cubit.state, isA());
+
+ async.elapse(const Duration(seconds: 3));
+ drain(async);
+ // A cancelled settlement after the swap leaves the user holding ZCHF — it
+ // is recoverable by retrying the pay leg, not a terminal failure.
+ final state = cubit.state as PayProcessPayRetry;
+ expect(state.reason, PayRetryReason.transient);
+
+ cubit.close();
+ async.flushTimers();
+ });
+ });
+
+ test('status polling ignores a transient error then settles', () async {
+ fakeAsync((async) {
+ wireHappyPath();
+ var call = 0;
+ when(() => payService.getPayStatus('pl_abc')).thenAnswer((_) async {
+ call++;
+ if (call == 1) throw Exception('rpc 503');
+ return const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed);
+ });
+
+ final cubit = build();
+ cubit.start();
+ drain(async);
+
+ // 1st poll throws → still awaiting.
+ async.elapse(const Duration(seconds: 3));
+ drain(async);
+ expect(cubit.state, isA());
+
+ // 2nd poll completes → success.
+ async.elapse(const Duration(seconds: 3));
+ drain(async);
+ expect(cubit.state, isA());
+
+ cubit.close();
+ async.flushTimers();
+ });
+ });
+
+ test('low ETH balance → faucet → eth polling crosses threshold → swap proceeds', () async {
+ fakeAsync((async) {
+ wireHappyPath();
+ when(
+ () => payService.getSwapPaymentInfo(any()),
+ ).thenAnswer((_) async => _swap(ethBalance: 0, requiredGasEth: 0.001));
+ when(
+ () => faucet.requestFaucet(),
+ ).thenAnswer((_) async => const FaucetResponseDto(txId: '0xf', amount: 0.01));
+ var balanceCall = 0;
+ when(() => blockchain.getEthBalance(any())).thenAnswer((_) async {
+ balanceCall++;
+ return balanceCall == 1 ? 0.0 : 0.01;
+ });
+ when(() => payService.getPayStatus('pl_abc')).thenAnswer(
+ (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed),
+ );
+
+ final cubit = build();
+ cubit.start();
+ drain(async);
+ expect(cubit.state, isA());
+
+ // 1st eth poll @ 5s — still 0.
+ async.elapse(const Duration(seconds: 5));
+ drain(async);
+ expect(cubit.state, isA());
+
+ // 2nd eth poll @ 10s — funded → swap runs through to settlement polling.
+ async.elapse(const Duration(seconds: 5));
+ drain(async);
+ expect(cubit.state, isA());
+
+ // status poll completes the flow.
+ async.elapse(const Duration(seconds: 3));
+ drain(async);
+ expect(cubit.state, isA());
+
+ cubit.close();
+ async.flushTimers();
+ });
+ });
+
+ test('faucet request failure → insufficientEth', () async {
+ when(
+ () => payService.getSwapPaymentInfo(any()),
+ ).thenAnswer((_) async => _swap(ethBalance: 0, requiredGasEth: 0.001));
+ when(() => faucet.requestFaucet()).thenThrow(Exception('faucet down'));
+
+ final cubit = build();
+ final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure);
+ await cubit.start();
+ final state = await failed as PayProcessFailure;
+
+ expect(state.reason, PayProcessFailureReason.insufficientEth);
+ await cubit.close();
+ });
+
+ test('UnsupportedError while signing → signatureUnsupported', () async {
+ wireHappyPath();
+ // Wallet reports software type (passes the start() gate) but the credentials
+ // throw UnsupportedError on sign — exercises the in-sign defensive guard.
+ when(() => account.primaryAddress).thenReturn(_UnsupportedCreds());
+
+ final cubit = build();
+ final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure);
+ await cubit.start();
+ final state = await failed as PayProcessFailure;
+
+ expect(state.reason, PayProcessFailureReason.signatureUnsupported);
+ await cubit.close();
+ });
+
+ test('swap quote fetch failure → generic', () async {
+ when(() => payService.getSwapPaymentInfo(any())).thenThrow(Exception('api 500'));
+
+ final cubit = build();
+ final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure);
+ await cubit.start();
+ final state = await failed as PayProcessFailure;
+
+ expect(state.reason, PayProcessFailureReason.generic);
+ await cubit.close();
+ });
+
+ test('BitBox disconnect during the swap sign → bitboxRequired', () async {
+ wireHappyPath();
+ when(() => account.primaryAddress).thenReturn(
+ FakeBitboxCredentials(behavior: FakeBitboxBehavior.disconnect, signDelay: Duration.zero),
+ );
+
+ final cubit = build();
+ final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure);
+ await cubit.start();
+ final state = await failed as PayProcessFailure;
+
+ expect(state.reason, PayProcessFailureReason.bitboxRequired);
+ await cubit.close();
+ });
+
+ test('generic sign failure during the swap → generic', () async {
+ wireHappyPath();
+ when(() => account.primaryAddress).thenReturn(
+ FakeBitboxCredentials(behavior: FakeBitboxBehavior.malformed, signDelay: Duration.zero),
+ );
+
+ final cubit = build();
+ final failed = cubit.stream.firstWhere((s) => s is PayProcessFailure);
+ await cubit.start();
+ final state = await failed as PayProcessFailure;
+
+ expect(state.reason, PayProcessFailureReason.generic);
+ await cubit.close();
+ });
+
+ test('transient quote re-fetch failure after swap → retry (not re-scan)', () async {
+ wireHappyPath();
+ when(() => payService.getPaymentDetails('pl_abc')).thenThrow(Exception('lnurlp 500'));
+
+ final cubit = build();
+ final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry);
+ await cubit.start();
+ final state = await retry as PayProcessPayRetry;
+
+ // A transient fetch error is NOT a genuine expiry — it routes to the
+ // pay-only retry, never to a re-scan → re-swap.
+ expect(state.reason, PayRetryReason.transient);
+ await cubit.close();
+ });
+
+ test('BitBox disconnect during the pay sign (after swap) → pay-only retry', () async {
+ wireHappyPath();
+ // First sign (swap) succeeds; the second sign (pay) reports a dropped BLE
+ // link. Because the swap already happened, this is a retryable pay-leg
+ // failure rather than a terminal one.
+ final creds = _CountingSignCreds(
+ throwOnCall: 2,
+ error: const BitboxNotConnectedException(),
+ );
+ when(() => account.primaryAddress).thenReturn(creds);
+
+ final cubit = build();
+ final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry);
+ await cubit.start();
+ final state = await retry as PayProcessPayRetry;
+
+ expect(creds.calls, 2);
+ expect(state.reason, PayRetryReason.transient);
+ await cubit.close();
+ });
+
+ test('insufficient ZCHF after swap (fresh amount > acquired) → typed retry', () async {
+ wireHappyPath();
+ // Swap acquires estimatedAmount=960 ZCHF, but the fresh quote now demands
+ // 1000 ZCHF — more than was swapped. Surface the typed, retryable state.
+ when(() => payService.getPaymentDetails('pl_abc')).thenAnswer(
+ (_) async => _details(
+ expiration: DateTime.now().add(const Duration(minutes: 5)),
+ zchf: 1000,
+ ),
+ );
+
+ final cubit = build();
+ final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry);
+ await cubit.start();
+ final state = await retry as PayProcessPayRetry;
+
+ expect(state.reason, PayRetryReason.insufficientZchf);
+ // The pay leg is never attempted — the swapped ZCHF stays in the wallet.
+ verifyNever(() => payService.createPayUnsignedTransaction(any()));
+ await cubit.close();
+ });
+
+ test('retryPay re-runs the pay leg only — never re-swaps', () async {
+ wireHappyPath();
+ // First pass: quote re-fetch throws → PayProcessPayRetry.
+ var detailsCall = 0;
+ when(() => payService.getPaymentDetails('pl_abc')).thenAnswer((_) async {
+ detailsCall++;
+ if (detailsCall == 1) throw Exception('lnurlp 500');
+ return _details(expiration: DateTime.now().add(const Duration(minutes: 5)));
+ });
+ when(() => payService.getPayStatus('pl_abc')).thenAnswer(
+ (_) async => const RealUnitOcpPayStatusDto(status: OcpPaymentStatus.completed),
+ );
+
+ final cubit = build();
+ final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry);
+ await cubit.start();
+ await retry;
+ expect(cubit.state, isA());
+
+ // Retry the pay leg: it must re-fetch the quote + submit WITHOUT re-swapping.
+ final settled = cubit.stream.firstWhere((s) => s is PayProcessAwaitingSettlement);
+ await cubit.retryPay();
+ await settled;
+
+ expect(cubit.state, isA());
+ // The swap legs ran EXACTLY ONCE over the whole flow — the retry reused the
+ // already-acquired ZCHF and never re-swapped (the key fund-safety guarantee).
+ verify(() => payService.createSwapUnsignedTransaction(any())).called(1);
+ verify(() => payService.broadcastSwapTransaction(any(), any())).called(1);
+ // The pay leg's submit ran once (only on the successful retry).
+ verify(() => payService.submitPay(any())).called(1);
+ await cubit.close();
+ });
+
+ test('retryPay is a no-op before a swap has completed', () async {
+ wireHappyPath();
+
+ final cubit = build();
+ // Never started → swap not completed → retry must not touch the network.
+ await cubit.retryPay();
+
+ verifyNever(() => payService.getPaymentDetails(any()));
+ await cubit.close();
+ });
+
+ test('non-signing wallet detected only at the pay sign (after swap) → retry', () async {
+ wireHappyPath();
+ // Swap sign succeeds; the pay sign hits a non-signing credential
+ // (UnsupportedError). Post-swap, this is a retryable pay-leg failure.
+ final creds = _CountingSignCreds(
+ throwOnCall: 2,
+ error: UnsupportedError('cannot sign'),
+ );
+ when(() => account.primaryAddress).thenReturn(creds);
+
+ final cubit = build();
+ final retry = cubit.stream.firstWhere((s) => s is PayProcessPayRetry);
+ await cubit.start();
+ final state = await retry as PayProcessPayRetry;
+
+ expect(creds.calls, 2);
+ expect(state.reason, PayRetryReason.transient);
+ await cubit.close();
+ });
+}
+
+/// Credentials that produce a real signature for every sign except the
+/// [throwOnCall]-th, which throws [error]. Lets a test target the swap (call 1)
+/// vs. the pay (call 2) sign deterministically.
+class _CountingSignCreds extends Fake implements CredentialsWithKnownAddress {
+ _CountingSignCreds({required this.throwOnCall, required this.error});
+
+ final int throwOnCall;
+ final Object error;
+ int calls = 0;
+
+ @override
+ EthereumAddress get address =>
+ EthereumAddress.fromHex('0x9F5713dEAcb8e9CaB6c2D3FaE1aFc2715F8D2D71');
+
+ @override
+ Future signToSignature(
+ Uint8List payload, {
+ int? chainId,
+ bool isEIP1559 = false,
+ }) async {
+ calls++;
+ if (calls == throwOnCall) throw error;
+ return EthPrivateKey.fromHex(
+ 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612',
+ ).signToSignature(payload, chainId: chainId, isEIP1559: isEIP1559);
+ }
+}
diff --git a/test/screens/pay/pay_process_page_test.dart b/test/screens/pay/pay_process_page_test.dart
new file mode 100644
index 00000000..bff92a33
--- /dev/null
+++ b/test/screens/pay/pay_process_page_test.dart
@@ -0,0 +1,268 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:get_it/get_it.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/packages/config/api_config.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/packages/service/wallet_service.dart';
+import 'package:realunit_wallet/packages/utils/default_assets.dart';
+import 'package:realunit_wallet/packages/wallet/wallet.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart';
+import 'package:realunit_wallet/screens/pay/pay_process_page.dart';
+
+import '../../helper/helper.dart';
+
+class _MockPayProcessCubit extends MockCubit implements PayProcessCubit {}
+
+class _MockPayService extends Mock implements RealUnitPayService {}
+
+class _MockFaucetService extends Mock implements DfxFaucetService {}
+
+class _MockBlockchainService extends Mock implements DfxBlockchainApiService {}
+
+class _MockWalletService extends Mock implements WalletService {}
+
+class _MockAppStore extends Mock implements AppStore {}
+
+class _MockApiConfig extends Mock implements ApiConfig {}
+
+class _MockWallet extends Mock implements SoftwareWallet {}
+
+void main() {
+ late _MockPayProcessCubit processCubit;
+
+ setUpAll(() {
+ final getIt = GetIt.instance;
+ // PayProcessPage resolves a full service graph from getIt and calls
+ // start(). A debug wallet makes start() settle immediately
+ // (signatureUnsupported) without touching the chain.
+ final payService = _MockPayService();
+ getIt.registerSingleton(payService);
+ getIt.registerSingleton(_MockFaucetService());
+ getIt.registerSingleton(_MockBlockchainService());
+ getIt.registerSingleton(_MockWalletService());
+ final appStore = _MockAppStore();
+ final apiConfig = _MockApiConfig();
+ when(() => apiConfig.asset).thenReturn(realUnitAsset);
+ final wallet = _MockWallet();
+ when(() => wallet.walletType).thenReturn(WalletType.debug);
+ when(() => appStore.wallet).thenReturn(wallet);
+ when(() => appStore.apiConfig).thenReturn(apiConfig);
+ getIt.registerSingleton(appStore);
+ });
+
+ tearDownAll(() async => GetIt.instance.reset());
+
+ setUp(() {
+ processCubit = _MockPayProcessCubit();
+ when(() => processCubit.state).thenReturn(const PayProcessInitial());
+ when(() => processCubit.retryPay()).thenAnswer((_) async {});
+ });
+
+ Widget buildSubject() => BlocProvider.value(
+ value: processCubit,
+ child: const PayProcessView(),
+ );
+
+ group('$PayProcessPage', () {
+ testWidgets('builds its own cubit and renders $PayProcessView', (tester) async {
+ await tester.pumpApp(const PayProcessPage(paymentLinkId: 'pl_abc', zchfNeeded: 42.7));
+ // start() runs and emits a failure on the debug wallet; pump a frame to
+ // let the cubit settle (the sheet animation is not awaited here).
+ await tester.pump();
+
+ expect(find.byType(PayProcessView), findsOne);
+ });
+ });
+
+ group('$PayProcessView progress labels', () {
+ Future expectLabel(WidgetTester tester, PayProcessState state, String label) async {
+ when(() => processCubit.state).thenReturn(state);
+ await tester.pumpApp(buildSubject());
+
+ expect(find.byType(CupertinoActivityIndicator), findsOne);
+ expect(find.text(label), findsOne);
+ }
+
+ testWidgets('initial shows preparing-swap', (tester) async {
+ await expectLabel(tester, const PayProcessInitial(), S.current.payPreparingSwap);
+ });
+
+ testWidgets('preparing-swap label', (tester) async {
+ await expectLabel(tester, const PayProcessPreparingSwap(), S.current.payPreparingSwap);
+ });
+
+ testWidgets('waiting-for-eth label', (tester) async {
+ await expectLabel(tester, const PayProcessWaitingForEth(), S.current.payWaitingForEth);
+ });
+
+ testWidgets('swapping label', (tester) async {
+ await expectLabel(tester, const PayProcessSwapping(), S.current.paySwapping);
+ });
+
+ testWidgets('refreshing-quote label', (tester) async {
+ await expectLabel(tester, const PayProcessRefreshingQuote(), S.current.payRefreshingQuote);
+ });
+
+ testWidgets('paying label', (tester) async {
+ await expectLabel(tester, const PayProcessPaying(), S.current.payPaying);
+ });
+
+ testWidgets('awaiting-settlement label', (tester) async {
+ await expectLabel(
+ tester,
+ const PayProcessAwaitingSettlement('0xtx'),
+ S.current.payAwaitingSettlement,
+ );
+ });
+
+ testWidgets('success label', (tester) async {
+ await expectLabel(tester, const PayProcessSuccess(), S.current.paySuccess);
+ });
+
+ testWidgets('pay-retry label', (tester) async {
+ await expectLabel(
+ tester,
+ const PayProcessPayRetry(PayRetryReason.quoteExpired),
+ S.current.payRetryTitle,
+ );
+ });
+
+ testWidgets('failure label', (tester) async {
+ await expectLabel(
+ tester,
+ const PayProcessFailure(PayProcessFailureReason.generic),
+ S.current.payFailureTitle,
+ );
+ });
+ });
+
+ // The result/retry sheets are modal bottom sheets shown from the listener.
+ // The PayProcessView keeps a CupertinoActivityIndicator animating behind the
+ // sheet, so pumpAndSettle never settles; pump fixed frames to open the sheet.
+ // A phone-sized surface keeps the taller retry sheet from overflowing the
+ // default 800x600 test viewport (mirrors the logout-sheet test convention).
+ Future pumpWithState(WidgetTester tester, PayProcessState terminal) async {
+ tester.view.physicalSize = const Size(1200, 2400);
+ tester.view.devicePixelRatio = 1.0;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+
+ whenListen(
+ processCubit,
+ Stream.fromIterable([terminal]),
+ initialState: const PayProcessSwapping(),
+ );
+ await tester.pumpApp(buildSubject());
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+ }
+
+ group('$PayProcessView result sheet', () {
+ testWidgets('success emits a success sheet with title + description', (tester) async {
+ await pumpWithState(tester, const PayProcessSuccess());
+
+ expect(find.text(S.current.paySuccessDescription), findsOne);
+ expect(find.byIcon(Icons.check_circle_rounded), findsOne);
+ expect(find.text(S.current.close), findsOne);
+
+ // Tapping close pops the sheet and then pops the page.
+ await tester.tap(find.text(S.current.close));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+
+ expect(find.byIcon(Icons.check_circle_rounded), findsNothing);
+ });
+
+ testWidgets('insufficient-zchf failure emits a failure sheet', (tester) async {
+ await pumpWithState(
+ tester,
+ const PayProcessFailure(PayProcessFailureReason.insufficientZchf),
+ );
+
+ // payFailureTitle also renders as the progress-label behind the sheet,
+ // so it appears twice; the reason message is the sheet-unique assertion.
+ expect(find.text(S.current.payFailureTitle), findsWidgets);
+ expect(find.text(S.current.payFailureInsufficientZchf), findsOne);
+ expect(find.byIcon(Icons.error_rounded), findsOne);
+ });
+
+ testWidgets('insufficient-eth failure message', (tester) async {
+ await pumpWithState(
+ tester,
+ const PayProcessFailure(PayProcessFailureReason.insufficientEth),
+ );
+
+ expect(find.text(S.current.payFailureInsufficientEth), findsOne);
+ });
+
+ testWidgets('signature-unsupported failure message', (tester) async {
+ await pumpWithState(
+ tester,
+ const PayProcessFailure(PayProcessFailureReason.signatureUnsupported),
+ );
+
+ expect(find.text(S.current.payFailureSignatureUnsupported), findsOne);
+ });
+
+ testWidgets('bitbox-required failure message', (tester) async {
+ await pumpWithState(
+ tester,
+ const PayProcessFailure(PayProcessFailureReason.bitboxRequired),
+ );
+
+ expect(find.text(S.current.payFailureBitboxRequired), findsOne);
+ });
+
+ testWidgets('generic failure message', (tester) async {
+ await pumpWithState(
+ tester,
+ const PayProcessFailure(PayProcessFailureReason.generic),
+ );
+
+ expect(find.text(S.current.payFailureGeneric), findsOne);
+ });
+ });
+
+ group('$PayProcessView retry sheet', () {
+ testWidgets('pay-retry emits a retry sheet whose primary action calls retryPay', (
+ tester,
+ ) async {
+ await pumpWithState(tester, const PayProcessPayRetry(PayRetryReason.quoteExpired));
+
+ expect(find.text(S.current.payRetryQuoteExpired), findsOne);
+ expect(find.byIcon(Icons.replay_rounded), findsOne);
+
+ await tester.tap(find.text(S.current.payRetryButton));
+ await tester.pump();
+
+ verify(() => processCubit.retryPay()).called(1);
+ });
+
+ testWidgets('retry sheet close action dismisses without retrying', (tester) async {
+ await pumpWithState(tester, const PayProcessPayRetry(PayRetryReason.transient));
+
+ expect(find.text(S.current.payRetryTransient), findsOne);
+
+ await tester.tap(find.text(S.current.close));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+
+ verifyNever(() => processCubit.retryPay());
+ expect(find.text(S.current.payRetryTransient), findsNothing);
+ });
+
+ testWidgets('insufficient-zchf retry reason shows its message', (tester) async {
+ await pumpWithState(tester, const PayProcessPayRetry(PayRetryReason.insufficientZchf));
+
+ expect(find.text(S.current.payRetryInsufficientZchf), findsOne);
+ });
+ });
+}
diff --git a/test/screens/pay/pay_process_state_test.dart b/test/screens/pay/pay_process_state_test.dart
new file mode 100644
index 00000000..354720b6
--- /dev/null
+++ b/test/screens/pay/pay_process_state_test.dart
@@ -0,0 +1,64 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_process/pay_process_cubit.dart';
+
+void main() {
+ group('PayProcessState equality (Equatable props)', () {
+ test('progress states with no fields expose empty props and compare by type', () {
+ // Reading `.props` directly evaluates the inherited base getter (const
+ // canonicalization would otherwise make `==` short-circuit via identical).
+ expect(const PayProcessPreparingSwap().props, isEmpty);
+ expect(const PayProcessWaitingForEth().props, isEmpty);
+ expect(const PayProcessInitial().props, isEmpty);
+ expect(const PayProcessSwapping().props, isEmpty);
+ expect(const PayProcessRefreshingQuote().props, isEmpty);
+ expect(const PayProcessPaying().props, isEmpty);
+ expect(const PayProcessSuccess().props, isEmpty);
+ expect(
+ const PayProcessPreparingSwap(),
+ isNot(equals(const PayProcessWaitingForEth())),
+ );
+ });
+
+ test('PayProcessAwaitingSettlement is keyed on txId', () {
+ expect(
+ const PayProcessAwaitingSettlement('0xtx'),
+ const PayProcessAwaitingSettlement('0xtx'),
+ );
+ expect(
+ const PayProcessAwaitingSettlement('0xtx'),
+ isNot(equals(const PayProcessAwaitingSettlement('0xother'))),
+ );
+ expect(const PayProcessAwaitingSettlement('0xtx').props, ['0xtx']);
+ });
+
+ test('PayProcessFailure is keyed on reason + message', () {
+ expect(
+ const PayProcessFailure(PayProcessFailureReason.generic),
+ const PayProcessFailure(PayProcessFailureReason.generic),
+ );
+ expect(
+ const PayProcessFailure(PayProcessFailureReason.generic),
+ isNot(equals(const PayProcessFailure(PayProcessFailureReason.insufficientEth))),
+ );
+ expect(
+ const PayProcessFailure(PayProcessFailureReason.generic, message: 'boom').props,
+ [PayProcessFailureReason.generic, 'boom'],
+ );
+ });
+
+ test('PayProcessPayRetry is keyed on reason + message', () {
+ expect(
+ const PayProcessPayRetry(PayRetryReason.transient),
+ const PayProcessPayRetry(PayRetryReason.transient),
+ );
+ expect(
+ const PayProcessPayRetry(PayRetryReason.transient),
+ isNot(equals(const PayProcessPayRetry(PayRetryReason.quoteExpired))),
+ );
+ expect(
+ const PayProcessPayRetry(PayRetryReason.insufficientZchf, message: 'short').props,
+ [PayRetryReason.insufficientZchf, 'short'],
+ );
+ });
+ });
+}
diff --git a/test/screens/pay/pay_quote_cubit_test.dart b/test/screens/pay/pay_quote_cubit_test.dart
new file mode 100644
index 00000000..91d8b34b
--- /dev/null
+++ b/test/screens/pay/pay_quote_cubit_test.dart
@@ -0,0 +1,103 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/payment/pay/dto/lnurlp_payment_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart';
+
+class _MockPayService extends Mock implements RealUnitPayService {}
+
+// Real Sepolia OCP capture (DFXswiss/api #3819): a CHF 2.00 payment link whose
+// Ethereum method settles 2.0 ZCHF. The cubit reads these amounts verbatim from
+// the public lnurlp quote — it never computes them.
+LnurlpPaymentDto _details({
+ required DateTime expiration,
+ bool withEthZchf = true,
+ double zchf = 2.0,
+}) {
+ return LnurlpPaymentDto(
+ requestedAmount: const LnurlpRequestedAmountDto(asset: 'CHF', amount: 2),
+ quote: LnurlpQuoteDto(id: 'plq_realunit_ocp_sepolia', expiration: expiration),
+ transferAmounts: [
+ if (withEthZchf)
+ LnurlpTransferAmountDto(
+ method: 'Ethereum',
+ assets: [LnurlpTransferAssetDto(asset: 'ZCHF', amount: zchf)],
+ )
+ else
+ const LnurlpTransferAmountDto(
+ method: 'Bitcoin',
+ assets: [LnurlpTransferAssetDto(asset: 'BTC', amount: 0.0005)],
+ ),
+ ],
+ );
+}
+
+void main() {
+ late _MockPayService payService;
+
+ setUp(() {
+ payService = _MockPayService();
+ });
+
+ PayQuoteCubit build() => PayQuoteCubit(payService, 'pl_realunit_ocp_sepolia');
+
+ blocTest(
+ 'a fresh quote with an Ethereum/ZCHF method emits PayQuoteReady',
+ build: build,
+ setUp: () {
+ when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenAnswer(
+ (_) async => _details(expiration: DateTime.now().add(const Duration(minutes: 5))),
+ );
+ },
+ act: (cubit) => cubit.load(),
+ expect: () => [isA(), isA()],
+ verify: (cubit) {
+ final state = cubit.state as PayQuoteReady;
+ expect(state.quoteId, 'plq_realunit_ocp_sepolia');
+ expect(state.fiatAsset, 'CHF');
+ expect(state.fiatAmount, 2);
+ expect(state.zchfAmount, 2.0);
+ },
+ );
+
+ blocTest(
+ 'an expired quote emits PayQuoteExpired',
+ build: build,
+ setUp: () {
+ when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenAnswer(
+ (_) async => _details(expiration: DateTime.now().subtract(const Duration(minutes: 1))),
+ );
+ },
+ act: (cubit) => cubit.load(),
+ expect: () => [isA(), isA()],
+ );
+
+ blocTest(
+ 'a link without an Ethereum/ZCHF method emits PayQuoteUnavailable',
+ build: build,
+ setUp: () {
+ when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenAnswer(
+ (_) async => _details(
+ expiration: DateTime.now().add(const Duration(minutes: 5)),
+ withEthZchf: false,
+ ),
+ );
+ },
+ act: (cubit) => cubit.load(),
+ expect: () => [isA(), isA()],
+ );
+
+ blocTest(
+ 'a service error emits PayQuoteError',
+ build: build,
+ setUp: () {
+ when(() => payService.getPaymentDetails('pl_realunit_ocp_sepolia')).thenThrow(
+ const ApiException(code: 'X', message: 'boom'),
+ );
+ },
+ act: (cubit) => cubit.load(),
+ expect: () => [isA(), isA()],
+ );
+}
diff --git a/test/screens/pay/pay_quote_page_test.dart b/test/screens/pay/pay_quote_page_test.dart
new file mode 100644
index 00000000..6e1952bc
--- /dev/null
+++ b/test/screens/pay/pay_quote_page_test.dart
@@ -0,0 +1,153 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter/cupertino.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:get_it/get_it.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/packages/config/api_config.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/packages/service/wallet_service.dart';
+import 'package:realunit_wallet/packages/utils/default_assets.dart';
+import 'package:realunit_wallet/packages/wallet/wallet.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_quote/pay_quote_cubit.dart';
+import 'package:realunit_wallet/screens/pay/pay_process_page.dart';
+import 'package:realunit_wallet/screens/pay/pay_quote_page.dart';
+
+import '../../helper/helper.dart';
+
+class _MockPayQuoteCubit extends MockCubit implements PayQuoteCubit {}
+
+class _MockPayService extends Mock implements RealUnitPayService {}
+
+class _MockFaucetService extends Mock implements DfxFaucetService {}
+
+class _MockBlockchainService extends Mock implements DfxBlockchainApiService {}
+
+class _MockWalletService extends Mock implements WalletService {}
+
+class _MockAppStore extends Mock implements AppStore {}
+
+class _MockApiConfig extends Mock implements ApiConfig {}
+
+class _MockWallet extends Mock implements SoftwareWallet {}
+
+void main() {
+ late _MockPayQuoteCubit quoteCubit;
+
+ // Real Sepolia OCP capture (DFXswiss/api #3819): CHF 2.00 → 2.0 ZCHF on the
+ // Ethereum method.
+ const ready = PayQuoteReady(
+ paymentLinkId: 'pl_realunit_ocp_sepolia',
+ quoteId: 'plq_realunit_ocp_sepolia',
+ fiatAsset: 'CHF',
+ fiatAmount: 2,
+ zchfAmount: 2.0,
+ );
+
+ setUpAll(() {
+ final getIt = GetIt.instance;
+
+ // PayQuotePage resolves the pay service from getIt and calls load(); the
+ // load throws a typed error here so the pushed route builds deterministically
+ // (rendering PayQuoteError) without a live backend.
+ final payService = _MockPayService();
+ when(() => payService.getPaymentDetails(any())).thenThrow(
+ const ApiException(code: 'TEST', message: 'no backend in widget test'),
+ );
+ getIt.registerSingleton(payService);
+
+ // The confirm button pushes PayProcessPage, which resolves a full service
+ // graph from getIt and calls start(). A debug wallet makes start() settle
+ // immediately (signatureUnsupported) without touching the chain.
+ getIt.registerSingleton(_MockFaucetService());
+ getIt.registerSingleton(_MockBlockchainService());
+ getIt.registerSingleton(_MockWalletService());
+ final appStore = _MockAppStore();
+ final apiConfig = _MockApiConfig();
+ when(() => apiConfig.asset).thenReturn(realUnitAsset);
+ final wallet = _MockWallet();
+ when(() => wallet.walletType).thenReturn(WalletType.debug);
+ when(() => appStore.wallet).thenReturn(wallet);
+ when(() => appStore.apiConfig).thenReturn(apiConfig);
+ getIt.registerSingleton(appStore);
+ });
+
+ tearDownAll(() async => GetIt.instance.reset());
+
+ setUp(() {
+ quoteCubit = _MockPayQuoteCubit();
+ when(() => quoteCubit.state).thenReturn(const PayQuoteLoading());
+ });
+
+ Widget buildSubject() => BlocProvider.value(
+ value: quoteCubit,
+ child: const PayQuoteView(),
+ );
+
+ group('$PayQuotePage', () {
+ testWidgets('builds its own cubit and renders $PayQuoteView', (tester) async {
+ await tester.pumpApp(const PayQuotePage(paymentLinkId: 'pl_abc'));
+
+ expect(find.byType(PayQuoteView), findsOne);
+ });
+ });
+
+ group('$PayQuoteView', () {
+ testWidgets('loading state shows a $CupertinoActivityIndicator', (tester) async {
+ when(() => quoteCubit.state).thenReturn(const PayQuoteLoading());
+ await tester.pumpApp(buildSubject());
+
+ expect(find.byType(CupertinoActivityIndicator), findsOne);
+ });
+
+ testWidgets('ready state shows the CHF amount, ZCHF needed and confirm button', (tester) async {
+ when(() => quoteCubit.state).thenReturn(ready);
+ await tester.pumpApp(buildSubject());
+
+ expect(find.text(S.current.payQuoteSummary('2.00', 'CHF')), findsOne);
+ expect(find.text('2.00 CHF'), findsOne);
+ expect(find.text('2.00 ZCHF'), findsOne);
+ expect(find.text(S.current.payConfirmButton), findsOne);
+ });
+
+ testWidgets('confirm button navigates to the process step', (tester) async {
+ when(() => quoteCubit.state).thenReturn(ready);
+ await tester.pumpApp(buildSubject());
+
+ await tester.tap(find.text(S.current.payConfirmButton));
+ // The process page renders a CupertinoActivityIndicator that animates
+ // forever, so pumpAndSettle would time out; pump fixed frames to drive
+ // the push transition instead.
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+
+ expect(find.byType(PayProcessView), findsOne);
+ });
+
+ testWidgets('expired state shows the re-scan message', (tester) async {
+ when(() => quoteCubit.state).thenReturn(const PayQuoteExpired());
+ await tester.pumpApp(buildSubject());
+
+ expect(find.text(S.current.payFailureQuoteExpired), findsOne);
+ });
+
+ testWidgets('unavailable state shows the unavailable message', (tester) async {
+ when(() => quoteCubit.state).thenReturn(const PayQuoteUnavailable());
+ await tester.pumpApp(buildSubject());
+
+ expect(find.text(S.current.payQuoteUnavailable), findsOne);
+ });
+
+ testWidgets('error state shows the generic failure message', (tester) async {
+ when(() => quoteCubit.state).thenReturn(const PayQuoteError('boom'));
+ await tester.pumpApp(buildSubject());
+
+ expect(find.text(S.current.payFailureGeneric), findsOne);
+ });
+ });
+}
diff --git a/test/screens/pay/pay_scan_cubit_test.dart b/test/screens/pay/pay_scan_cubit_test.dart
new file mode 100644
index 00000000..5d17648f
--- /dev/null
+++ b/test/screens/pay/pay_scan_cubit_test.dart
@@ -0,0 +1,51 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart';
+
+void main() {
+ // LUD-01 bech32 of `https://api.dfx.swiss/v1/lnurlp/pl_abc123`.
+ const lnurl = 'LNURL1DP68GURN8GHJ7CTSDYHXGENC9EEHW6TNWVHHVVF0D3H82UNVWQHHQMZLV93XXVFJXV5T0E5A';
+
+ group('PayScanCubit', () {
+ test('starts in PayScanScanning', () {
+ expect(PayScanCubit().state, isA());
+ });
+
+ blocTest(
+ 'a valid LNURL emits PayScanDecoded with the id',
+ build: PayScanCubit.new,
+ act: (cubit) => cubit.onCodeDetected(lnurl),
+ verify: (cubit) {
+ final state = cubit.state as PayScanDecoded;
+ expect(state.link.id, 'pl_abc123');
+ },
+ );
+
+ blocTest(
+ 'an invalid code emits PayScanInvalid',
+ build: PayScanCubit.new,
+ act: (cubit) => cubit.onCodeDetected('not-a-payment-code'),
+ expect: () => [isA()],
+ );
+
+ blocTest(
+ 'ignores further detections once decoded (no re-emit)',
+ build: PayScanCubit.new,
+ act: (cubit) {
+ cubit.onCodeDetected(lnurl);
+ cubit.onCodeDetected(lnurl);
+ },
+ expect: () => [isA()],
+ );
+
+ blocTest(
+ 'reset returns to PayScanScanning',
+ build: PayScanCubit.new,
+ act: (cubit) {
+ cubit.onCodeDetected('bad');
+ cubit.reset();
+ },
+ expect: () => [isA(), isA()],
+ );
+ });
+}
diff --git a/test/screens/pay/pay_scan_page_test.dart b/test/screens/pay/pay_scan_page_test.dart
new file mode 100644
index 00000000..b8c486d4
--- /dev/null
+++ b/test/screens/pay/pay_scan_page_test.dart
@@ -0,0 +1,122 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:get_it/get_it.dart';
+import 'package:mobile_scanner/mobile_scanner.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/lnurl_decoder.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/screens/pay/cubits/pay_scan/pay_scan_cubit.dart';
+import 'package:realunit_wallet/screens/pay/pay_quote_page.dart';
+import 'package:realunit_wallet/screens/pay/pay_scan_page.dart';
+
+import '../../helper/helper.dart';
+
+class _MockPayScanCubit extends MockCubit implements PayScanCubit {}
+
+class _MockPayService extends Mock implements RealUnitPayService {}
+
+void main() {
+ late _MockPayScanCubit scanCubit;
+
+ setUpAll(() {
+ // pay_scan_page.dart carries the `@no-integration-test` note: the live
+ // camera is exercised only on a real device. The stub keeps the headless
+ // preview deterministic and free of MissingPluginException.
+ stubMobileScannerChannel();
+
+ // The decoded-link navigation pushes PayQuotePage, which resolves the pay
+ // service from getIt and triggers a load(); register a mock whose quote
+ // fetch throws so the pushed route builds deterministically (rendering the
+ // PayQuoteError message) without touching a live backend.
+ final payService = _MockPayService();
+ when(() => payService.getPaymentDetails(any())).thenThrow(
+ const ApiException(code: 'TEST', message: 'no backend in widget test'),
+ );
+ GetIt.instance.registerSingleton(payService);
+ });
+
+ tearDownAll(() async => GetIt.instance.reset());
+
+ setUp(() {
+ scanCubit = _MockPayScanCubit();
+ when(() => scanCubit.state).thenReturn(const PayScanScanning());
+ when(() => scanCubit.reset()).thenReturn(null);
+ });
+
+ Widget buildSubject() => BlocProvider.value(
+ value: scanCubit,
+ child: const PayScanView(),
+ );
+
+ group('$PayScanPage', () {
+ testWidgets('builds its own cubit and renders $PayScanView', (tester) async {
+ await tester.pumpApp(const PayScanPage());
+
+ expect(find.byType(PayScanView), findsOne);
+ });
+ });
+
+ group('$PayScanView', () {
+ testWidgets('renders the scan title and the scanner preview', (tester) async {
+ await tester.pumpApp(buildSubject());
+
+ expect(find.text(S.current.payScanTitle), findsOne);
+ expect(find.byType(MobileScanner), findsOne);
+ });
+
+ testWidgets('onDetect forwards a scanned raw value to the cubit', (tester) async {
+ when(() => scanCubit.onCodeDetected(any())).thenReturn(null);
+
+ await tester.pumpApp(buildSubject());
+
+ final scanner = tester.widget(find.byType(MobileScanner));
+ // A capture with no barcodes is ignored (rawValue is null) …
+ scanner.onDetect!(const BarcodeCapture());
+ // … while a barcode with a raw value is forwarded to the cubit.
+ scanner.onDetect!(
+ const BarcodeCapture(barcodes: [Barcode(rawValue: 'lnurl_raw')]),
+ );
+
+ verify(() => scanCubit.onCodeDetected('lnurl_raw')).called(1);
+ });
+
+ testWidgets('an invalid scan shows a snackbar and resets the cubit', (tester) async {
+ whenListen(
+ scanCubit,
+ Stream.fromIterable([const PayScanInvalid('bad code')]),
+ initialState: const PayScanScanning(),
+ );
+
+ await tester.pumpApp(buildSubject());
+ await tester.pump();
+
+ expect(find.byType(SnackBar), findsOne);
+ expect(find.text(S.current.payScanInvalid), findsOne);
+ verify(() => scanCubit.reset()).called(1);
+ });
+
+ testWidgets('a decoded link navigates to the quote step and resets the cubit', (tester) async {
+ final link = DecodedPaymentLink(
+ id: 'pl_abc123',
+ lnurlpUrl: Uri.parse('https://api.dfx.swiss/v1/lnurlp/pl_abc123'),
+ );
+ whenListen(
+ scanCubit,
+ Stream.fromIterable([PayScanDecoded(link)]),
+ initialState: const PayScanScanning(),
+ );
+
+ await tester.pumpApp(buildSubject());
+ await tester.pumpAndSettle();
+
+ // The quote step is pushed and rendered; the cubit is reset so returning
+ // to the scanner re-arms detection.
+ expect(find.byType(PayQuoteView), findsOne);
+ verify(() => scanCubit.reset()).called(1);
+ });
+ });
+}
diff --git a/test/setup/di_pay_service_test.dart b/test/setup/di_pay_service_test.dart
new file mode 100644
index 00000000..f8d88e2a
--- /dev/null
+++ b/test/setup/di_pay_service_test.dart
@@ -0,0 +1,51 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/packages/repository/balance_repository.dart';
+import 'package:realunit_wallet/packages/repository/settings_repository.dart';
+import 'package:realunit_wallet/packages/repository/wallet_repository.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart';
+import 'package:realunit_wallet/setup/di.dart';
+
+class _MockAppStore extends Mock implements AppStore {}
+
+class _MockBalanceRepository extends Mock implements BalanceRepository {}
+
+class _MockSettingsRepository extends Mock implements SettingsRepository {}
+
+class _MockWalletRepository extends Mock implements WalletRepository {}
+
+void main() {
+ // The pay flow's backend client is wired through `setupServices()` as a
+ // factory: `() => RealUnitPayService(getIt(), getIt())`.
+ // This test exercises that exact registration and then resolves the factory.
+ //
+ // `setupServices()` constructs `BalanceService` (eager singleton) up front,
+ // so `AppStore` + `BalanceRepository` must already be registered. Resolving
+ // `RealUnitPayService` then pulls the lazy `WalletService`, whose own
+ // dependencies bottom out at `WalletRepository` + `SettingsRepository` +
+ // `AppStore` (`BitboxService` is registered by `setupServices()` itself).
+ // Registering those leaf mocks keeps the whole chain construct-only — none
+ // of the mocked collaborators perform I/O on construction.
+ setUp(() {
+ getIt.reset();
+ getIt.registerSingleton(_MockAppStore());
+ getIt.registerSingleton(_MockBalanceRepository());
+ getIt.registerSingleton(_MockSettingsRepository());
+ getIt.registerSingleton(_MockWalletRepository());
+ });
+
+ tearDown(() => getIt.reset());
+
+ test('setupServices registers a resolvable RealUnitPayService factory', () {
+ setupServices();
+
+ expect(getIt.isRegistered(), isTrue);
+
+ final service = getIt();
+ expect(service, isA());
+
+ // registerFactory hands back a fresh instance on every resolution.
+ expect(identical(service, getIt()), isFalse);
+ });
+}
diff --git a/test/setup/routing/pay_route_test.dart b/test/setup/routing/pay_route_test.dart
new file mode 100644
index 00000000..4cdb9a70
--- /dev/null
+++ b/test/setup/routing/pay_route_test.dart
@@ -0,0 +1,65 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_localizations/flutter_localizations.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart';
+import 'package:realunit_wallet/screens/pay/pay_scan_page.dart';
+import 'package:realunit_wallet/setup/routing/router_config.dart';
+import 'package:realunit_wallet/setup/routing/routes/app_routes.dart';
+
+import '../../helper/helper.dart';
+
+class _MockHomeBloc extends MockBloc implements HomeBloc {}
+
+void main() {
+ late _MockHomeBloc homeBloc;
+
+ setUpAll(() {
+ // PayScanPage embeds a MobileScanner; the stub keeps the headless camera
+ // preview deterministic and free of MissingPluginException.
+ stubMobileScannerChannel();
+ });
+
+ setUp(() {
+ homeBloc = _MockHomeBloc();
+ when(() => homeBloc.state).thenReturn(const HomeState());
+ });
+
+ // Mirrors the production wiring in main.dart: the routed pages read their
+ // blocs from above MaterialApp.router, so HomeBloc (used by the initial
+ // /home route) is provided here. Navigation then drives the real
+ // `routerConfig` to the /pay GoRoute under test.
+ Future pumpRouter(WidgetTester tester) async {
+ await tester.pumpWidget(
+ BlocProvider.value(
+ value: homeBloc,
+ child: MaterialApp.router(
+ routerConfig: routerConfig,
+ localizationsDelegates: const [
+ S.delegate,
+ GlobalMaterialLocalizations.delegate,
+ ],
+ supportedLocales: S.delegate.supportedLocales,
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+ }
+
+ testWidgets('the pay route builds PayScanPage', (tester) async {
+ await pumpRouter(tester);
+
+ routerConfig.goNamed(AppRoutes.pay);
+ await tester.pumpAndSettle();
+
+ expect(find.byType(PayScanPage), findsOneWidget);
+ expect(find.byType(PayScanView), findsOneWidget);
+
+ // Restore the router to its initial location so the global singleton
+ // does not leak the /pay location into any later test.
+ addTearDown(() => routerConfig.goNamed(AppRoutes.home));
+ });
+}