Skip to content

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

Merged
TaprootFreak merged 17 commits into
stagingfrom
feature/ocp-pay-flow
Jul 22, 2026
Merged

feat: OCP pay-flow (RealU→ZCHF→Open CryptoPay)#674
TaprootFreak merged 17 commits into
stagingfrom
feature/ocp-pay-flow

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

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

@TaprootFreak

Copy link
Copy Markdown
Contributor Author

Hardening pass — complete, CI green

Addressed the review findings on the OCP pay client. Verified on a macOS build host (codegen chain + flutter analyze clean + full flutter test green + 100% scoped coverage + goldens) and CI is green (Analyze & Test, Coverage Floor Gate, Visual Regression, BitBox); commits are signed.

Blockers

  • String/number parse: transferAmounts / requestedAmount amount is parsed as string-or-number, so a real payment-link response no longer throws on the string-serialized amount and the flow reaches the quote.
  • Endless status poll: paired with the backend fix, the status DTO's unknown is now terminal, a poll-generation guard stops a stale tick from cancelling a newer timer, and a max-attempts cap + request timeout replace the unbounded poll.
  • Blind signing: the unsigned pay tx is decoded (new EIP-1559 decoder) and validated fail-closed against the expected token / recipient / amount / chain (chain bound to local config; gas & fee fields capped) before signing.
  • Double swap: in-flight guard on the ETH poll, isClosed checks after every await, single-shot confirm button.

Major

  • Merchant name and the REALU amount / expected ZCHF / fees are now shown before the irreversible confirm; payment-link id prefix validation; retry action on the quote error view; camera-permission error handling.

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.

@TaprootFreak
TaprootFreak marked this pull request as draft July 18, 2026 15:19
@TaprootFreak

Copy link
Copy Markdown
Contributor Author

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: flutter analyze → no issues; flutter test --coverage --exclude-tags golden → all pass; 100% coverage on the changed cubit/package lines.

Fixed in this round:

  • Decoding: canonical RLP in the EIP-1559 decoder — empty access list required, non-canonical single-byte integers rejected.
  • Money: exact plain-decimal settlement comparison; NaN/Infinity/negative rejected; fail-closed (retry) when an exact comparison isn't possible.
  • Robustness: ETH-balance poll bounded by timeout + max attempts (no wedge); required payment-DTO lists parsed strictly (no silent empty default); an unrecognized OCP status is logged instead of silently mapped.
  • Conventions: theme-derived text style, unused ARB keys removed, import ordering.

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 staging, so the pull_request CI can't run — it needs a rebase onto current staging (which cascades to the stacked #687) before CI runs and it can be marked ready.

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.
@TaprootFreak
TaprootFreak force-pushed the feature/ocp-pay-flow branch from 878ee54 to 4b07824 Compare July 20, 2026 11:52
@TaprootFreak
TaprootFreak marked this pull request as ready for review July 20, 2026 12:03
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.
@TaprootFreak

Copy link
Copy Markdown
Contributor Author

Fresh full review at the final commit

Re-ran conformity + logic against the final state:

  • Conformity (blocking, fixed): pay_quote_page used the forbidden Column + Spacer() sticky-CTA pattern (the BitBox-untappable-button bug class). Migrated _PayQuoteReadyView to ScrollableActionsLayout and wrapped the amount rows in Flexible — the new matrix test caught a real RenderFlex overflow at large text scale — added a responsive matrix test + catalog entry, and regenerated the affected pay-quote goldens.
  • Logic: added request timeouts to the five pay/swap mutation calls (previously only the GET calls had them, so a hung connection could leave the UI spinning indefinitely). The pay-leg blind-sign validation (token / recipient / amount / chainId + gas caps + RLP fail-closed) is sound.
  • Follow-up: the swap leg is still blind-signed because the backend does not yet return swap metadata to validate against (#4274).

Analyze & Test, Visual Regression and Coverage green.

@TaprootFreak
TaprootFreak merged commit c454897 into staging Jul 22, 2026
7 checks passed
@TaprootFreak
TaprootFreak deleted the feature/ocp-pay-flow branch July 22, 2026 21:00
TaprootFreak added a commit that referenced this pull request Jul 22, 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 added a commit that referenced this pull request Jul 23, 2026
…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>
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