feat: OCP pay-flow (RealU→ZCHF→Open CryptoPay)#674
Open
TaprootFreak wants to merge 7 commits into
Open
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.
This was referenced Jun 4, 2026
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.
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 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