feat: RealUnit wallet-to-wallet (W2W) gasless transfer flow - #687
Conversation
e4925f9 to
183b7a4
Compare
532d1cc to
63743cc
Compare
|
Follow-up hardening complete — corrective round pushed as This closes the residual edge cases from review of the W2W transfer flow:
Verified locally with the CI commands: Note: CI has not re-run on the latest commits — this PR currently conflicts with its base ( |
878ee54 to
4b07824
Compare
b036586 to
5a8d3ff
Compare
Review summary — rebase onto updated OCP-pay base + full pr-readyRebased the W2W transfer flow onto the updated OCP-pay base ( Conformity lens — the new send surfaces used Logic lens — pay-scan absorption, scanner reuse, routing, and shared-code base-interaction all verified clean. One pre-existing issue — duplicate navigation on repeated QR detection, shared by the Pay and Send scan flows via Verified on the build host: |
ca9cecc to
621e81f
Compare
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.
Regenerate the three dashboard goldens that include the action row so they show the four-button layout (buy/sell/pay/send). Baselines taken from the self-hosted VR runner output (testImage).
…confirm, blind-sign, 403) - parse an EIP-681 ERC20-transfer URI correctly: take the recipient from the `address=` query parameter instead of the token contract before the `/`, and fail closed on ambiguous forms. - prevent a double transfer: single-shot confirm button (survives push/pop), isClosed guards after every await, and a terminal navigation after success. - make retry safe: keep the prepared request and re-confirm the SAME id via retryConfirm() instead of preparing a new one, and treat the backend's 409 "already confirmed" as success (mirrors the sell flow) — a retry can no longer relay a second transfer. - validate the eip7702 data against the user-confirmed recipient/amount before signing (not just the API response against itself); the doc states plainly that the blanket delegation binds the recipient server-side. - map a 403 (registration / KYC level required) to a dedicated failure reason and render the concrete API message instead of a generic dead-end. - funnel the confirm outcomes through a single isClosed-guarded emit.
- guard the confirm button synchronously so a rapid double-tap cannot start two transfers before the rebuild - keep PopScope canPop true only for the terminal success state, so a system-back during the failure->retry->signing transition cannot slip through and start a second transfer - reject ambiguous or malformed EIP-681 recipients: require exactly one non-empty address query param and catch parse errors - funnel prepare failures through a single isClosed-guarded emit to avoid emit-after-close - always show the localized message when registration or KYC is required
Migrate the send amount / confirm / process-result / recipient surfaces off Column+Spacer to ScrollableActionsLayout so the CTAs stay tappable on every device size and text scale (the BitBox-untappable-button bug class), add responsive matrix tests plus catalog entries, and fix a latent RenderFlex overflow in the confirm summary rows.
Rename RealUnitTransferEip7702Data to RealUnitTransferEip7702DataDto per the
{Resource}Dto convention, and order the sell_payment_info and web3dart imports
per the style guide.
621e81f to
cfd42fa
Compare
## Summary Soft launch for the two new phase-2 features: the **Pay** (OpenCryptoPay, #674) and **Send** (W2W transfer, #687) dashboard actions are hidden behind an invisible wall so only insiders can reach them. - The two dashboard buttons render only when a persisted `insiderFeaturesUnlocked` flag is set; Buy and Sell stay visible unconditionally. - Unlock: tap the version number in Settings **seven times** (developer-options pattern). A snackbar confirms the unlock; the flag persists across restarts (SharedPreferences, seeded into `SettingsBloc`). - The OpenCryptoPay payment deeplink intentionally keeps working regardless of the unlock state, so payment links handed to insiders resolve as before. ## Deliberate deviation from the API-authority rule (reviewed, intentional) CONTRIBUTING lists "feature visibility based on local state" as not OK and prefers an API capability flag. This gate deviates from that on purpose, as a product decision made with the API-capability alternative on the table: - The point of the soft launch is that outsiders must not even *see* the features, and the unlock must work offline/instantly for anyone told the gesture — an account-bound API capability would change the product (server-side insider bookkeeping, no gesture unlock). - No API truth is duplicated or contradicted: there is no server-side notion of this soft launch, and the API remains the sole decision authority for every actual transfer/payment the flows perform. The unlocked app renders exactly what the API-authorized app rendered before this PR; the locked app renders a subset. - Being a public repo, the mechanism is readable in source — the wall is a discoverability hurdle, not a security boundary. ## Implementation notes - The Settings version row keeps its exact visuals; it moves into a new `SettingsVersionUnlock` widget that follows the page-local `getIt` bloc-access pattern (its test harness deliberately mirrors the settings golden harness structure). - `DashboardActions` gates Pay/Send with collection-`if`s on `context.watch<SettingsBloc>()`. - `ActionButton` now scales its icon/label column down (`FittedBox`) instead of overflowing its fixed 110x50 box — the new actions matrix exposed real overflows under Expanded width squeeze (German labels, 2px at 1.0x on narrow devices) and at large text scales (up to 258px at 3.0x). The tap area stays the full box (the `InkWell` wraps it, not the scaled content). Layouts that fit are visually unchanged; the four positive-balance dashboard goldens picked up sub-pixel antialiasing deltas (77-107 bytes each) from the new render path and were regenerated by the runner. - `expectFullyTappable` maps both rect corners through the render transform, so scaled targets measure their visual rect (transform-neutral for every existing call site). - The repository setter follows the established fire-and-forget persistence idiom; the repo-wide hardening idea is tracked in #886. Pre-existing positive-balance dashboard overflows (CashHoldingBox and siblings) are tracked in #887 and deliberately not part of this PR. ## Handbook Section 79 of the handbook (/de/#insider-unlock) explains the unlock step by step in German with three screenshots (settings version row, dashboard before, dashboard after) so the link can be shared directly with the people who should know. The new dashboard_insider_unlocked golden is mapped as handbook screenshot slot 269; the three updated dashboard baselines were already mapped and refresh automatically on the next handbook deploy. ## Tests - New widget tests for the 7-tap unlock (6 taps inert, 7th dispatches exactly one event + snackbar, 9 rapid taps still dispatch exactly once, taps ignored once unlocked, version text still rendered) - `DashboardActions` locked/unlocked cases incl. the existing navigation assertions, plus a locked→unlocked transition test that pins the `context.watch` rebuild behaviour - New golden case `dashboard_insider_unlocked` (renders the same four-button dashboard the pre-PR baseline showed); existing dashboard goldens change to the 2-button locked default - New responsive-matrix group renders `DashboardActions` standalone (insider unlocked, all four buttons) across the full device/text-scale grid with overflow + tappability gates — scoped to the actions row this PR owns; the zero-balance page matrix is unchanged - Repository getter/setter covered against a real SharedPreferences backend (100% lines floor on `lib/packages/*`) - Full suite on the verification host: 4755 tests green, analyzer clean --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
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).