Promote: staging -> develop#871
Open
github-actions[bot] wants to merge 8 commits into
Open
Conversation
Two small product-copy/display bugs surfaced during the support & PIN golden audit (noted against #816). Two atomic commits. ## 1. `fix(pin)` — dangling 'Forgot PIN?' reference in gate-flow lockout copy The permanent-lock message `pinVerifyLocked` ("…use 'Forgot PIN?' to reset") points at a button that **only exists in the app-lock entry point** (`VerifyPinPage.appLock`, `bottom != null`). In feature-gate flows the button isn't rendered, so the copy referenced nothing — the exact trap already solved for `VerifyPinUnverifiable`. Fix: branch `VerifyPinLocked`'s copy on `widget.bottom`, mirroring `Unverifiable`: - gate flow → new **`pinVerifyLockedGate`**: "…Lock the app and reset the wallet from the lock screen." - app-lock flow → unchanged button-referencing text. The existing `verify_pin_page_locked` golden was the gate variant and **showed the bug** — it's regenerated with the corrected copy, and a new `verify_pin_page_locked_app_lock` golden covers the button-present branch (mirroring the `unverifiable` / `unverifiable_app_lock` pair). Page-test updated + app-lock case added. ## 2. `fix(support)` — ticket list date shown in UTC `ticket.created` is parsed as UTC (`DateTime.parse` of the API's ISO-8601), so the list rendered the UTC calendar date — wrong across midnight for non-UTC users. Converted with `.toLocal()` before formatting (same fix as the chat-bubble timestamp in #820). New regression test fails on the raw-UTC path. The `support_tickets_page_loaded` golden is **unchanged** (its midnight-UTC fixture stays on the same day in the runner timezone — verified). ## Verification (m5me, Flutter 3.41.6, CI-identical) `flutter analyze` clean; `verify_pin_page_test` + `support_tickets_page_test` pass; goldens double-run deterministic; only the two intended pin baselines changed, no other golden drifted.
Follow-up to the ticket-date fix (#870): the same UTC-display bug class in the transaction rows. ## The bug The DFX history API returns transaction timestamps as UTC (`Z`-suffixed → parsed to a UTC `DateTime`). The dashboard and history rows formatted `transaction.timestamp` directly, so they showed **UTC** — off by the device's offset (e.g. +02:00), and the date could be wrong across midnight. ## The fix `.toLocal()` at the display sites — `transaction_row.dart` (×2), `transaction_history_row.dart`, and `pending_transaction_row.dart` — so every transaction date/time renders in the device's local time. Consistent with the chat-bubble and ticket-date fixes. Five goldens shifted from UTC to local and were regenerated (dashboard recent/hidden-amounts, history list + two receipt-row states); verified they show the +02:00-converted times, consistent across dashboard and history. The pending-row golden is unchanged (its fixture doesn't cross midnight). ## Not changed (learned during review) - The **date-range filter** was left as-is: `DateTime.isBefore`/`isAfter` compare the absolute instant regardless of the UTC/local flag, so a `.toLocal()` there is a no-op and changes no filtering. (A separate, pre-existing local-date-boundary question is out of scope.) - The stale determinism comment in `transaction_history_states_golden_test.dart` was updated — it wrongly claimed the row was timezone-independent. ## Verification (self-hosted-equivalent runner, Flutter 3.41.6, TZ +0200) `flutter analyze` clean; dashboard + transaction_history tests pass (incl. the unchanged filter cubit); goldens double-run deterministic; only the five transaction-showing goldens changed.
…cher.onError (#867) ## What `FlutterError.onError` only sees errors raised inside a Flutter callback — build, layout, paint. Unhandled errors from a `Future`, `Stream` or `Timer` callback in the root isolate reach **no handler at all** today: `lib/main.dart` installs `FlutterError.onError` and `ErrorWidget.builder`, and nothing else. There is no `PlatformDispatcher.onError` and no `runZonedGuarded`. This installs `PlatformDispatcher.onError` alongside the existing handlers and extracts the whole installation out of `main.dart` into `lib/setup/error_handling/error_handlers.dart`, next to the `RealUnitErrorView` it already uses — which is what makes the contract testable at all. ## Scope — please read before assuming this fixes field diagnostics This does **not** deliver release-mode evidence, and the PR should not be merged under that belief. `developer.log` writes to the VM service stream, so these calls surface in the DevTools Logging view under the `WalletApp` tag in **debug and profile builds only** — the VM service is absent from a release build. What this PR actually delivers: - async errors become **reachable where a developer is already attached**, where today they reach nothing; - the handler returns **`false`**, so the engine's own fallback reporting keeps running in release instead of being suppressed. Returning `true` would claim the error as handled while our log is a no-op in release — reported by nobody at all; - the handler body is now **the hook a real sink plugs into**. Getting evidence off a customer's device still needs a crash reporter or a persisted log sink. That is a separate change and the one that would actually close a silent-field-crash report. ## Why `false` and not `true` This is the one design decision in the diff, so it is pinned by an assertion in the test rather than left to prose. `true` = "handled, stop reporting"; `false` = "log it, then let the engine report as it always did". Since the log is compiled out of release, `true` would convert a reported crash into total silence — the opposite of the intent. ## Test `test/setup/error_handling/error_handlers_test.dart` — 4 cases: the async handler is installed and returns `false`; it tolerates an empty stack trace; the Flutter handler still delegates to the previously installed one (the default reporting path stays intact); the on-brand error widget builder is installed. Process-wide statics are captured in `setUp` and restored in `tearDown` so nothing leaks into other suites. `// @no-integration-test:` annotation added per CONTRIBUTING:205 — the engine-side fallback reporting that runs after the handler returns `false` is embedder behaviour and is not observable from a Dart test. ## Verification Draft PRs skip CI in this repo, so both runs are local and this is the only gate: - `flutter analyze` → 1 issue, pre-existing, in generated/gitignored code (`lib/generated/i18n.dart:55` `override_on_non_overriding_member`). - `flutter test` → `+3019 -4`. The 4 failures are **pre-existing golden failures** in `settings_user_data`, baseline-proven at `+3015 -4` without this change. Isolated: `flutter test test/setup/error_handling/` → `+6: All tests passed!` ## Context Came out of a customer case where an Android BitBox setup failed with a repeated crash and we had **zero** diagnostic evidence to work from. The transport half of that case is #866. The missing crash reporting is the other half — this PR is the groundwork for it, not the fix.
## 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. **Scan** — `mobile_scanner` QR scan → decode the `lightning=LNURL1…` param (LUD-01 bech32) / `app.dfx.swiss`→`api.dfx.swiss` host fallback → extract the `pl_…` id. 2. **Quote** — `GET /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/broadcast` → **re-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 + `app`→`api` 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
## 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>
## What Adds a payment-deeplink entry point so an incoming `realunit-wallet:lightning:<LNURL>` URL is routed straight into the existing OpenCryptoPay settlement flow (`PayScanCubit.onCodeDetected` → `LnurlDecoder` → REALU→ZCHF swap → ZCHF transfer). Until now that flow was reachable **only** by manually scanning a QR code from the dashboard "Pay" button; the `realunit-wallet://` scheme only foregrounded the app with no payload. This wires the missing external entry point. ## Why So DFX payment-link pages (e.g. `/pl`) can list RealUnit as a wallet and open it directly with a pre-filled payment. Companion to the backend listing in **DFXswiss/api#4339** (adds the `wallet_app` row with `deepLink='realunit-wallet:'`, from which the frontend builds `realunit-wallet:lightning:<LNURL>`). ## How - `extractPaymentDeeplinkPayload` (string-level, so it handles both the opaque `realunit-wallet:lightning:<LNURL>` and a `//`-normalized variant) strips the scheme and feeds `lightning:<LNURL>` (or a bare `LNURL…`/https lnurlp URL) verbatim into `onCodeDetected`. No pre-validation — a malformed payload is left for `LnurlDecoder` to reject (no silent fallback). - **Warm resume:** the redirect itself still returns `null` (a true no-op — match list / back-stack / `extra` untouched); the actual navigation is a deferred imperative `pushNamed('/pay')`, so it lands on top without clobbering a pushed route. - **Cold start:** the payload is stashed and replayed as a `/pay` push **only** once the boot/unlock ladder lands on the dashboard — i.e. strictly after the same PIN/biometric gate a normal `/pay` navigation passes. It can never bypass the lock. - `/pay` accepts an optional `initialPayload` via a **guarded** `state.extra` cast (never an unchecked cast); when present it skips the live camera and feeds the cubit once. - Preserves the documented `app_link_entry.dart` hardening for all non-payment scheme URLs. **No** iOS/Android manifest or entitlement changes — the `realunit-wallet` scheme is already registered; no new deeplink package. ## Tests 16 new widget/unit tests: payload-extraction forms (single-colon, `//`, bare LNURL, https lnurlp, canonical open→null, path-carrying→null, non-scheme→null); warm-resume push without back-stack clobber; canonical open never pushes `/pay`; cold-start stash + replay-only-after-dashboard; guarded non-String extra → null; `initialPayload` feeds the cubit once and skips the camera. `flutter analyze` clean; full suite green. ## Notes - **Staging-only feature.** The OpenCryptoPay pay flow (`LnurlDecoder`/`PayScanCubit`/`/pay`) currently lives only on `staging`; this builds on it. - **Device smoke test recommended before release:** OS-level custom-scheme delivery + a real LNURL round-trip are `@no-integration-test` (cannot run in CI). - The two-line diff in `assets/languages/strings_{de,en}.arb` is an incidental normalization produced by the mandatory `generate_localization.dart` codegen step (it split two entries that were on one line); no localization key or value was changed.
## Summary - inject the repository `SENTRY_DSN` into Android and iOS store builds with an explicit `production`/`internal` environment - keep compile-time values out of Fastlane process arguments via a mode-0600 temporary define file and fail release builds when the DSN is missing - upload Android split debug info, Dart obfuscation maps, and iOS archive symbols to the self-hosted Sentry project - identify uploads with the native release name `swiss.realunit.app@<version>+<build>` and matching distribution The Sentry project `realunitchapp` and the repository secrets `SENTRY_DSN` and `SENTRY_AUTH_TOKEN` have been provisioned. Runtime reporting activates together with #878. Related to DFXServer/server#958. ## Validation - Ruby syntax checks for both Fastfiles - workflow YAML parse and `git diff --check` - `flutter analyze --no-fatal-infos` - non-golden Flutter suite: 4,687 tests passed; the 16 subprocess cases initially failed only because local `dart` was absent from PATH and all 16 passed when rerun with the Flutter SDK path - DFX PR precheck: 3 rounds, final conformity and logic reviews both at 0 findings ## Follow-up verification The first tagged native release should confirm the uploaded debug files in Sentry and symbolicate one controlled test event. --------- Co-authored-by: Daniel Padrino <danswarrior1@gmail.com>
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.
Automatic Staging PR
This PR was automatically created after changes were pushed to staging.
Commits: 1 new commit(s)
Checklist