Skip to content

Release: develop -> main#325

Open
github-actions[bot] wants to merge 269 commits into
mainfrom
develop
Open

Release: develop -> main#325
github-actions[bot] wants to merge 269 commits into
mainfrom
develop

Conversation

@github-actions
Copy link
Copy Markdown
Contributor

Automatic Release PR

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

Commits: 1 new commit(s)

Checklist

  • Review all changes
  • Verify CI passes
  • Approve and merge when ready for production

TaprootFreak and others added 30 commits May 15, 2026 13:48
)

## Summary
Stage 19 of the coverage push. Closes the previously-deferred
apiBasedSync gap on TransactionHistoryService — kept in a separate file
from \`fetchPendingTransactions\` (#336) to keep the repository mocks
focused on the sync flow.

| Method under test | Test file | Cases |
| --- | --- | --- |
| \`TransactionHistoryService.apiBasedSync\` |
\`test/packages/service/transaction_history_service_sync_test.dart\` | 8
|

## What it covers
- Non-200 on \`/v1/realunit/account/<addr>/history\` short-circuits: no
repository writes (the cubit's early return when AccountHistoryDto is
null).
- Entries whose \`transfer\` field is null are skipped.
- Inserts a plain \`Transaction\` when there is no matching DFX row.
- Inserts a DFX-enriched transaction (\`insertDfxTransaction\`) when
\`/v1/transaction\` has a row whose \`id\` is not null and whose
\`inputTxId\` matches the history hash.
- Existing transactions go through \`updateTransaction\` /
\`updateDfxTransaction\` instead of insert (governed by
\`existsTransaction\`).
- The DFX-row match also accepts \`outputTxId\` — both directions are
pinned.
- DFX rows whose \`id\` is null fall back to the plain-Transaction
insert path.

## Notes
- Both endpoints are fetched via \`Future.wait\`. Reading
\`request.url.path\` lets a single MockClient handler return different
bodies for the two paths.
- The test stubs \`existsTransaction\` to return false by default, then
flips it to true for the update-path tests.

## Test plan
- [x] \`flutter analyze\` on the new file — clean
- [x] \`flutter test\` — 8 / 8 passing locally
- [ ] CI green
## Summary
- always show dashboard buy/sell actions and the empty-state Buy
RealUnit button
- make Buy/Sell/BankAccount/Sell brokerbot requests auth-aware by using
lazy DFX auth and a one-time 401 refresh retry
- remove the now-dead HomeState.isFiatServiceAvailable setup path

## Why
The dashboard must not hide fiat entry points behind a transient
in-memory auth token. After making the entry points visible, the
downstream services also need to avoid sending Authorization: Bearer
null or stale tokens. Authenticated requests now call getAuthToken() at
first use and retry once with refreshAuthToken() on 401.

## Tests
- flutter analyze
- flutter test test/packages/service/dfx/dfx_auth_service_test.dart
test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart
test/screens/buy/buy_page_test.dart
test/screens/sell/sell_page_test.dart

---------

Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
## Summary
Stage 21 of the coverage push. Closes the \`dfx_kyc_service\` surface
added in #343.

| Method | Cases |
| --- | --- |
| \`continueKyc\` | 2 |
| \`startStep\` | 2 |
| \`setData\` | 2 |
| \`getFinancialData\` | 3 |
| \`setFinancialData\` | 2 |

## What each method pins
- **continueKyc:** PUT \`/v2/kyc\` with the \`x-kyc-code\` header from
\`getUser().kyc.hash\`; parses \`KycSessionDto\`; \`ApiException\` on
non-2xx.
- **startStep:** GET \`/v2/kyc/<stepName.value>\` (the path encodes the
step enum's wire string); parses \`KycSessionDto\`; \`ApiException\` on
non-2xx.
- **setData:** PUTs the caller-provided session URL (NOT the host) with
the JSON body + \`x-kyc-code\` header; \`ApiException\` on non-2xx.
- **getFinancialData:** GET \`<url>?lang=<lang.code>\`; defaults the
language to \`Language.de\` (\`'de'\`); \`x-kyc-code\` header;
\`ApiException\` on non-2xx.
- **setFinancialData:** PUTs \`{ "responses": [...] }\` to the
caller-provided URL with \`x-kyc-code\` header; serialises each
\`KycFinancialResponse\` to \`{ key, value }\`; \`ApiException\` on
non-2xx.

## Conflict avoidance
Same as #343 — \`dfx_kyc_service.dart\` is NOT in PR #332's diff. The
service surface is stable on that branch.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 11 / 11 passing locally
- [ ] CI green
## Summary
Stage 20 of the coverage push. Covers four core methods of the
previously-deferred \`dfx_kyc_service\`.

| Method | Cases |
| --- | --- |
| \`getUser\` | 3 |
| \`updateUser\` | 3 |
| \`request2FaCode\` | 2 |
| \`verify2FaCode\` | 2 |

## What each method's tests pin
- **getUser:** GET \`/v2/user\` with Bearer JWT + parses \`UserDto\`;
201 also accepted (in addition to 200); throws \`ApiException\` on a
non-2xx non-401 (the 401-refresh-and-retry path is large enough to
deserve its own test, deferred).
- **updateUser:** PUT \`/v2/user\` with the JSON body; 201 accepted;
\`ApiException\` on non-2xx.
- **request2FaCode:** POST \`/v2/kyc/2fa?level=Strict\` with
\`x-kyc-code\` header set from \`getUser().kyc.hash\` (pins the kyc-hash
propagation); \`ApiException\` on non-2xx.
- **verify2FaCode:** POST \`/v2/kyc/2fa/verify\` with \`{ token: code
}\` body and the same \`x-kyc-code\` header; \`ApiException\` on
non-2xx.

## Conflict avoidance
PR #332 (bitbox sign + KYC routing) touches
\`lib/screens/kyc/cubits/kyc/kyc_cubit.dart\` and
\`real_unit_registration_service.dart\` — NOT this service.
\`dfx_kyc_service.dart\` itself is unchanged on that branch, so this PR
is conflict-free.

## Notes
- Tests use a tiny \`_StubWallet\` so \`DFXAuthService.wallet\` resolves
without plumbing a real \`SoftwareWallet\` through.
- The wire format for \`kyc.level\` is the numeric value (e.g. \`20\` →
\`KycLevel.level20\`), pinned in the test fixture so a future enum / DTO
refactor surfaces immediately.

## Excluded (deferred)
- \`getUser\`'s 401 → \`refreshAuthToken\` → retry path needs a stateful
MockClient + the auth-service signing flow; deserves its own focused
test.
- \`continueKyc\`, \`startStep\`, \`setData\`, \`getFinancialData\`,
\`setFinancialData\` — more elaborate bodies + headers; will follow up
if/when needed.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 10 / 10 passing locally
- [ ] CI green
Closes Acceptance Criterion #6 of #314 — _\"Documentation:
\`docs/testing.md\` explains when to use which tier, with examples.\"_

## Summary

A single page that captures everything we've learned across Phase 0
(#319) and Phase 1 Foundation (#320 / #321) about how to test
BitBox-touching code without re-deriving the rules every PR:

- **Tier matrix** (0–4) with hardware / CI status for each, so reviewers
can tell at a glance what a test does and doesn't prove
- **Decision tree** for picking a tier — \"if a Tier 0 test would have
to mock the very thing under test, drop down a tier\"
- **Tier 0 patterns** with concrete excerpts from real test files:
- Cubit / Bloc tests (\`bloc_test\` + \`mocktail.Mock\`) pointing at
\`test/screens/kyc/cubits/kyc/kyc_cubit_test.dart\`
- Widget tests (\`pumpApp\` + \`MockCubit\`) pointing at
\`test/screens/kyc/steps/kyc_email_page_test.dart\`
- Service + HTTP tests (\`http/testing\` \`MockClient\` +
\`_MockAppStore\`) pointing at
\`test/packages/service/dfx/dfx_bank_account_service_test.dart\`,
including the **\`setAuthToken('test-jwt')\` pre-seed** trick for
DFXAuthService-derived services (the gotcha that broke CI on #321)
- **Tier 1 patterns** for \`FakeBitboxCredentials\` with the
\`FakeBitboxBehavior\` matrix and the disconnect-flip-to-success
reconnect pattern, pointing at
\`test/integration/kyc_sign_flow_test.dart\`
- **Tiers 2–4** marked deferred with status pointers back to the
corresponding phase of #314
- **Mocktail gotchas** (\`Future\` + \`thenAnswer\`,
\`registerFallbackValue\`, private mocks)
- **Add-tests checklist** for PRs touching \`KycCubit\` /
\`Eip712Signer\` / \`DFXAuthService\` / \`BitboxCredentials\` /
\`bitbox_flutter\`

CONTRIBUTING.md's Testing section gets a one-line cross-link.

## Test plan

- [x] Doc compiles / renders (no broken intra-repo links in the
markdown)
- [x] Every code example matches a real file already on \`develop\`
- [x] \`flutter analyze\` — no new issues (4 pre-existing errors in
\`test/screens/home/home_bloc_test.dart\` are unrelated to this PR;
verified they exist on \`develop\` too)
…346)

## Summary
Stage 23 of the coverage push. Three small pure-Dart targets that round
out the config + widget-extension surface.

| File | Cases (new) |
| --- | --- |
| \`lib/packages/config/network_mode.dart\` | 3 (new file) |
| \`lib/packages/config/api_config.dart\` | 6 (appended to existing) |
| \`lib/widgets/mnemonic_field.dart\` (\`SeedStringExtension\`) | 5 (new
file) |

## What each file covers
- **network_mode:** mainnet / testnet getters + name;
\`NetworkMode.values\` has exactly 2 entries (a third would silently
bypass every \`switch (mode)\` call site).
- **api_config (extension):** testnet \`asset\` = \`realUnitTestAsset\`,
testnet ids = Sepolia; mainnet \`asset\` = \`realUnitAsset\`, mainnet
ids = Ethereum; \`buildUri\` produces https URIs; appends queryParams
when provided; omits the query string when null.
- **mnemonic_field SeedStringExtension:** splits a 12-word mnemonic
correctly; collapses tabs and multi-space runs; empty / whitespace-only
input → \`[]\`; trims leading/trailing whitespace; preserves word order.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 17 / 17 passing locally (incl. the 2
pre-existing api_config cases)
- [ ] CI green
)

## Summary
Stage 22 of the coverage push. Pure-Dart utility files.

| File | Cases |
| --- | --- |
| \`lib/packages/utils/svg_parser.dart\` | 5 |
| \`lib/packages/utils/device_info.dart\` | 5 |
| \`lib/packages/utils/default_assets.dart\` | 4 |

## What each file covers
- **svg_parser:** integer + decimal mm → px conversion at 96 DPI (≈
3.7795 px/mm), non-mm units pass through unchanged, every \`mm\`
occurrence is replaced (multiple per string), surrounding SVG markup is
preserved.
- **device_info:** iOS / Android / macOS / Windows+Linux / Fuchsia
matrix using \`debugDefaultTargetPlatformOverride\` (save + restore
around each test so a failure doesn't leak the override).
- **default_assets:** mainnet \`realUnitAsset\` and Sepolia
\`realUnitTestAsset\` pinned to their production / dev contract
addresses + decimals + symbols (a typo here would silently route the
entire fiat pipeline at the wrong contract). ETH and ZCHF asset-id
constants also pinned.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 14 / 14 passing locally
- [ ] CI green
## Summary
Stage 24 of the coverage push. The \`SettingsRepository\` wraps
SharedPreferences and is one of the few storage-adjacent files that can
be tested directly via \`SharedPreferences.setMockInitialValues\`.

| Group | Cases |
| --- | --- |
| currentWalletId | 3 |
| language | 3 |
| currency | 3 |
| terms (terms + softwareTerms) | 3 |
| networkMode | 4 |

## What each group covers
- **currentWalletId:** null when not stored; \`saveCurrentWalletId\`
persists; \`removeCurrentWalletId\` clears.
- **language:** falls back to \`'en'\` for non-German system locales
(PlatformDispatcher locale in tests is en_US); returns stored value when
set; setter persists.
- **currency:** defaults to \`'CHF'\` when not stored; returns stored
value; setter persists.
- **terms:** both \`termsAccepted\` and \`softwareTermsAccepted\`
default to false; setters persist; setting one does not flip the other
(independent flags).
- **networkMode:** defaults to mainnet when no value AND when stored
value is unknown (\`firstWhere\`'s \`orElse\`); reads via the **enum
constructor argument name** (\`'Mainnet'\` / \`'Testnet'\`) — not the
Dart enum identifier (this trap surfaced during the tests because the
production code uses \`.name\` and the constructor arg shadows it);
setter persists.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 16 / 16 passing locally
- [ ] CI green
## Summary
Stage 25 of the coverage push. Three small pure-Dart targets.

| File | Cases |
| --- | --- |
| \`lib/packages/utils/asset_logo.dart\` | 7 |
| \`lib/models/blockchain.dart\` | 8 |
| \`lib/packages/utils/xfile_extension.dart\` | 7 |

## What each file pins
- **asset_logo:** mainnet ETH (\`chainId:1 + 0x0\`) → \`ETH.png\`;
mainnet RealUnit + Sepolia RealUnit → \`REALU.png\`; mixed-case address
still resolves REALU (production lowercases first); unknown
chain/address falls back to \`REALU.png\` (pinned — changing this would
silently render every unknown asset as ETH instead).
\`getChainImagePath\` for mainnet + Sepolia.
- **Blockchain:** \`Blockchain.values\` has exactly 2 entries; chainId /
name / nativeSymbol for both; \`getFromChainId\` resolves the known ids
and throws \`StateError\` on unknown; \`nativeAsset\` shape (address
\`0x0\`, decimals 18).
- **xfile_extension:** \`toBase64DataUri\` uses an explicit \`mimeType\`
when present; falls back to extension-based guessing for \`.png\` /
\`.jpg\` / \`.JPEG\` (case-insensitive) / \`.pdf\`; defaults to
\`application/octet-stream\` for unknown extensions; the base64 segment
encodes the raw file bytes (verified by round-tripping a UTF-8 payload).

## Notes
- \`xfile_extension\` tests write a real \`XFile\` against a per-test
temp directory so the production code's \`readAsBytes\` path runs
unchanged. No platform-channel plumbing needed.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 22 / 22 passing locally
- [ ] CI green
## Summary
Stage 26 of the coverage push. Pure-Dart model classes.

| Model | Cases |
| --- | --- |
| \`Asset\` | 5 |
| \`Balance\` | 5 |
| \`Transaction\` | 3 |
| \`TransactionTypes\` enum | 1 |

## What each model pins
- **Asset:** \`id\` derived from \`fastHash(chainId:address)\`;
\`Asset.getId\` static matches the instance getter; different chains /
addresses produce different ids; \`id\` depends only on chain+address,
not on metadata (\`name\` / \`symbol\` / \`decimals\` don't affect
identity — important for repo de-duplication).
- **Balance:** \`id\` from \`fastHash(wallet:chain:contract)\`;
\`balance\` amount is mutable; two Balances with the same identity tuple
are \`==\` regardless of amount (pinned so a stream-emitted update with
a new amount can still match an existing map key); different chain /
wallet differs.
- **Transaction:** \`isOutbound\` true when sender matches wallet
(EIP-55 normalised); false when sender differs; normalises hex-digit
case via \`fromHex\` / \`hexEip55\` on both sides (NOT case-insensitive
on the \`0x\` prefix — that breaks fromHex; the test pins the actual
contract).
- **TransactionTypes:** values has exactly 5 entries (catches an
accidental addition that would silently break the rendering switch).

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 14 / 14 passing locally
- [ ] CI green
## Summary
Stage 27 of the coverage push. Wire-format DTOs that were missing direct
tests — important to pin because they encode the contract with the DFX
backend.

| DTO | Cases |
| --- | --- |
| \`PriceStep.fromJson\` | 2 |
| \`BroadcastTransactionRequestDto.toJson\` | 1 |
| \`BroadcastTransactionResponseDto.fromJson\` | 1 |
| \`RealUnitUnsignedTransactionsRequestDto.fromJson\` | 1 |
| \`RealUnitSellConfirmDto.toJson\` | 4 |
| \`Eip7702DelegationDto.toJson\` | 1 |

## What's pinned
- **PriceStep:** full wire shape (\`source\` / \`from\` / \`to\` /
\`price\` / \`timestamp\`); integer \`price\` widens to double (matches
the \`num\` cast in production).
- **Broadcast req/resp:** request serialises the four signature parts
(\`unsignedTx\`, \`r\`, \`s\`, \`v\`); response extracts \`txHash\`.
- **Unsigned-transactions request:** parses \`swap\` + \`deposit\`.
- **SellConfirmDto:** the conditional-key serialisation (4 branches —
txHash-only, eip7702-only, both, neither/empty). This is load-bearing on
the wire and a regression here would silently send empty confirms.
- **Eip7702DelegationDto:** round-trips all five fields.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 10 / 10 passing locally
- [ ] CI green
)

## Summary
Stage 28 of the coverage push.

| File | Cases |
| --- | --- |
| \`lib/styles/currency.dart\` | 5 |
| \`lib/styles/language.dart\` | 5 |
| \`lib/models/dfx_transaction.dart\` | 3 |

## What's pinned
- **Currency:** wire codes (\`EUR\` / \`CHF\`); enum has exactly 2
values; \`fromCode\` resolves canonical inputs; **case-insensitive** on
input (production calls \`toUpperCase\` first); throws \`StateError\` on
unknown.
- **Language:** wire codes (\`en\` / \`de\`) and flag asset paths; enum
has exactly 2 values; \`fromCode\` resolves canonical;
**case-SENSITIVE** on input (the lookup does NOT lowercase — pinned
because it's different from Currency); throws \`StateError\` on unknown.
- **DfxTransaction:** is-a \`Transaction\` with inherited fields;
carries \`dfxId\` + \`rate\` + \`inputTxId\` + \`outputTxId\`; rate /
inputTxId / outputTxId are all optional (\`isNull\` check pins the
constructor's default-null contract).

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 13 / 13 passing locally
- [ ] CI green
## Summary
Stage 29 of the coverage push. Pure-Dart static methods on
\`SecureStorage\` — pinning the on-disk crypto contract for PIN-hashing
and seed-encryption.

| Helper | Cases |
| --- | --- |
| \`generatePinSalt\` | 3 |
| \`hashPin\` | 5 |
| \`encryptSeed / decryptSeed\` round-trip | 6 |

## What's pinned
- **generatePinSalt:** returns a 16-byte \`Uint8List\`; consecutive
calls return distinct salts (CSPRNG); every byte falls in 0..255.
- **hashPin:** returns a 64-char lowercase-hex string (32 bytes);
deterministic for the same \`(pin, salt, iterations)\` triple; sensitive
to each axis individually — including the iteration-count variation that
exists for the **legacy-migration path** (10k → 600k).
- **encryptSeed / decryptSeed:** round-trips a typical 12-word mnemonic,
an empty string, and unicode content; GCM nonce randomisation gives
distinct ciphertexts for the same plaintext (privacy property); decrypt
with the wrong key throws; output format is pinned as
\`<base64-iv>:<base64-ciphertext>\` (load-bearing for storage
forward-compat).

## Notes
- Tests use \`iterations: 1\` on the synchronous \`hashPin\` to avoid
the 600k-iter PBKDF2 cost. The deterministic / sensitivity properties
hold for any iteration count.
- These helpers are exercised indirectly in the existing PIN cubit
tests, but a direct contract for them lets a crypto-edge-case regression
(e.g. someone "optimising" the IV size) surface immediately.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 14 / 14 passing locally
- [ ] CI green
## Summary
Stage 30 of the coverage push. Three more wire-format DTOs and the
\`KycLevel\` int↔enum mapping.

| File | Cases |
| --- | --- |
| \`dfx_country_dto.dart\` (+ \`Country\` equality) | 5 |
| \`support_message_dto.dart\` + \`SupportMessage.fromDto\` | 5 |
| \`kyc_level.dart\` (value getter + fromValue) | 4 (+ 2 round-trip /
error cases) |

## What's pinned
- **DfxCountryDto.fromJson:** full wire mapping; \`foreignName\`
optional; false boolean flags preserved (not coerced); \`toCountry\`
extension drops the \`*_allowed\` flags; \`Country\` equality is
**id-only** — two Country instances with the same id are \`==\` even if
name / symbol differ (\`props = [id]\`).
- **SupportMessageDto / SupportMessage:** full wire shape; \`author\` /
\`message\` / \`fileName\` all optional with nulls preserved;
\`fromDto\` copies every field; \`isFromSupport\` true when \`author ==
null\`, false otherwise — pins the **support-vs-user** distinction used
in the chat UI.
- **KycLevel:** \`values\` has exactly 8 entries; numeric levels map to
0/10/20/30/40/50; terminated → −10, rejected → −20 (negative sentinels
for terminal states); \`KycLevelExtension.fromValue\` round-trips every
enum entry; throws \`ArgumentError\` on unknown int.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 16 / 16 passing locally
- [ ] CI green
## Summary
Stage 32 of the coverage push. Registration DTOs that round-trip through
the KYC backend.

| DTO | Cases |
| --- | --- |
| \`KycAddress\` toJson / fromJson | 5 |
| \`KycPersonalData\` toJson / fromJson | 4 |
| \`KycAccountType.fromString\` | 2 |

## What's pinned
- **KycAddress.toJson:** nests country as \`{id: int}\` on the wire;
omits \`houseNumber\` entirely when null.
- **KycAddress.fromJson:** reads country as a nested map (canonical),
AND as a bare int (legacy / alternate wire shape — the factory has a
defensive \`is Map\` check); \`houseNumber\` optional.
- **KycPersonalData.toJson:** serialises the personal fields + nested
address; excludes optional \`organizationName\` /
\`organizationAddress\` when absent; includes them when set.
- **KycPersonalData.fromJson:** round-trips both personal and
organization shapes.
- **KycAccountType.fromString:** resolves the three known wire values
(\`Personal\` / \`Organization\` / \`SoleProprietorship\`); throws on
unknown.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 11 / 11 passing locally
- [ ] CI green
… tests) (#357)

## Summary
Stage 33 of the coverage push. Wire-format DTOs for the
transaction-history flow that previously had no direct coverage.

| File | Cases |
| --- | --- |
| \`transactions_dto.dart\` (TransactionType / State / Dto / helpers) |
14 |
| \`account_history_dto.dart\` (TransferDto + HistoryEventDto +
AccountHistoryDto) | 5 |

## What's pinned
- **TransactionType.fromString:** resolves Buy/Sell/Swap/Referral; null
in → null out; unknown returns null (no throw — the factory uses
\`orElse: () => null\` so a new server-side type doesn't crash
deserialisation).
- **TransactionState:** canonical states; null + unknown → null;
\`isPending\` is the inverse of \`{completed, failed, returned}\` —
parameterised across every enum value to catch a new state silently
being treated as terminal.
- **TransactionDto:** full row parse; every field optional; integer
numerics widen to double via the \`num\` cast.
- **TransactionDto behaviour:** \`isPending\` mirrors
\`state.isPending\` and is false when state is null; \`belongsToWallet\`
matches sourceAccount case-insensitively, matches targetAccount when
source is null, returns false when both are null.
- **TransferDto / HistoryEventDto / AccountHistoryDto:** complete
wire-shape parse; \`transfer\` optional; empty history list allowed.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 19 / 19 passing locally
- [ ] CI green
## Summary
Stage 31 of the coverage push. Wire-format DTOs for the KYC session
hierarchy.

| DTO | Cases |
| --- | --- |
| \`KycStepDto.fromJson\` | 4 |
| \`KycSessionInfoDto\` + \`UrlType\` | 3 |
| \`KycStepSessionDto.fromJson\` | 1 |
| \`KycLevelDto.fromJson\` | 2 |
| \`KycSessionDto.fromJson\` | 2 |

## What's pinned
- **KycStepDto:** full-step parse (name + type + status + reason +
sequenceNumber + isCurrent); type / reason are optional (null on the
wire stays null); \`isCurrent\` defaults to \`false\` when **missing
entirely** (\`??\` fallback — pinned because callers rely on this);
unknown step name throws (no silent fallback).
- **KycSessionInfoDto / UrlType:** url + UrlType parse;
\`UrlTypeExtension.fromJson\` covers all four enum values
(Browser/API/Token/None); unknown wire string throws \`ArgumentError\`.
- **KycStepSessionDto:** inherits all step fields; carries a nested
\`KycSessionInfoDto\` parsed from \`session\`.
- **KycLevelDto:** level + step list; empty step list is allowed.
- **KycSessionDto:** extends \`KycLevelDto\` with an **optional**
\`currentStep\` (null stays null).

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 12 / 12 passing locally
- [ ] CI green
## Summary

Stage 34 of the coverage push. Pure DTO tests for EIP-7702 wire shapes
used in the RealUnit sell flow.

## Cases

| Target | Cases |
| --- | --- |
| \`Eip7702Domain.fromJson\` | 1 — name + version + chainId +
verifyingContract |
| \`Eip7702TypeField.fromJson\` | 1 — name + type |
| \`Eip7702Types.fromJson\` | 1 — \`Delegation\` + \`Caveat\` field
lists |
| \`Eip7702Message.fromJson\` | 1 — delegate / delegator / authority /
caveats / salt |
| \`Eip7702Data.fromJson\` | 1 — every top-level field + recursive
nested DTO parse |
| \`Eip7702AuthorizationDto.toJson\` | 2 — six-field round-trip;
\`chainId\` / \`nonce\` accept both number and string (per source
comment) |
| \`Eip7702ConfirmDto.toJson\` | 1 — nested \`delegation\` +
\`authorization\` |

## What's pinned

- The nested \`fromJson\` chain (\`Eip7702Data\` → \`Eip7702Domain\` /
\`Eip7702Types\` / \`Eip7702Message\`) actually walks all the way down.
- \`Eip7702AuthorizationDto.chainId\` and \`nonce\` are typed
\`dynamic\` on purpose so the wire-side number-or-string contract holds
— covered explicitly.
- \`Eip7702ConfirmDto.toJson\` preserves both nested JSON shapes
byte-for-byte.

## Test plan

- [x] \`flutter test
test/packages/service/dfx/models/payment/eip7702_dtos_test.dart\` — all
8 pass
- [x] \`flutter analyze\` clean on the new file
- [ ] CI green
## Summary

Stage 35 of the coverage push. Pure DTO + enum tests for the RealUnit
registration wire surface.

## Cases

| Target | Cases |
| --- | --- |
| \`RegistrationEmailStatus.fromString\` | 3 — \`email_registered\` /
\`merge_requested\` / throws on unknown |
| \`RegistrationStatus.fromString\` | 4 — \`completed\` /
\`pending_review\` / \`forwarding_failed\` / throws on unknown |
| \`RegistrationUserType.fromName\` | 3 — \`HUMAN\` / \`CORPORATION\` /
\`ArgumentError\` on unknown |
| \`RealUnitEmailRegistrationRequestDto.toJson\` | 1 |
| \`RealUnitRegisterWalletRequestDto.toJson\` | 1 |
| \`RealUnitRegistrationEmailResponseDto.fromJson\` | 2 — happy path +
propagated exception |
| \`RealUnitRegistrationResponseDto.fromJson\` | 2 — happy path +
propagated exception |
| \`CountryAndTin\` | 1 — \`fromJson\` + \`toJson\` round-trip |
| \`RealUnitRegistrationRequestDto.toJson\` | 2 — omits
\`countryAndTINs\` when null; includes the list when provided |

## What's pinned

- The wire strings stay locked: \`email_registered\` /
\`merge_requested\` for email status, \`pending_review\` /
\`forwarding_failed\` for registration status, \`HUMAN\` /
\`CORPORATION\` for user type.
- \`RealUnitRegistrationRequestDto.toJson\` omits the optional
\`countryAndTINs\` key when null (not just \`null\`) and inlines the
list of \`CountryAndTin.toJson()\` shapes when present.
- Both response DTOs delegate to the enum parsers, so an unknown status
surfaces an exception rather than a default — pinned by the negative
case.

## Excluded (and why)

- \`RegistrationUserType.name(BuildContext)\` — needs widget test
infrastructure for \`S.of(context)\`, covered elsewhere via screen-level
widget tests.

## Test plan

- [x] \`flutter test
test/packages/service/dfx/models/registration/registration_dtos_test.dart\`
— 19 pass
- [x] \`flutter analyze\` clean on the new file
- [ ] CI green
## Summary

Stage 36 of the coverage push. Pure DTO tests for the RealUnit buy +
sell wire surface, plus the \`PaymentInfoError\` enum contract.

## Cases

| Target | Cases |
| --- | --- |
| \`RealUnitBuyDto.toJson\` | 2 — defaults currency to \`CHF\`; honours
an override |
| \`RealUnitBuyConfirmDto.fromJson\` | 1 — reference field |
| \`RealUnitBuyPaymentInfoDto.fromJson\` | 2 — happy path; optional
\`minVolume\` / \`maxVolume\` / \`paymentRequest\` / \`remittanceInfo\`
as null |
| \`RealUnitSellDto.toJson\` | 4 — amount-only; targetAmount-only;
currency override; assertion that exactly one of \`amount\` /
\`targetAmount\` is set |
| \`BeneficiaryDto.fromJson\` | 2 — name + iban; name null |
| \`PaymentInfoError\` | 1 — four documented variants pinned |

## What's pinned

- \`RealUnitSellDto\` enforces XOR-style mutual exclusion on \`amount\`
/ \`targetAmount\` via assertion — both negative cases (neither / both)
are covered.
- The optional-field handling on \`RealUnitBuyPaymentInfoDto\` is
byte-exact: \`minVolume\` / \`maxVolume\` / \`paymentRequest\` /
\`remittanceInfo\` survive an explicit \`null\` on the wire.
- \`Currency\` round-trips through \`.code\` on serialisation and
\`Currency.fromCode\` on parse.
- \`PaymentInfoError\` is locked at four variants so a sneaky addition
doesn't slip past without an intentional bump.

## Excluded (and why)

- \`RealUnitSellPaymentInfoDto.fromJson\` — entangles \`Eip7702Data\`,
\`DfxFeesData\` and \`BeneficiaryDto\` parsing in one shot; better
covered via the \`real_unit_sell_payment_info_service\` integration
tests once PR #332 is in.

## Test plan

- [x] \`flutter test
test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart\` — 12
pass
- [x] \`flutter analyze\` clean on the new file
- [ ] CI green
… (+14 tests) (#361)

## Summary

Stage 37 of the coverage push. Aggregate pure-DTO file for the remaining
simple wire shapes across the DFX model surface.

## Cases

| Target | Cases |
| --- | --- |
| \`BrokerbotBuyPriceDto.fromJson\` | 1 — pins \`totalPrice\` →
\`totalCost\` rename |
| \`BrokerbotBuySharesDto.fromJson\` | 1 |
| \`BrokerbotSellPriceDto.fromJson\` | 1 |
| \`BrokerbotSellSharesDto.fromJson\` | 1 |
| \`FaucetResponseDto.fromJson\` | 1 |
| \`DfxFeesData.fromJson\` | 1 — integer wire values widen to double |
| \`Country\` | 2 — equality is by \`id\` only;
\`DfxCountryDtoMapper.toCountry\` forwards all four fields |
| \`UserDto.fromJson\` | 2 — \`mail\` + \`kyc\`; \`mail\` is optional |
| \`RealUnitWalletStatusDto.fromJson\` | 2 — \`isRegistered\` +
\`userData\`; unregistered / null \`userData\` branch |
| \`RealUnitUserDataDto.fromJson\` | 2 — happy path; \`countryAndTINs\`
list when provided |

## What's pinned

- \`BrokerbotBuyPriceDto.fromJson\` maps \`totalPrice\` (wire) →
\`totalCost\` (Dart) — the rename is locked.
- \`Country.props\` is \`[id]\`, so two countries with the same id
compare equal even if their other fields differ — covered explicitly.
- The optional fields (\`mail\` on \`UserDto\`, \`userData\` on
\`RealUnitWalletStatusDto\`, \`countryAndTINs\` on
\`RealUnitUserDataDto\`) survive being null on the wire.
- \`DfxFeesData\` widens \`int\` to \`double\` so an integer-typed JSON
value doesn't throw.

## Test plan

- [x] \`flutter test
test/packages/service/dfx/models/aggregate_dtos_test.dart\` — 14 pass
- [x] \`flutter analyze\` clean on the new file
- [ ] CI green
#362)

## Summary

Stage 38 of the coverage push. Pure value-object + DTO file covering the
PDF wire surface, the support issue ladder, BankAccount equatable
contract and BuyPaymentInfo equality.

## Cases

| Target | Cases |
| --- | --- |
| \`MultiReceiptDto.toJson\` | 2 — default CHF; renames \`txIds\` →
\`txHashes\` |
| \`SingleReceiptDto.toJson\` | 2 — default CHF; renames \`txId\` →
\`txHash\` |
| \`PdfDto.fromJson\` | 1 — reads \`pdfData\` |
| \`BalancePdfDto.toJson\` | 2 — default EN (uppercased on wire);
override + uppercased language code |
| \`SupportIssueType\` | 3 — round-trip; falls back to \`genericIssue\`;
\`toJson\` returns the wire value |
| \`SupportIssueReason\` | 2 — round-trip; falls back to \`other\` |
| \`SupportIssueState\` | 2 — round-trip; falls back to \`created\` |
| \`SupportIssueDto.fromJson\` | 2 — full happy path; absent
\`messages\` → \`[]\` |
| \`SupportIssue\` | 2 — \`isOpen\` for \`created\` / \`pending\` only;
\`fromDto\` maps fields |
| \`BankAccount\` | 2 — equality by \`id\` only; \`isActive\` defaults
to false |
| \`BuyPaymentInfo\` | 1 — equatable props pin every field
(currency-mismatch breaks equality) |

## What's pinned

- The two-step \`txId\` rename (\`txId\` → \`txHash\`, \`txIds\` →
\`txHashes\`) is locked on both \`SingleReceiptDto\` and
\`MultiReceiptDto\`.
- \`BalancePdfDto\` uppercases \`Language.code\` (\`en\` → \`EN\`) on
the wire — a silent lowercase would break the contract.
- All three support enums use \`firstWhere(... orElse: ...)\` to fall
back to a sentinel on unknown wire values; each fallback is pinned by
name so refactors can't silently change which sentinel returns.
- \`SupportIssueDto.messages\` is optional on the wire and resolves to
an empty list, not null.
- \`BankAccount\` Equatable \`props\` is \`[id]\` only — two accounts
with the same id compare equal even if name / iban / isActive differ.
- \`BuyPaymentInfo\` props enumerate every field — the negative case
(currency difference) is the canary.

## Test plan

- [x] \`flutter test
test/packages/service/dfx/models/pdf_support_bank_test.dart\` — 21 pass
- [x] \`flutter analyze\` clean on the new file
- [ ] CI green
## Summary

Stage 39 of the coverage push. Pure-DTO tests for the KYC financial-data
wire surface plus the \`KycLevelDto\` wrapper that ties \`KycLevel\` to
a list of \`KycStepDto\`.

## Cases

| Target | Cases |
| --- | --- |
| \`QuestionType.fromValue\` | 2 — four documented wire values
(\`Confirmation\` / \`SingleChoice\` / \`MultipleChoice\` / \`Text\`);
throws \`ArgumentError\` on unknown |
| \`KycFinancialOption.fromJson\` | 1 — key + text |
| \`KycFinancialCondition.fromJson\` | 1 — question + response |
| \`KycFinancialQuestion.fromJson\` | 2 — happy path (description +
options + conditions); description / options / conditions all optional |
| \`KycFinancialResponse\` | 1 — \`fromJson\` + \`toJson\` round-trip |
| \`KycFinancialOutData.fromJson\` | 2 — with responses; absent
\`responses\` defaults to \`[]\` |
| \`KycLevelDto.fromJson\` | 1 — \`kycLevel\` + nested \`KycStepDto\`
list |

## What's pinned

- \`QuestionType.fromValue\` is **opt-in strict** (throws on unknown
wire values) unlike the support enums which fall back to a sentinel —
pinned by both the happy path and the negative case.
- \`KycFinancialQuestion\` treats \`description\`, \`options\` and
\`conditions\` as fully optional — wire-side \`null\` survives as Dart
\`null\`, not an empty list.
- \`KycFinancialOutData.responses\` defaults to \`[]\` when the key is
absent (not \`null\`), distinguishing it from the question-side
optionals.

## Test plan

- [x] \`flutter test
test/packages/service/dfx/models/kyc/kyc_financial_data_dto_test.dart\`
— 10 pass
- [x] \`flutter analyze\` clean on the new file
- [ ] CI green
## Summary

Stage 42 of the coverage push. Pure-function tests for the two IBAN
formatters that drive the bank-account input field.

## Cases

| Target | Cases |
| --- | --- |
| \`IbanTextFormatter.formatIban\` | 5 — empty; lowercase → upper;
strips existing spaces before regrouping; long input groups every four
chars; exactly four chars: no trailing space |
| \`IbanInputFormatter.formatEditUpdate\` | 4 — groups output in fours
and uppercases; drops invalid characters silently; caret stays collapsed
at the end; empty input remains empty |

## What's pinned

- \`IbanTextFormatter\` is the read-side display helper — it does not
validate characters, it just regroups and uppercases.
- \`IbanInputFormatter\` is the write-side \`TextInputFormatter\`; the
regex \`[A-Z0-9]\` is applied **after** uppercasing, so lowercase
letters survive but punctuation does not. The "drops invalid characters
silently" case pins this contract.
- The caret behaviour (\`selection: TextSelection.collapsed(offset:
text.length)\`) is essential UX — explicit case covers it so a future
refactor can't silently strand the caret mid-string.

## Test plan

- [x] \`flutter test test/widgets/iban_formatters_test.dart\` — 9 pass
- [x] \`flutter analyze\` clean on the new file
- [ ] CI green
## Summary

Stage 40 of the coverage push. First cubit-level coverage of the PIN
authentication lifecycle: setup, verification, background lockout, and
reset.

## Cases

| Target | Cases |
| --- | --- |
| initial state | 1 — both flags false |
| \`initialize\` | 2 — PIN set: \`isPinSetup=true\`, \`isPinVerified\`
stays false; no PIN: \`isPinVerified\` auto-passes |
| \`onPinSetupComplete\` | 1 — both flags true |
| \`onPinVerified\` | 1 — preserves \`isPinSetup\`, flips
\`isPinVerified\` |
| background / resume lockout | 4 — resume without prior hide is a
no-op; resume when PIN not setup is a no-op; elapsed <
\`lockoutDuration\` keeps verification; \`lockoutDuration\` constant pin
(5 min) |
| \`reset\` | 1 — wipes pin hash + biometric + lockout, emits initial
state |

## What's pinned

- The "no PIN set" branch in \`initialize\` deliberately auto-passes
verification — pinning it so a refactor doesn't silently force an
unnecessary PIN prompt.
- The lockout-window comparator and the \`lockoutDuration\` constant are
tested in tandem: the elapsed-too-small branch via behavior, the
boundary via the constant pin. The elapsed-too-large branch would
require wall-clock manipulation; the constant pin is what catches drift
today.
- \`reset\` issues three independent storage deletes (\`deletePinHash\`
+ \`deleteBiometricEnabled\` + \`resetPinLockout\`) — all three are
verified to fire once.

## Excluded (and why)

- The elapsed-≥-\`lockoutDuration\` invalidation branch — requires
fake-clock or 5 minutes of real wait; covered indirectly by the constant
pin and by integration tests on the wider auth flow.

## Test plan

- [x] \`flutter test test/screens/pin/pin_auth_cubit_test.dart\` — 10
pass
- [x] \`flutter analyze\` clean on the new file
- [ ] CI green
## Summary
Four fixes that together let the BitBox-gated KYC registration run all
the way through on iOS BLE without the user falling into a dead end:

- **`fix(bitbox)`** Wrap `confirmPairing` and `createBitboxWallet` in
75s/30s timeouts so a silent BLE stall surfaces as `BitboxNotConnected`
instead of an endless spinner. `ConnectBitboxCubit` gains injectable
timeouts for unit tests.
- **`fix(bitbox)`** Serialize all sign calls (`signToSignature`,
`signPersonalMessage`, `signTypedDataV4`) through a static `_signQueue`.
The bitbox02-api-go SDK keeps a single noise cipher per device, so two
concurrent signs would advance the nonce out of order and break
decryption permanently.
- **`fix(bitbox)`** Transliterate every string field that goes into the
EIP-712 envelope (and its matching DTO copy) to printable ASCII via
`toBitboxSafeAscii` — covers German umlauts, French/Spanish/Portuguese
accents, Polish/Czech letters, Nordic æ/ø/å, Romanian/Turkish. BitBox
firmware rejects any non-ASCII byte in `string`-typed values with
`ErrInvalidInput (101)`. KYC personal-data fields keep the original
spelling so ID-verification still sees the legal name with diacritics.
- **`fix(kyc)`** `KycCubit` now routes based on the status of the
required steps, not just the numeric level. A high aggregate level with
`ident=failed` or `financialData=outdated` no longer short-circuits to
`KycCompleted` — the user is sent back through the unfinished steps via
`_continueKyc`.

## Test plan
- [x] `flutter test` (446 passing, +24 new across
`connect_bitbox_cubit_test.dart`, `bitbox_credentials_test.dart`,
`kyc_cubit_test.dart`, `eip712_signer_bitbox_test.dart`,
`ascii_transliterate_test.dart`)
- [x] `flutter analyze` clean
- [x] Manual smoke on iPhone Air (iOS 26) — pair → 13-page EIP-712 sign
with umlaut name → KYC continues to next required step

---------

Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
## Summary

Stage 41 of the coverage push. Cubit-level coverage of the 'show
recovery seed' toggle screen.

## Cases

| Target | Cases |
| --- | --- |
| initial state | 1 — seed mirrors \`wallet.seed\`; \`showSeed=false\` |
| \`toggleShowSeed\` | 2 — single flip to true; double flip back to
false |
| \`SettingsSeedState\` | 2 — \`copyWith\` preserves untouched fields;
Equatable props cover \`seed\` + \`showSeed\` |

## What's pinned

- The toggle does not mutate the underlying seed — every emitted state
still carries the canonical BIP39 test mnemonic.
- \`copyWith\` is non-destructive: passing only \`showSeed\` keeps
\`seed\` intact.
- Equatable props enumerate both fields — pinned via the negative case
(different \`showSeed\` breaks equality).

## Test plan

- [x] \`flutter test
test/screens/settings_seed/settings_seed_cubit_test.dart\` — 5 pass
- [x] \`flutter analyze\` clean on the new file
- [ ] CI green
…367)

## Summary

Stage 43 of the coverage push. Cubit-level coverage of the KYC
financial-data step: question loading, conditional visibility, answer
routing, navigation, submission and the \`LoadedSuccess\` view-model
helpers.

## Cases

| Target | Cases |
| --- | --- |
| initial state | 1 — \`KycFinancialDataInitial\` |
| \`loadQuestions\` | 3 — Loading → LoadedSuccess; conditional questions
filtered when their condition is unmet; Loading → Failure on service
error |
| \`answerQuestion\` | 2 — no-op outside LoadedSuccess; stores the
answer and re-runs the visibility filter (conditional questions appear
once their predicate matches) |
| \`submitAndNext\` | 4 — non-last increments \`currentIndex\`; last
emits Submitting → SubmitSuccess and calls \`setFinancialData\`; submit
error surfaces a Failure; no-op outside LoadedSuccess |
| \`goBack\` | 3 — decrements \`currentIndex\` from 1; no emit at index
0; no-op outside LoadedSuccess |
| \`KycFinancialDataLoadedSuccess\` helpers | 4 — \`currentQuestion\` at
index; \`isFirstQuestion\` / \`isLastQuestion\`; \`currentResponse\` +
\`hasAnswer\` reflect the responses map; \`hasAnswer\` is false on an
empty string response |

## What's pinned

- Conditional-question visibility is **dynamic**: \`answerQuestion\`
re-runs the filter so a previously hidden question becomes visible the
moment its condition is satisfied. Pinned by a live-flip test on \`q3\`
(visible only when \`q1 == 'a'\`).
- \`submitAndNext\` only calls \`setFinancialData\` once the user is on
the last visible question — the non-last branch must never reach the
service.
- \`goBack\` at index 0 must not emit (same state instance pinned with
\`same\`), so the UI doesn't show a spurious rebuild when the user taps
Back too hard.
- \`hasAnswer\` treats an empty string as no answer — pins the contract
used by the "Next" button's disabled state.

## Test plan

- [x] \`flutter test
test/screens/kyc/steps/financial_data/kyc_financial_data_cubit_test.dart\`
— 17 pass
- [x] \`flutter analyze\` clean on the new file
- [ ] CI green
)

## Summary

Stage 44 of the coverage push. Pure-function tests for the seed-word
string utilities and the BIP39 highlighting on the mnemonic input
controller.

## Cases

| Target | Cases |
| --- | --- |
| \`SeedStringExtension.seedWords\` | 5 — splits on single spaces;
collapses repeated whitespace + tabs; strips leading/trailing
whitespace; treats newlines as whitespace; empty input returns empty
list |
| \`MnemonicListExtension.seed\` | 2 — joins controller texts and trims
each entry; preserves the controller order |
| \`MnemonicInputFieldController.buildTextSpan\` | 2 — base style for a
word in the BIP39 list; merges in the red non-match style for an unknown
word |

## What's pinned

- The seed-word split uses \`RegExp(r'\s+')\`, so any whitespace
boundary (tab / newline / multiple spaces) counts — all four whitespace
classes are exercised.
- \`MnemonicListExtension.seed\` trims each controller entry
individually before joining, so accidental leading/trailing spaces (e.g.
from a paste) don't bleed into the recovered phrase.
- \`buildTextSpan\` differentiates valid vs invalid BIP39 words via a
style merge — a word that **is** in the wordlist returns the unmodified
base style, anything else picks up the red status color. Pinned by both
branches.

## Test plan

- [x] \`flutter test test/widgets/mnemonic_extensions_test.dart\` — 9
pass
- [x] \`flutter analyze\` clean on the new file
- [ ] CI green
…ts) (#383)

## Summary

Stage 59 of the coverage push. Drives \`confirmPayment\` end-to-end with
a real \`EthPrivateKey\` credential so the signing chain
(\`Eip712Signer.signDelegation\` + \`Eip7702Signer.signAuthorization\`)
is exercised, not just the validation guard.

## Cases

| Case | What's pinned |
| --- | --- |
| Software wallet happy path | PUTs to
\`/v1/realunit/sell/<id>/confirm\`; body carries the \`eip7702\`
envelope (not the \`txHash\` branch); delegation block mirrors the
eip7702 data + carries a 65-byte real signature; authorization block
carries the matching chain / nonce / (r, s, yParity) |
| 4xx response | \`ApiException\` propagates from \`_sendConfirm\` |

## What's pinned

- The delegation \`salt\` is serialised as a **string** (\`'0'\`), not
the raw int. Pinned via string equality so a regression to numeric
serialization surfaces.
- The authorization block uses \`chainId\` from \`domain.chainId\`,
\`address\` from \`delegatorAddress\` (the MetaMask Delegator contract),
and \`nonce\` from \`userNonce\` — the test asserts each value
explicitly.
- The signature length pin (132 chars including \`0x\`) catches a
serialization regression that drops the trailing \`v\` byte.
- Deterministic test key is shared with \`FakeBitboxCredentials\`.
Reusing it keeps test infrastructure consistent and lets future
BitBox-vs-software comparison tests reuse the same address.

## Test plan

- [x] \`flutter test
test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart\`
— 2 pass
- [x] \`flutter analyze\` clean on the new file
- [ ] CI green
TaprootFreak and others added 30 commits June 3, 2026 00:05
## 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

Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
…#648)

## Problem

The iOS leg of release **v1.0.77** failed at `deliver` (the App Store
listing push). gym + `upload_to_testflight` succeeded (the TestFlight
binary shipped), then `deliver` aborted:

```
app_version: 1.0.77, skip_app_version_update: false
The provided entity includes a relationship with an invalid value
- You cannot create a new version of the App in the current state. - /data/relationships/app
```

The earlier `#646` fix (`skip_app_version_update: false`) correctly
makes deliver *attempt* to create the App Store version (the old "could
not find an editable version" error is gone). But Apple's API refuses to
create the **first** App Store version for an app that is still
TestFlight-only — an **App Store Connect bootstrap prerequisite**, not a
repo problem. The additive listing push then hard-failed the whole iOS
release even though the binary uploaded fine.

## Fix — best-effort listing push (Option B, owner-approved)

`ios/fastlane/Fastfile`: wrap the `deliver` call (both the `beta` lane
and the `store_metadata` lane) in a new `deliver_best_effort` helper:

```ruby
def deliver_best_effort(**options)
  deliver(**options)
rescue => e
  UI.error(...); UI.important("ACTION: create the first App Store version once in App Store Connect ...")
end
```

- The `rescue` wraps **only the single `deliver` call**, so a deliver
failure is logged loudly and the lane exit stays **0**.
- **A failure of any other action (gym, signing, `upload_to_testflight`,
…) still hard-fails** — they are outside the rescue.
- Android path **unchanged**.
- README updated to document the best-effort behavior + the one-time ASC
bootstrap.

Once the first App Store version is created once in App Store Connect,
the next tag-driven release's `deliver` writes the listing automatically
— no further manual step. (Owner will do the ASC bootstrap later.)

## What is swallowed vs. what still hard-fails
- **Swallowed (logged WARNING, exit 0):** any failure of the `deliver`
call in the `beta` / `store_metadata` lanes — i.e. the App Store listing
push, including the "cannot create a new version in the current state"
bootstrap error.
- **Still hard-fails:** `gym` (build/archive), code signing,
`upload_to_testflight` (the release-critical TestFlight upload), and
every other action — none are wrapped.

## Validation
- `ruby -c ios/fastlane/Fastfile` passes.
- Isolated rescue test: the helper swallows the deliver exception
(returns, no re-raise → exit 0); a non-deliver error propagates and
hard-fails.
- The error stays clearly visible in the build log (`UI.error` banner +
`UI.important` ACTION line) so the pending ASC bootstrap is never
silently lost.

Base branch: **`staging`** (per `CONTRIBUTING.md`).

Refs #634
## 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

Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
fix(handbook): store-listing source links point at develop, not main
## 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

Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
The DFX API now returns an optional `context` field in
KycLevelRequiredException and RegistrationRequiredException responses.
This lets the API scope KYC steps to the current flow (e.g.
RealunitBuy only requires LEVEL_30, RealunitSell requires all steps).

The app reads the context from the exception, carries it through the
failure state and route navigation, and passes it as a query param
on GET/PUT /v2/kyc. No local step-filtering logic — the API decides.
feat(kyc): consume context param from KYC/registration exceptions
…ing-state

feat(kyc): render MergeProcessing waiting state (pairs with api#3807)
Promote: staging -> develop
…padding

fix(sell): zero-pad EIP-7702 authorization r/s to 32 bytes
…en (#618)

## Summary
`KycViewManager`'s `KycSuccess` step switch had no `KycStep.dfxApproval`
case. When `_continueKyc` emits `KycSuccess(currentStep:
KycStep.dfxApproval)` (backend `processStatus: InProgress` with a
`DfxApproval` current step), it fell through to `(_) => const
Scaffold()` — a **blank white screen** with no refresh/back, stranding
the user.

Fix:
- Route `dfxApproval` to the existing `KycPendingPage` (semantically
"DFX is reviewing"; it already has a refresh action).
- Remove the catch-all `(_)` arm so the inner switch is **exhaustive
over `KycStep`** — a future enum value becomes a compile error (forced
handling) rather than another silent blank.

## Test
Adds the first `KycViewManager` widget test: drives the real
`_continueKyc` path to `KycSuccess(dfxApproval)` and asserts
`KycPendingPage` renders (not a blank `Scaffold`).

## Verification (local, CI-equivalent, Flutter 3.41.6)
`generate_localization` → `generate_release_info` → `build_runner` →
`analyze` → `test`:
- `flutter analyze` on the changed files: **No issues found**.
- Widget test **passes with the fix**; **fails when the routing change
is reverted** (renders blank `Scaffold` → `KycPendingPage` not found),
confirming it catches the regression.

Fixes the K1 item of #613.
## Summary
The user-facing **"Delete Wallet"** only cleared `walletAccountInfos`
(`WalletStorage.deleteWallet`). The `walletInfos` row — which holds the
**AES-GCM-encrypted seed** — and the **mnemonic encryption key** in
`flutter_secure_storage` both survived. So after a user deletes their
wallet, the full mnemonic remained recoverable on the device (resale /
GDPR right-to-erasure risk); only the current-wallet pointer was
cleared, masking the residue.

Fix (non-breaking — the account-only `deleteWallet` is preserved for the
onboarding-regenerate flow, which deliberately relies on the seed row
surviving):
- `WalletStorage.deleteWalletCompletely(id)` — deletes accounts **and**
the `walletInfos` seed row in a transaction (FK-safe).
- `SecureStorage.deleteMnemonicKey()` — removes the AES-GCM key.
- `WalletRepository.purgeWallet(id)` = `deleteWalletCompletely` +
`deleteMnemonicKey`.
- `WalletService.deleteCurrentWallet` now calls `purgeWallet`.

> Verified the onboarding-regenerate path does **not** call
`deleteCurrentWallet`/`deleteWallet` at runtime (it defers the DB
write), so it is unaffected.

## Test
- `purgeWallet` removes the seed row **and** the mnemonic key.
- `deleteWallet` (account-only) still leaves the row + key intact
(regenerate contract guard).
- The service delete now verifies `purgeWallet` is called and
`deleteWallet` is not.

## Verification (local, CI-equivalent, Flutter 3.41.6)
- `flutter analyze` on the 6 changed files: **No issues found**.
- All tests **pass with the fix**; the service test **fails when
`deleteCurrentWallet` is reverted** to `deleteWallet`, confirming the
regression is caught.

Fixes the S2 item of #612.
…oad (#630)

## What

When a legal document's markdown asset fails to load,
`LegalDocumentPage` caught the error and set `_markdownContent = ''`,
which `build` treats as the *loaded* state → the user got a **blank page
with no message and no way to retry**.

This adds an explicit error state with a localized message and a
**Retry** action.

## Why

Part of #629 (audit finding **#19**). The failure was silent: `catch (_)
{ _markdownContent = ''; }` is indistinguishable from a successfully
loaded empty document, so a missing/unreadable legal asset (e.g. a
packaging slip-up or I/O error) leaves the user staring at an empty
screen.

## Changes

- `legal_document_page.dart`: track `_loadFailed`; on load failure
render a centered error message + `Retry` button (`_retryLoad` re-runs
the load) instead of an empty `Markdown`. The load is also now logged
via `developer.log`.
- New i18n key `legalDocumentLoadFailed` (EN + DE).
- `legal_document_page_test.dart` (new): regression test — asset-load
failure must surface an error + retry affordance (was red before the
fix), plus a control for the loaded body and a retry-path test.
`rootBundle.clear()` per test keeps the asset cache from leaking between
cases.
- `legal_document_golden_test.dart`: the existing default golden
rendered `buildSubject()` with no content, which under the new behaviour
would race into the error state. Switched it to render an empty document
deterministically (`initialMarkdownContent: ''`) — same committed
baseline, no async load.

## Test plan

- `flutter analyze` → **0 issues**
- `flutter test test/screens/legal/legal_document_page_test.dart` → red
on `develop`, **green** with the fix
- `flutter test
test/goldens/screens/legal/legal_document_golden_test.dart` → **green**
(baseline unchanged)
- full `flutter test --coverage` run locally (only the known
`home_golden` macOS pixel drift remains, which is green on CI)

No production behaviour changes for the loaded/loading paths — only the
failure path gains an error UI.
…export (#659)

Implements [#658](#658).

Adds a **"Rechtsdokumente — Downloads"** (nav `L`) section to the
handbook that exposes RealUnit's three in-app legal documents as a
**derived PDF + DOCX export** of the repo's Markdown — the same
upstream/downstream model the store-listing and mail sections already
use. `assets/legal/*.md` stays the single source of truth.

### In scope (exactly the 3 repo-local docs)
| Document | Source | ARB title key |
|---|---|---|
| Datenschutzbestimmungen | `assets/legal/privacy_policy_<lang>.md` |
`legalDisclaimerCheckboxPrivacyPolicy` |
| Nutzungsbedingungen | `assets/legal/terms_of_use_<lang>.md` |
`termsOfUse` |
| Registrierungsvereinbarung |
`assets/legal/registration_agreement_<lang>.md` |
`legalDisclaimerCheckboxRegistrationAgreement` |

Languages are **discovered by glob** (never hardcoded) → today `de` +
`en` = 3 docs × 2 langs = **6 PDF + 6 DOCX**. A future `_fr.md` appears
automatically. DFX, Aktionariat and the externally-hosted corporate PDFs
are intentionally **out of scope** (no Markdown source in the repo).

### Per (document, language): three access paths
Each card carries, consistent with the existing handbook `.test` cards
(same `permalink` anchor + `🔗 Link` copy-button pattern):
- a **PDF** download and a **DOCX** download,
- a **direct link / permalink** to the entry
(`id="legal-<base>-<lang>"`, copy-to-clipboard button), and
- a `↗` link to the **Markdown source** on `develop`.

> Direct-links per the follow-up request: each document now also has a
Direktlink/permalink built exactly like the other handbook sections
(`a.name.permalink` + generic `.copy-link[data-target]` JS), so the look
stays consistent across the handbook.

### The critical determinism split
- **HTML block** (download list) — produced by the pure-stdlib
`scripts/assemble-handbook-legal.py`, **deterministic → committed →
sync-gated** (CI re-runs the generator and fails on a non-empty `git
diff` of `index.html`).
- **PDF/DOCX binaries** — produced by `scripts/build-legal-downloads.sh`
via pandoc (weasyprint PDF engine), **non-deterministic → git-ignored →
built only inside the image → never committed, never sync-gated** (same
treatment as the assembled screenshots).

### Changes
- `scripts/assemble-handbook-legal.py` — deterministic generator
(mirrors `assemble-handbook-store-listing.py`).
- `scripts/templates/legal-downloads.html.tmpl` — section template.
- `scripts/build-legal-downloads.sh` — pandoc PDF/DOCX builder
(image-only).
- `Dockerfile.handbook` — new `legal-docs-builder` stage chained on
`store-listing-builder` (so both rewritten blocks survive); installs
`font-dejavu` so weasyprint can render.
- `handbook.nginx.conf` — `/legal/*.{pdf,docx}` served with
`Content-Disposition: attachment`, behind the existing Basic-Auth gate.
- `.github/workflows/handbook-build-check.yaml` — paths, the legal HTML
sync gate, and download-presence smoke checks.
- `.gitignore` — `docs/handbook/legal/`.
- `docs/handbook/de/index.html` — markers, nav entry, CSS, and the
committed (generated) block.

### Local verification
- `docker build -f Dockerfile.handbook` is **green**; the
`legal-docs-builder` stage produces **12 files**.
- PDFs are valid (`%PDF-`); DOCX are valid OOXML (zip with
`word/document.xml` + `word/styles.xml`) — i.e. real **editable Word
documents**, the artifact requested for legal review.
- DOCX content matches the Markdown (German text incl. umlauts intact,
e.g. "Geltungsbereich", "RealUnit Schweiz AG").
- Generator is **idempotent** (re-run = byte-identical); the legal sync
gate is in sync.
- Container serves the downloads (200/401 behind auth) with the
attachment disposition.

Draft until CI is green.
…t instructions) (#661)

Implements [#660](#660).

**Pure i18n label fix.** The buy-payment button told customers to
transfer the money *first* and click *afterwards*, but the click is
exactly what **requests the payment instructions** (`PUT
/v1/realunit/buy/{id}/confirm` → `requestPaymentInstructions` on the API
side, sets `WAITING_FOR_PAYMENT`, returns a reference). The customer
should click first, receive the payment slip by email, then pay. Tester
feedback: Bojan Jankovic, 28.05.2026 — "Einzig den Text auf dem Button
sollte man ändern."

### Change (label only)
| Lang | Old | New |
|------|-----|-----|
| de | `Klicken Sie hier, sobald Sie die Überweisung getätigt haben` |
`Zahlungsanweisungen per E-Mail anfordern` |
| en | `Click here once you have made the transfer` | `Request payment
instructions by email` |

Only the `buyPaymentConfirm` value in `assets/languages/strings_de.arb`
+ `strings_en.arb` (key keeps its alphabetical position).
`lib/generated/i18n.dart` is git-ignored and regenerated in CI (`dart
run tool/generate_localization.dart`).

### Strictly out of scope (untouched)
`confirmPayment` / `BuyConfirmCubit` logic, the `/confirm` endpoint and
any API behaviour, `buyPaymentInformationDescription`,
`buyExecutedDescription` / `PaymentExecutedSheet`, and the error strings
`buyPaymentConfirmFailed` / `buyPaymentConfirmFailedAktionariat`.

### Tests & goldens
- No widget test asserts the old literal or the `buyPaymentConfirm` key
(verified across all of `test/`) — no test code change needed. The
button consumer (`payment_information_details.dart`) uses the generated
getter, so the new label takes effect and `onPressed` still calls
`confirmPayment`.
- The `buy_payment_info_loaded` golden renders this button and changes;
**`golden-regenerate.yaml` was triggered on this branch immediately
after push** (not waiting for Visual Regression to fail). The other 6
buy goldens don't render this button and are unaffected.

3-subagent review: Quality + Logic both PASS_CLEAN. Draft until CI
(incl. regenerated golden) is green.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## 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

---------

Co-authored-by: Josh <joshua.krueger@dfx.swiss>
Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
feat(handbook): add task-overview section rendered from mounted CSV (#662)
Promote: staging -> develop
## Summary
Drop-in replacement for `assets/images/splash/splash_background.png`
with the brand designer's updated asset.

- **Mountain key visual kept** (per design alignment) — only the subline
changed.
- Subline unified to **"Sicher. Einfach. Bankenunabhängig."** (was
"Bankenunabhängig. Sicher.").
- 5120×5120, sRGB — same format/dimensions as the previous asset (clean
drop-in).

This asset doubles as the OS launch splash and the in-app HomePage
background; layout constraints (logo + subline in the upper ~60 %,
footer in the gradient-covered lower area) are respected.

## Test plan
- [ ] Visual Regression: the Welcome/HomePage golden changes (new
subline). `golden-regenerate` triggered on this branch; VR green after
regenerated goldens are committed.
- [ ] Manual: launch splash + Welcome screen show the new subline;
mountain unchanged.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## 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

Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
fix(splash): regenerate native launch assets (OS launch splash showed old image)
## 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

Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
docs(handbook): Download / Test access section (#spec-downloads)
## Problem

Der Handbook-Deploy triggerte bisher **nur auf `develop`** und fächerte
diesen einen Run in **beide** Environments auf — beide Jobs pushten
denselben Docker-Tag (`:beta`). Das koppelt DEV und PRD und führt zum
**Clobber**: ein develop-Build überschreibt das dev-Image (und
umgekehrt) auf Docker Hub, sobald sich Builds zeitlich überlappen.

## Lösung: ein Environment pro Branch

| Push auf | baut aus | deployt | Tag | Endpoint |
| --- | --- | --- | --- | --- |
| `staging` | staging | **DEV** | `:beta` | dev-handbook.realunit.app |
| `develop` | develop | **PRD** | `:latest` | handbook.realunit.app |

## Änderungen

**`.github/workflows/handbook-deploy.yaml`**
- Trigger auf `[staging, develop]` (+ `workflow_dispatch`, Ziel-Env nach
dispatchtem Branch)
- Routing je Branch über `if: github.ref_name == 'staging' | 'develop'`
- **In-Run-Kopplung (`needs:`/sequenziell) entfernt** — die „DEV grün
vor PRD"-Garantie liefert jetzt der Promotion-Flow selbst: Inhalt
erreicht `develop` erst nach `staging` (via `auto-staging-pr.yaml`),
also nach DEV-Build+Smoke
- **Concurrency-Gruppe pro Branch** (`handbook-deploy-${{
github.ref_name }}`) → DEV und PRD blockieren/canceln sich nicht mehr
gegenseitig
- **Getrennte Image-Tags** (`:beta` DEV, `:latest` PRD) → kein Clobber
mehr

**`.github/workflows/handbook.yaml`**
- Neuer required Input **`ref`**; der Handbook-Checkout nutzt ihn → DEV
baut den **staging-Stand**, PRD den **develop-Stand** (jeweils der
triggernde Branch)
- Der `DFXswiss/api`-Mail-Preview-Checkout bleibt für **beide** Envs auf
`develop` gepinnt (er folgt dem Handbook-Branch-Split bewusst **nicht**)
— klargestellt im Code-Kommentar

**Doku:** README-Workflow-Tabelle, `docs/handbook/README.md`
Trigger-Sektion und zwei Prosa-Stellen in `docs/handbook/de/index.html`
auf das neue Mapping aktualisiert.

## Tag-Wahl / Server-Kompatibilität

Die Tags `:beta` (DEV) / `:latest` (PRD) entsprechen exakt dem **bereits
in `README.md` dokumentierten** Mapping (der YAML war auf beide-`:beta`
regressiert). Damit bleiben die serverseitigen Compose-Referenzen auf
dfxdev/dfxprd gültig — **keine Server-Änderung nötig**.

## Lokal verifiziert
- ✅ `actionlint` sauber auf beiden geänderten Workflows
(Reusable-Input-Verdrahtung inkl. neuem `ref` valide). Die einzigen
Hinweise sind **vorbestehende** `SC2012`-infos in der **nicht
angefassten** Mail-Preview-Stufe.
- ✅ Beide Handbook-Generatoren sind No-ops auf der editierten
`index.html` (Sync-Gates grün)
- ✅ `docker build -f Dockerfile.handbook` + Container-Smoke (`/healthz`
200, `/de/` 401) grün; geänderte Prosa im Image vorhanden
- ✅ YAML-Parse OK
Promote: staging -> develop
…rm (#696)

## Problem

The **Wallet-Adresse** screen rendered the receive address in
**lowercase** (`0x127d4a7e…ae8aa2`) in both the QR code and the address
text. `SettingsWalletAddressPage` used `AppStore.primaryAddress`, which
returns the lowercase `.hex` form.

The canonical, verifiable representation of an Ethereum address is its
**EIP-55 checksummed** form (mixed case). Showing lowercase is incorrect
for a receive address users are meant to verify.

## Fix

- In `settings_wallet_address_page.dart`, convert the address to its
checksummed form via `EthereumAddress.fromHex(...).hexEip55` before
passing it to the QR code and the displayed text.
- **Scoped to this screen only** — `AppStore.primaryAddress` and all
other consumers are untouched (per request: wallet-address screen fix
only).

## Tests

- Added a page test asserting the rendered `QRAddressWidget` receives
the **checksummed** address in both its `subtitle` (text) and `uri`
(QR), given a lowercase mock.
- `flutter analyze` clean; page + QR-widget tests green.
- Golden `settings_wallet_address_page_default` regenerated via
`golden-regenerate.yaml` (address text now mixed-case).

## Test plan

- [ ] Open Wallet-Adresse screen → address shown checksummed, QR encodes
checksummed, copy copies checksummed.
- [ ] Visual Regression green against the regenerated baseline.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
… price chart (#695)

## Problem

The public price view ("RealUnit Aktienkurs") shows an **empty chart**
and `CHF --.--` whenever the RealUnit quote is temporarily unavailable.

Confirmed against the live API:
- `GET /v1/realunit/price` → `{"timestamp":"2026-06-04T22:28:16.539Z"}`
— **no `chf`/`eur`**.
- `GET /v1/realunit/price/history?timeFrame=ALL` → **1514 points, 1513
valid**, only the latest (2026-06-04) has no `chf`/`eur`.

`DFXPriceService.getPriceChart` did `BigInt.from(entry['chf'] * 100)`
for every entry, so the single null point threw (`null * 100`), the
whole method threw, and `priceChart` stayed `[]` → the entire history
(1513 valid points) was discarded and the chart rendered empty.
`getPriceOfAsset` threw on the same null.

This is the same defect class as the portfolio-chart fix in #694, but in
the **price** code path (`dfx_price_service.dart`), which #694 does not
touch. It is more visible because it affects the wallet-less public
price view.

## Fix

- `getPriceChart`: skip entries whose selected-currency price is `null`
instead of throwing — the chart renders the full history minus the
unpriced tail point.
- `getPriceOfAsset`: return `BigInt.zero` when the price is missing, so
the UI renders `--.--` instead of throwing.
- `getChfToEurRate`: treat a missing `chf`/`eur` as `0` (already guards
`chf > 0`).

## Tests

- `getPriceChart` skips a trailing null point and keeps earlier valued
points.
- `getPriceOfAsset` returns zero on a missing price.
- `getChfToEurRate` returns `0.0` on a missing price.
- `flutter analyze` clean; price-service + price-chart-cubit suites
green (22 tests).

## Test plan

- [ ] Open the app with no wallet while the quote endpoint omits
`chf`/`eur` → price chart renders full history, header `--.--`, no empty
chart.
- [ ] Normal case (all prices present) → unchanged.
- [ ] Current price endpoint without `chf`/`eur` → header `--.--`, no
crash.

## Related

- Sibling fix for the portfolio chart: #694 (same null-handling defect,
account endpoint).
#694)

## Problem

When the REALU quote is temporarily unavailable, the dashboard showed a
corrupt portfolio chart: the line crashed to **0** at the most recent
point and the performance box reported **-921.27 CHF | -100.00%**, even
though the header correctly rendered the total as `--.--`.

Root cause: the API returns `valueChf` / `valueEur` as `null` for points
it cannot price. `RealUnitAccountService.getPortfolioHistory` mapped
that `null` to `0` (`value ?? 0`). The latest history point (balance
still held, price `null`) was therefore plotted as a zero-value point,
dragging the chart to the x-axis and making the `last - first`
performance calc read `-100%`.

## Fix

- Skip history points whose value is `null` in the selected currency
instead of mapping them to `0`. The chart now ends at the last **known**
value and the change is computed against it; the header keeps showing
`--.--` (already handled).
- A genuine `0.0` value (balance held but worth zero) is preserved and
stays distinct from `null` (unknown).

Single change at the API boundary — no chart/cubit/widget changes
needed, since the false `0` originated in the service mapping.

## Tests

- Rewrote the obsolete `treats a null value as 0` test → `skips a point
whose value is null` (point is dropped).
- Added `keeps a genuine 0.0 value (distinct from null)`.
- Added `drops a trailing null point but keeps earlier valued points`.
- `flutter analyze` clean; dashboard + dfx service suites green (516
tests).

## Test plan

- [ ] Wallet with holdings while the quote endpoint returns `null` for
the latest point → chart stays at last known value, header `--.--`, no
`-100%`.
- [ ] Normal case with all values present → unchanged.
- [ ] Holding genuinely worth 0 → still renders 0, not dropped.
Promote: staging -> develop
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.

4 participants