Release: develop -> main#325
Open
github-actions[bot] wants to merge 269 commits into
Open
Conversation
) ## 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
## 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Automatic Release PR
This PR was automatically created after changes were pushed to develop.
Commits: 1 new commit(s)
Checklist