feat: RealUnit wallet-to-wallet (W2W) gasless transfer flow#687
Open
TaprootFreak wants to merge 8 commits into
Open
feat: RealUnit wallet-to-wallet (W2W) gasless transfer flow#687TaprootFreak wants to merge 8 commits into
TaprootFreak wants to merge 8 commits into
Conversation
Add the Phase 2 Open CryptoPay pay-flow client: scan a POS payment QR, swap REALU -> ZCHF (proceeds stay in the user wallet), then pay that ZCHF to the OCP recipient via the public lnurlp settlement path. - QR scan + LUD-01 bech32 / app->api host decode (LnurlDecoder) - RealUnitPayService (extends DFXAuthService): public lnurlp read, the 3 swap endpoints and the 3 pay endpoints, with a typed mainnet-only gate for the pay/* endpoints keyed off ApiConfig.networkMode - DTOs with fromJson per resource under models/payment/pay/dto - Page + Cubit per step (scan / quote / process), separate state files; process orchestrates ETH-gas check -> swap (sign+broadcast) -> re-fetch quote -> pay (sign+submit) -> poll status, surfacing typed failures - Unified raw-payload signing (signToSignature -> r/s/v) for software and BitBox; debug wallet surfaces a dedicated non-signing failure - New typed exceptions enumerated in exception_surface_test - AppRoutes.pay + GoRoute + a third dashboard Pay action (golden updated) - mobile_scanner dependency; iOS camera usage string covers payments - i18n keys in both ARB files Consumes DFXswiss/api#3819 (pair-PR; backend lands first).
Address reviewer findings on the irreversible REALU→ZCHF swap → OCP pay flow so a failed pay leg can never strand the user or force a re-swap. Fund safety / orchestration: - Hoist the mainnet-only environment gate to the very start of the flow (PayProcessCubit.start and PayQuoteCubit.load), gated off the new RealUnitPayService.isPaySupportedEnvironment getter. The swap can no longer run on an environment where the pay leg cannot settle; the service keeps assertPaySupported as defense-in-depth. - Add a pay-only retry after a successful swap: track swap completion + acquired ZCHF in cubit state and expose retryPay(), which re-quotes + signs + submits WITHOUT re-swapping (mirrors SellBitboxDepositRetry). A failed pay surfaces the new PayProcessPayRetry state instead of a terminal failure, so it never forces a re-scan → re-swap. - Distinguish genuine quote expiry (expiration.isBefore) from transient fetch/submit errors; both route to the pay-only retry, neither to a re-scan. Terminal non-completed settlement is retryable too. - Widen the swap headroom buffer 1.01 → 1.03 (documented) and add a typed insufficient-ZCHF-after-swap retry state when the fresh settlement amount exceeds the acquired ZCHF, instead of a server-side failure. Parsing robustness: - lnurlp DTO: parse transfer-asset amount as nullable (optional on the non-priced path) and remove the dead recipient field (a backend object, never read, that threw when populated). - Remove the dead RealUnitSwapDto.fromAmount constructor and its coverage-ignore; the flow only uses fromTargetAmount. Quality: - Fix import ordering in real_unit_pay_service. Tests: bloc_test cases for env-unsupported-before-swap, pay-only retry, transient-fetch → retry (not re-scan), insufficient-ZCHF typed state, and retryPay-never-re-swaps; nullable-amount + object-recipient DTO parsing; isPaySupportedEnvironment. i18n payRetry* keys added to both ARB files.
Cover the OCP pay flow's scan / quote / process pages with visual-regression Goldens and full widget tests so the pages are at 100% line coverage (in addition to the already-covered cubits/services). Goldens (test/goldens/screens/pay/, baselines under goldens/macos/): - pay_scan: scanning state with the camera-preview placeholder. The mobile_scanner method + event channels are stubbed via a new stubMobileScannerChannel() helper so the live-camera widget settles into a deterministic placeholder instead of throwing MissingPluginException — matching the @no-integration-test note on pay_scan_page.dart (the live camera is exercised only on a device). - pay_quote: loading, ready (CHF amount + ZCHF needed), expired and unsupported-environment states. - pay_process: swapping, awaiting-settlement and pay-retry states. Widget tests (test/screens/pay/) drive every PayScanView / PayQuoteView / PayProcessView state with mocked cubits, assert the rendered copy, and exercise the button taps (scan onDetect, quote confirm navigation, the process success/failure/retry sheets and their retry/close actions) dispatching to the mocked cubits. Baselines regenerated here are host-local; dispatch golden-regenerate.yaml on the branch to record the authoritative dfx01 baselines for the Visual Regression gate.
Add a wallet-to-wallet send flow that transfers REALU to another wallet (recipient via QR scan or manual entry), consuming the gasless EIP-7702 transfer endpoints from DFXswiss/api#3820. The transfer is gasless: DFX pays gas from a dedicated W2W gas wallet via EIP-7702, so the app signs an EIP-712 delegation + an EIP-7702 authorization exactly like the SOFTWARE gasless sell confirm — reusing eip712_signer.dart / eip7702_signer.dart and the wallet unlock/lock boundary. The capability gate (software-only signing) surfaces a dedicated unsupported state for debug/BitBox wallets; the flow is not otherwise branched on wallet type. - Steps: enter/scan recipient -> amount (whole REALU shares, vs available balance) -> confirm -> sign + PUT /transfer then /transfer/:id/confirm -> success (txHash) / typed failures (unsupported wallet, signature cancelled, invalid request, gas-funding-unavailable from the API 503). - Page + Cubit per step with separate state files. - Reuse the QR scanner: extract a shared QrScannerView wrapping MobileScanner and refactor the pay scan page onto it (no scanner duplication). - RealUnitTransferService (extends DFXAuthService) with the two endpoints, fromJson DTOs, and typed exceptions enumerated in exception_surface_test. - Navigation: AppRoutes.send + GoRoute('/send') + a dashboard Send action. - i18n: new keys in both ARBs, German + English. - bloc_test/mocktail cubit tests for every step + typed failure, widget tests for the pages, 100% scoped coverage, golden tests + baselines.
Add widget/unit tests for the changed lib lines that the existing pay + send suites did not yet exercise: - DashboardActions: render + tap-routes the buy/sell/pay/send action buttons, covering the four Expanded(ActionButton) subtrees and their onPressed push closures (incl. the new send button). - setupServices: resolve the newly registered RealUnitPayService and RealUnitTransferService factories, covering their registration and construction closures in di.dart. - routerConfig /pay and /send routes: drive the real router to each route so the GoRoute builder closures returning PayScanPage / SendRecipientPage are executed. AppRoutes.pay and AppRoutes.send are compile-time const fields (no instrumentable line); they are exercised at runtime by the above tests.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 2 Baustein 3 — RealUnit wallet-to-wallet (W2W) transfer: send REALU to another wallet, recipient picked via QR scan or manual entry. Implements #684 (umbrella #666).
The transfer is gasless via EIP-7702 — DFX pays gas from a dedicated W2W gas wallet — so the app signs an EIP-712 delegation + an EIP-7702 authorization, exactly like the existing SOFTWARE gasless sell confirm (
real_unit_sell_payment_info_service.dart). It reuseseip712_signer.dart/eip7702_signer.dartand the wallet unlock/lock boundary; it is not the bitbox raw-tx path.Flow (Page + Cubit per step, separate state files):
ethereum:EIP-681 URI is normalized to the bare address.decimals = 0); the available balance is read via the shared balance watcher and the over-balance guard is local UX only.PUT /transfer→ sign EIP-712 delegation + EIP-7702 authorization →PUT /transfer/:id/confirm→ success (txHash) / typed failure.Typed failures rendered as states (no error-string parsing): unsupported wallet (debug/BitBox), signature cancelled, invalid request (API 400/404 — invalid recipient / self-transfer / token-contract recipient / insufficient REALU), and gas-funding-unavailable (API
ServiceUnavailable503 → friendly "temporarily unavailable", REALU untouched).Scanner reuse (no duplication)
The scanner from #674 was an inline
MobileScannerinPayScanPage. Extracted a sharedlib/widgets/scanner/qr_scanner_view.dart(the camera/MethodChannel wrapper) and refactored both the pay scan page and the new send recipient page onto it — each flow keeps its own decode logic (LNURL vs EVM address). No scanner code is duplicated.API / decision authority
Consumes DFXswiss/api#3820 (pair-PR, backend lands first):
PUT /v1/realunit/transfer+PUT /v1/realunit/transfer/:id/confirm. The app renders API-signaled outcomes and does not duplicate backend KYC/registration/limit/eligibility logic.Branch / stacking
Branched from
feature/ocp-pay-flow(#674) to reuse the scanner without duplication; PR base isstaging. Stacked on #674 (scanner) — review/merge #674 first; this diff will shrink once #674 merges to staging.Tests / gates
flutter analyze: 0 issues.flutter test --coverage --exclude-tags golden: all pass; 100% scoped coverage on every new file (nocoverage:ignore).flutter test --tags golden: golden tests + baselines for the new screens.dart format(repo config: page_width 100, trailing_commas preserve): clean.Goldens regenerate pending on the runner: baselines here were rendered locally on macOS and will mismatch the CI runner; the Golden Regenerate workflow is being dispatched so the runner pushes authoritative baselines.
Stays Draft (no ready-for-review, no merge).