Skip to content

feat: RealUnit wallet-to-wallet (W2W) gasless transfer flow - #687

Merged
TaprootFreak merged 10 commits into
stagingfrom
feature/w2w-transfer
Jul 22, 2026
Merged

feat: RealUnit wallet-to-wallet (W2W) gasless transfer flow#687
TaprootFreak merged 10 commits into
stagingfrom
feature/w2w-transfer

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

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 reuses eip712_signer.dart / eip7702_signer.dart and the wallet unlock/lock boundary; it is not the bitbox raw-tx path.

Flow (Page + Cubit per step, separate state files):

  1. Recipient — scan a wallet QR or paste/type an EVM address; client-side checksum validation for UX only (the API is the final authority). An ethereum: EIP-681 URI is normalized to the bare address.
  2. Amount — whole REALU shares (REALU decimals = 0); the available balance is read via the shared balance watcher and the over-balance guard is local UX only.
  3. Confirm — review recipient + amount.
  4. Process — capability gate (software-only signing) → 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 ServiceUnavailable 503 → friendly "temporarily unavailable", REALU untouched).

Scanner reuse (no duplication)

The scanner from #674 was an inline MobileScanner in PayScanPage. Extracted a shared lib/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 is staging. 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 (no coverage: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).

@TaprootFreak
TaprootFreak marked this pull request as ready for review June 4, 2026 08:57
@TaprootFreak
TaprootFreak force-pushed the feature/w2w-transfer branch from e4925f9 to 183b7a4 Compare July 13, 2026 15:11
@TaprootFreak
TaprootFreak changed the base branch from staging to feature/ocp-pay-flow July 13, 2026 15:11
@TaprootFreak
TaprootFreak force-pushed the feature/w2w-transfer branch 2 times, most recently from 532d1cc to 63743cc Compare July 13, 2026 16:45
@TaprootFreak

Copy link
Copy Markdown
Contributor Author

Follow-up hardening complete — corrective round pushed as b0365860.

This closes the residual edge cases from review of the W2W transfer flow:

  • Double-transfer: the confirm button guards synchronously against a rapid double-tap, and PopScope keeps canPop true only for the terminal success state — so a system-back during the failure→retry→signing transition can no longer slip through and start a second transfer.
  • Recipient parsing: ambiguous or malformed EIP-681 URIs are rejected (exactly one non-empty address query param; parse errors are caught).
  • State safety: prepare failures funnel through a single isClosed-guarded emit (no emit-after-close); the registration/KYC-required path always shows the localized message.

Verified locally with the CI commands: flutter analyzeNo issues found!; flutter test --coverage --exclude-tags golden3042 passed.

Note: CI has not re-run on the latest commits — this PR currently conflicts with its base (feature/ocp-pay-flow), so GitHub cannot produce a merge commit and the pull_request workflow does not fire. It will run once the base is merged to staging and this branch is rebased onto it.

@TaprootFreak

Copy link
Copy Markdown
Contributor Author

Review summary — rebase onto updated OCP-pay base + full pr-ready

Rebased the W2W transfer flow onto the updated OCP-pay base (feature/ocp-pay-flow); the 7 feature commits replayed cleanly and only the shared dashboard / routing / ARB integration points needed a union merge (both flows verified to coexist). Then ran the full two-lens review.

Conformity lens — the new send surfaces used Column + Spacer() sticky CTAs instead of ScrollableActionsLayout (the BitBox-untappable-button bug class, blocking per CONTRIBUTING). Migrated the amount / confirm / process-result / recipient surfaces to ScrollableActionsLayout, added responsive-matrix tests (device × text scale, expectNoLayoutOverflow + expectFullyTappable) and catalog entries. That gate surfaced and fixed a latent RenderFlex overflow in the confirm summary rows at textScale 3.0. Also applied the {Resource}Dto naming to the EIP-7702 data DTO and corrected an import ordering.

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 QrScannerView — is tracked separately in #874, since the robust fix belongs in the shared widget rather than this rebase.

Verified on the build host: flutter analyze clean, send goldens regenerated, responsive-matrix and send test suites green.

@TaprootFreak
TaprootFreak force-pushed the feature/w2w-transfer branch from ca9cecc to 621e81f Compare July 22, 2026 14:13
Base automatically changed from feature/ocp-pay-flow to staging July 22, 2026 21:00
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.
@TaprootFreak
TaprootFreak force-pushed the feature/w2w-transfer branch from 621e81f to cfd42fa Compare July 22, 2026 21:02
@TaprootFreak
TaprootFreak merged commit a59b989 into staging Jul 22, 2026
5 checks passed
@TaprootFreak
TaprootFreak deleted the feature/w2w-transfer branch July 22, 2026 21:09
TaprootFreak added a commit that referenced this pull request Aug 3, 2026
## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant