Skip to content

feat: OCP pay-flow (RealU→ZCHF→Open CryptoPay)#674

Open
TaprootFreak wants to merge 7 commits into
stagingfrom
feature/ocp-pay-flow
Open

feat: OCP pay-flow (RealU→ZCHF→Open CryptoPay)#674
TaprootFreak wants to merge 7 commits into
stagingfrom
feature/ocp-pay-flow

Conversation

@TaprootFreak
Copy link
Copy Markdown
Contributor

@TaprootFreak TaprootFreak commented Jun 3, 2026

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):

  1. Scanmobile_scanner QR scan → decode the lightning=LNURL1… param (LUD-01 bech32) / app.dfx.swissapi.dfx.swiss host fallback → extract the pl_… id.
  2. QuoteGET /v1/lnurlp/:id shows the requested CHF amount + the exact ZCHF needed (read from the API transferAmounts Ethereum/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.
  3. Process (on confirm) — assert the environment can settle (before any on-chain action) → check ETH gas (faucet + poll) → PUT /swap (targetAmount = ZCHF + headroom buffer) → /swap/:id/unsigned-transaction → sign → /swap/:id/broadcastre-fetch the OCP quote (fresh quoteId, guards expiry between swap and pay) → /pay/unsigned-transaction → sign → /pay/submit → poll /pay/:id/status until terminal.

Signing uses the unified raw-payload path (signToSignature → r/s/v) for both software and BitBox wallets — the flow is not branched on walletType; 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:

  • Environment gate before the swap. The mainnet-only capability is environment-static (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 where pay/* cannot settle. The service keeps assertPaySupported() on the pay/* calls as defense-in-depth.
  • Pay-only retry after a successful swap. Once the swap is broadcast the cubit records that ZCHF was acquired; any subsequent pay-leg failure surfaces the PayProcessPayRetry state 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-leg SellBitboxDepositRetry.
  • Genuine expiry vs. transient errors are distinct. Only an explicit expiration.isBefore(now) is treated as expiry; transient fetch/submit/settlement errors route to the pay-only retry, not to a re-scan.
  • Slippage boundary. The swap target uses a documented 3% headroom (was 1%). If the freshly re-fetched settlement amount still exceeds the acquired ZCHF, a typed PayRetryReason.insufficientZchf retry 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 typed PayUnsupportedEnvironmentException keyed off ApiConfig.networkMode (a local environment capability gate, not error-string parsing), surfaced before the swap as a dedicated state.

Parsing robustness

  • lnurlp DTO: optional transfer-asset amount is parsed as nullable (the non-priced display path emits amount-less entries), and the dead recipient field is removed (a backend object, never read, that threw a TypeError when populated).
  • The dead RealUnitSwapDto.fromAmount constructor (and its coverage-ignore) is removed; the flow only uses fromTargetAmount.

Tests

  • LNURL bech32 + appapi decode (unit), all pay DTOs fromJson/toJson (incl. nullable amount + object-recipient), the pay service (mocked http client, incl. isPaySupportedEnvironment), every step Cubit (success + each typed failure, fake_async for the ETH/status polling timers).
  • New fund-safety cases: env-unsupported fails before any swap, pay-only retry after a successful swap, transient-fetch error → retry (not re-scan), insufficient-ZCHF-after-swap typed state, and retryPay never re-swaps.
  • Typed exceptions enumerated in exception_surface_test.dart; i18n payRetry* keys in both ARB files.
  • Dashboard golden (third Pay action button) unchanged and green.

Issue: #666

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.
@TaprootFreak TaprootFreak marked this pull request as ready for review June 3, 2026 21:55
@TaprootFreak TaprootFreak marked this pull request as draft June 4, 2026 07:39
TaprootFreak and others added 2 commits June 4, 2026 09:57
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.
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