Skip to content

Promote: staging -> develop#871

Open
github-actions[bot] wants to merge 8 commits into
developfrom
staging
Open

Promote: staging -> develop#871
github-actions[bot] wants to merge 8 commits into
developfrom
staging

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Automatic Staging PR

This PR was automatically created after changes were pushed to staging.

Commits: 1 new commit(s)

Checklist

  • Review all changes
  • Verify CI passes
  • Approve and merge to promote into develop

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).
@TaprootFreak TaprootFreak added the tier3:full Opt-in: run Tier 3 Maestro handbook flows on this PR label Jul 22, 2026
TaprootFreak and others added 3 commits July 23, 2026 11:39
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

tier3:full Opt-in: run Tier 3 Maestro handbook flows on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant