Skip to content

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

Open
TaprootFreak wants to merge 8 commits into
stagingfrom
feature/w2w-transfer
Open

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

Conversation

@TaprootFreak
Copy link
Copy Markdown
Contributor

@TaprootFreak TaprootFreak commented Jun 4, 2026

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 and others added 6 commits June 3, 2026 23:14
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.
@TaprootFreak TaprootFreak marked this pull request as ready for review June 4, 2026 08:57
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.
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