feat: OCP pay-flow (RealU→ZCHF→Open CryptoPay) - #674
Conversation
0c19f44 to
4d58842
Compare
Hardening pass — complete, CI greenAddressed the review findings on the OCP pay client. Verified on a macOS build host (codegen chain + Blockers
Major
Scope note / follow-up: only the pay leg's unsigned tx is validated locally; validating the preceding REALU→ZCHF swap tx needs a backend DTO carrying swap metadata (tracked separately). Stated plainly in the code docs. |
|
Hardening complete — independent conformity + correctness review passes, iterated to zero defects (4 passes; the last surfaced only a stale doc comment, now corrected). Verified locally with the CI commands: Fixed in this round:
Deferred to follow-up tickets (verified out of this PR's scope): pinning the ZCHF token to a local per-chain address, validating the swap leg against backend swap metadata, client-side swap idempotency on a lost broadcast response, and showing the actually-executed buffered swap before confirm. Note: this branch currently conflicts with |
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 widget/unit tests for the changed lib lines that the existing pay-flow suite did not yet exercise: - DashboardActions: render + tap-routes the buy/sell/pay action buttons, covering the three Expanded(ActionButton) subtrees and their onPressed push closures. - setupServices: resolve the newly registered RealUnitPayService factory, covering its registration and construction closure in di.dart. - routerConfig /pay route: drive the real router to the pay route so the GoRoute builder closure that returns PayScanPage is executed. AppRoutes.pay is a compile-time const field (no instrumentable line); it is exercised at runtime by the above tests.
The DFX backend now settles Open CryptoPay on every environment (Sepolia off-PRD, mainnet+L2 on PRD; DFXswiss/api #3819, verified by a real Sepolia OCP payment). The client must no longer pre-decide that testnet is unsupported — that was an API-as-authority anti-pattern and is now wrong. Remove the environment capability gate end to end: - RealUnitPayService.isPaySupportedEnvironment getter, assertPaySupported(), and the per-call defensive guards on createPayUnsignedTransaction/submitPay - PayUnsupportedEnvironmentException (now unreachable; dropped from the exception-surface guard list) - PayQuoteUnsupportedEnvironment state + the up-front load() gate - PayProcessFailureReason.payUnsupportedEnvironment + the up-front start() gate - payFailureUnsupportedEnvironment i18n key (en + de) and the view branches The flow now always requests the real quote; a typed backend error surfaces through the existing failure states. Fund safety is untouched: start() still gates the debug wallet before any on-chain action, and the post-swap pay-only-retry path is unchanged. Replace the unsupported-environment fallback golden with a real OCP-quote golden built from the captured Sepolia run (CHF 2.00 -> 2.0 ZCHF). Drive the pay_quote cubit/widget/golden tests and the pay/unsigned-transaction service test with the real fixture values.
Rebase onto staging left two factories and several import lines concatenated without newlines/quotes. Restore valid DI registration for RealUnitPayService and a complete exception_surface_test import list.
Refresh the three dashboard goldens that include the action row so they show the three-button layout (buy/sell/pay). Baselines taken from the self-hosted VR runner (testImage).
…n validation, merchant/fees) - parse transferAmounts/requestedAmount `amount` as string-or-number, so a real payment-link response no longer throws on the string-serialized amount and the flow reaches the quote. - guard the irreversible REALU->ZCHF swap against double execution: in-flight flag on the ETH poll, isClosed checks after every await, single-shot confirm button. - harden status polling: unknown status is terminal, a poll-generation guard stops a stale tick from cancelling a newer timer, a max-attempts cap and a request timeout replace the unbounded poll. - validate the unsigned pay tx locally before signing via a new EIP-1559 decoder: reject on token/recipient/amount/chainId mismatch, bind chainId to local config and cap gas/fee fields. The swap leg is not yet validated (needs a backend metadata extension) and this is now stated plainly in the docs. - surface the merchant and the REALU amount / expected ZCHF / fees on the quote screen before the irreversible confirm. - validate the payment-link id prefix, add a retry action to the quote error view, and handle a denied camera permission.
Cover the previously-uncovered lines flagged by the coverage floor gate: the EIP-1559 decoder field-count guard, the pay-tx validation branches (non-zero value, malformed amount, invalid token address), the status-poll max-attempts and error paths, the ETH-poll transient-error path, the unsigned-tx-mismatch retry message, the quote-error retry button, and the camera errorBuilder non-permission branch.
…rict parsing) - decode the EIP-1559 pay tx with canonical RLP and reject non-empty access lists - compare settlement amounts via exact plain-decimal strings and reject NaN/Infinity/negative money - bound the ETH balance poll with a timeout and max attempts so the flow cannot wedge - parse the required payment DTO lists strictly instead of defaulting to empty - surface an unrecognized OCP status explicitly instead of silently mapping to unknown - style: derive the text style from the theme, drop unused ARB keys, fix import ordering
…il-close, poll flag) - reject non-canonical single-byte RLP integers (0x81 xx with xx < 0x80) in the EIP-1559 decoder - fail closed when a settlement amount is not exactly comparable instead of a rounding-prone double comparison - reset the ETH-poll in-flight flag on every abort path so a later poll can restart - log an unrecognized OCP status even for the empty-string case
The comment still described the removed double-> fallback; the code now fails closed (returns true -> retry) when an exact plain-decimal comparison is not possible.
878ee54 to
4b07824
Compare
Migrate _PayQuoteReadyView off Column+Spacer to ScrollableActionsLayout and wrap the amount rows in Flexible so the confirm CTA stays tappable at every device size and text scale (the BitBox-untappable-button bug class); add a responsive matrix test plus catalog entry and regenerate the affected pay-quote goldens.
Apply .timeout(_httpTimeout) to the five authenticatedPut calls (swap info, swap/pay unsigned tx, swap broadcast, pay submit), matching the existing GET calls, so a hung connection surfaces as a timeout instead of an indefinite spinner.
Fresh full review at the final commitRe-ran conformity + logic against the final state:
Analyze & Test, Visual Regression and Coverage green. |
## 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).
…rt (#875) ## Problem `mobile_scanner 5.2.3` depends on GoogleMLKit, whose frameworks ship **no arm64-iphonesimulator slice**. On GitHub's arm64 macOS runners the iOS 26 simulator is arm-only (Apple removed Rosetta for the iOS 26 simulator), so the app could neither build nor run there — breaking the `tier3-handbook` CI with `Framework 'Pods_Runner' not found`. This is what red-flags the staging→develop promote. > Note: `mobile_scanner` was reintroduced in #674 at `^5.2.3` in pubspec but without a matching `pod install` / `ios/Podfile.lock` commit, leaving the lockfile out of sync. This PR both bumps the version (5.2.3 → 7.4.0) and restores lockfile consistency. ## Fix Upgrade `mobile_scanner` to **7.4.0**, which drops GoogleMLKit for Apple's native **Vision** API → the app builds and installs/launches **natively on arm64** iOS 26.5 simulators. - Migrate the two `errorBuilder` call sites to 7.x's 2-arg signature (dropped the unused `Widget? child`). - `QrScannerView` now supplies a compact, textScale-safe **default** error placeholder (icon-only) so 7.x's taller default no longer overflows `send_recipient_page`'s bounded `Expanded` at large accessibility text sizes. - Add a no-op stub for mobile_scanner 7.x's new `deviceOrientation` event channel in the golden-test helper, plus a focused widget test asserting the default placeholder stays overflow-safe at `TextScaler.linear(3.0)`. ## Verification - `flutter build ios --simulator --debug` → succeeds; `simctl install` + `launch` on iPhone 17 / iOS 26.5 → succeeds (no arch error, app renders). - `flutter analyze` clean; `flutter test --exclude-tags golden` → all pass. - Golden baselines regenerated on the self-hosted runner (one changed: the send scanner error icon). - Tier 3 handbook flows (iOS build + 26 Maestro flows) and RealUnit Build (analyze/test, visual regression, coverage, BitBox) both green on the branch head. ## Note on the scanner backend The iOS scan backend changes GoogleMLKit → Apple Vision. The app only consumes `Barcode.rawValue`, so Dart-side handling is unchanged. QR-decode parity between the two backends is architecturally expected (both fully support the QR symbology) but has **not** been verified against a live camera — the scanner is `@no-integration-test` and cannot run in CI, so a device smoke-test (bare address, checksummed address, `ethereum:` URI, LNURL) is worth doing before release. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## 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 Open CryptoPay (OCP) 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 — all orchestrated through
api.dfx.swiss.Flow (Page + Cubit per step, separate state files):
mobile_scannerQR scan → decode thelightning=LNURL1…param (LUD-01 bech32) /app.dfx.swiss→api.dfx.swisshost fallback → extract thepl_…id.GET /v1/lnurlp/:idshows the requested CHF amount + the exact ZCHF needed (read from the APItransferAmountsEthereum/ZCHF entry; never computed locally). The mainnet-only environment gate is checked up-front here so the irreversible swap can never start where the pay leg cannot settle. Expired quote / no-ZCHF-method / unsupported-environment are typed states.PUT /swap(targetAmount = ZCHF + headroom buffer) →/swap/:id/unsigned-transaction→ sign →/swap/:id/broadcast→ re-fetch the OCP quote (freshquoteId, guards expiry between swap and pay) →/pay/unsigned-transaction→ sign →/pay/submit→ poll/pay/:id/statusuntil terminal.Signing uses the unified raw-payload path (
signToSignature→ r/s/v) for both software and BitBox wallets — the flow is not branched onwalletType; only the genuine non-signing capability gap (debug wallet) is gated and surfaced as a dedicated failure state. Typed failures are rendered as states — no error-string parsing drives control flow.Fund-safety semantics (two irreversible legs)
The REALU→ZCHF swap is irreversible, so the flow is hardened so the user can never be stranded and a failed pay never double-converts REALU:
ApiConfig.networkMode) and is now evaluated at the very start of both the quote and process steps. The swap is never signed/broadcast on an environment wherepay/*cannot settle. The service keepsassertPaySupported()on thepay/*calls as defense-in-depth.PayProcessPayRetrystate whose recovery (retryPay()) re-quotes + signs + submits without ever re-swapping. A failed pay no longer forces a re-scan → re-swap. Mirrors the sell flow's two-legSellBitboxDepositRetry.expiration.isBefore(now)is treated as expiry; transient fetch/submit/settlement errors route to the pay-only retry, not to a re-scan.PayRetryReason.insufficientZchfretry state is surfaced (re-quote may land within the held ZCHF; the leftover ZCHF stays in the wallet) instead of an opaque server-side failure.API / decision authority
Consumes DFXswiss/api#3819 (
feat/realunit-ocp-pay) — pair-PR, backend lands first. App renders API-signaled fields (isValid/error,requestedAmount,transferAmounts, quote expiration, payment status) and does not duplicate backend limit/eligibility logic.Mainnet-only limitation: the OCP payment-link engine settles on mainnet only; on
dev.api.dfx.swiss(Sepolia)pay/*fails fast. The client mirrors this as a typedPayUnsupportedEnvironmentExceptionkeyed offApiConfig.networkMode(a local environment capability gate, not error-string parsing), surfaced before the swap as a dedicated state.Parsing robustness
lnurlpDTO: optional transfer-assetamountis parsed as nullable (the non-priced display path emits amount-less entries), and the deadrecipientfield is removed (a backend object, never read, that threw aTypeErrorwhen populated).RealUnitSwapDto.fromAmountconstructor (and its coverage-ignore) is removed; the flow only usesfromTargetAmount.Tests
app→apidecode (unit), all pay DTOsfromJson/toJson(incl. nullable amount + object-recipient), the pay service (mockedhttpclient, incl.isPaySupportedEnvironment), every step Cubit (success + each typed failure,fake_asyncfor the ETH/status polling timers).retryPaynever re-swaps.exception_surface_test.dart; i18npayRetry*keys in both ARB files.Issue: #666