diff --git a/.github/workflows/handbook-build-check.yaml b/.github/workflows/handbook-build-check.yaml
index 7877d723..093997db 100644
--- a/.github/workflows/handbook-build-check.yaml
+++ b/.github/workflows/handbook-build-check.yaml
@@ -23,12 +23,20 @@ on:
- "scripts/assemble-handbook-screenshots.sh"
- "scripts/assemble-handbook-store-listing.py"
- "scripts/templates/store-listing.html.tmpl"
+ - "scripts/assemble-handbook-legal.py"
+ - "scripts/build-legal-downloads.sh"
+ - "scripts/templates/legal-downloads.html.tmpl"
- "test/goldens/**"
# Store-listing section is a derived export of the Fastlane metadata —
# changing it must re-run the generator and re-commit the handbook.
- "ios/fastlane/metadata/**"
- "ios/fastlane/screenshots/**"
- "android/fastlane/metadata/**"
+ # Legal-downloads section is a derived export of the in-app legal Markdown
+ # (and the ARB titles) — changing either must re-run the generator and
+ # re-commit the handbook HTML block.
+ - "assets/legal/**"
+ - "assets/languages/**"
- "Dockerfile.handbook"
- "handbook.nginx.conf"
- "handbook.htpasswd"
@@ -99,6 +107,23 @@ jobs:
exit 1
fi
+ - name: Verify handbook legal-downloads section is in sync with assets/legal
+ # The legal-downloads block in docs/handbook/de/index.html is a derived
+ # export of assets/legal/*.md + the ARB titles. Re-run the (pure-stdlib,
+ # deterministic) generator; if the working tree changes, the committed
+ # handbook is stale — someone edited a legal .md or its ARB title without
+ # re-running the generator. Only the HTML block is gated here; the
+ # pandoc-produced PDF/DOCX are non-deterministic and intentionally
+ # git-ignored, so build-legal-downloads.sh is NOT run in this gate.
+ run: |
+ set -euo pipefail
+ python3 scripts/assemble-handbook-legal.py /tmp/legal-out
+ if ! git diff --quiet docs/handbook/de/index.html; then
+ echo "::error::docs/handbook/de/index.html legal-downloads block is stale — re-run scripts/assemble-handbook-legal.py and commit."
+ git diff docs/handbook/de/index.html
+ exit 1
+ fi
+
- name: Build handbook image (no push)
run: docker build -f Dockerfile.handbook -t realunit-handbook:pr-check .
@@ -139,4 +164,16 @@ jobs:
fi
done
+ # Legal downloads must be present (built by the legal-docs-builder
+ # stage via pandoc). Same 200/401-vs-404 logic: 404 means the PDF/DOCX
+ # was not produced. Covers PDF + DOCX and both languages.
+ for f in legal/privacy_policy_de.pdf legal/privacy_policy_de.docx legal/terms_of_use_en.pdf legal/registration_agreement_de.docx; do
+ code=$(curl -s -o /dev/null -w '%{http_code}' -u "${HANDBOOK_USER:-x}:${HANDBOOK_PASS:-x}" "http://127.0.0.1:8080/${f}")
+ if [ "$code" = "404" ]; then
+ echo "legal download ${f} missing from /usr/share/nginx/html/legal/" >&2
+ docker logs handbook
+ exit 1
+ fi
+ done
+
docker stop handbook
diff --git a/.gitignore b/.gitignore
index a8b8014a..ce510890 100644
--- a/.gitignore
+++ b/.gitignore
@@ -66,6 +66,14 @@ docs/handbook/mails/
# directory is only populated transiently for local previews.
docs/handbook/screenshots/
+# Built at handbook build time from assets/legal/*.md via pandoc
+# (scripts/build-legal-downloads.sh, see Dockerfile.handbook). The PDF/DOCX are
+# NON-deterministic (pandoc embeds timestamps/metadata), so — unlike the
+# committed legal-downloads HTML block in de/index.html — they are never
+# committed and never sync-gated; they exist only inside the image. This
+# directory holds the assembly output for local previews only.
+docs/handbook/legal/
+
# Scratch directories produced when reproducing the handbook CI's
# mail-preview generation locally (see docs/handbook/README.md → "E-Mail
# Previews → Lokal regenerieren"). Mirrors the names the CI uses so a
diff --git a/Dockerfile.handbook b/Dockerfile.handbook
index d2343c78..49abbf12 100644
--- a/Dockerfile.handbook
+++ b/Dockerfile.handbook
@@ -53,6 +53,32 @@ COPY android/fastlane/metadata/ ./android/fastlane/metadata/
COPY docs/handbook/de/index.html ./docs/handbook/de/index.html
RUN python3 ./scripts/assemble-handbook-store-listing.py /out && cp ./docs/handbook/de/index.html /out/index.html
+# Legal-downloads section: derived export of the in-app legal Markdown
+# (assets/legal/*.md). Two separate concerns, by design:
+# - assemble-handbook-legal.py rewrites the deterministic block in index.html (committed + sync-gated upstream).
+# - build-legal-downloads.sh renders the NON-deterministic PDF/DOCX via pandoc
+# (weasyprint PDF engine — no TeX), git-ignored like the screenshots.
+# This stage takes the store-listing-rewritten index.html as input so BOTH the
+# store-listing and the legal-downloads blocks survive into the final image.
+FROM alpine:3.20 AS legal-docs-builder
+WORKDIR /work
+# font-dejavu (+ a rebuilt fontconfig cache) is required: weasyprint renders via
+# Pango, which aborts ("No fonts configured in FontConfig" → "Error producing
+# PDF") on a bare Alpine that ships no font files. DejaVu covers the Latin/German
+# glyphs the legal texts use.
+RUN apk add --no-cache python3 pandoc weasyprint bash font-dejavu \
+ && fc-cache -f
+COPY scripts/assemble-handbook-legal.py ./scripts/
+COPY scripts/build-legal-downloads.sh ./scripts/
+COPY scripts/templates/legal-downloads.html.tmpl ./scripts/templates/
+COPY assets/legal/ ./assets/legal/
+COPY assets/languages/ ./assets/languages/
+COPY --from=store-listing-builder /out/index.html ./docs/handbook/de/index.html
+RUN python3 ./scripts/assemble-handbook-legal.py /out \
+ && bash ./scripts/build-legal-downloads.sh /out \
+ && cp ./docs/handbook/de/index.html /out/index.html
+
FROM nginx:1.27.5-alpine
COPY docs/handbook/ /usr/share/nginx/html/
@@ -62,6 +88,11 @@ COPY --from=screenshots-builder /out/ /usr/share/nginx/html/screenshots/
COPY --from=store-listing-builder /out/ios/ /usr/share/nginx/html/store/ios/
COPY --from=store-listing-builder /out/android/ /usr/share/nginx/html/store/android/
COPY --from=store-listing-builder /out/index.html /usr/share/nginx/html/de/index.html
+# Legal-downloads PDFs/DOCX + the index.html with BOTH the store-listing and the
+# legal-downloads blocks rewritten (this copy overwrites the store-listing one
+# above, so the legal stage's index.html is the authoritative final version).
+COPY --from=legal-docs-builder /out/legal/ /usr/share/nginx/html/legal/
+COPY --from=legal-docs-builder /out/index.html /usr/share/nginx/html/de/index.html
COPY handbook.nginx.conf /etc/nginx/conf.d/default.conf
COPY handbook.htpasswd /etc/nginx/handbook.htpasswd
diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb
index 3246a214..e3f21595 100644
--- a/assets/languages/strings_de.arb
+++ b/assets/languages/strings_de.arb
@@ -28,7 +28,7 @@
"buyExecutedReference": "Ihre Referenz",
"buyExecutedTitle": "Vielen Dank.",
"buyMinAmount": "Mindestbetrag: ${amount} ${currency}",
- "buyPaymentConfirm": "Klicken Sie hier, sobald Sie die Überweisung getätigt haben",
+ "buyPaymentConfirm": "Zahlungsanweisungen per E-Mail anfordern",
"buyPaymentConfirmFailed": "Es gibt ein technisches Problem. Bitte versuchen Sie es später erneut. Falls der Fehler weiterhin besteht, kontaktieren Sie unseren Support.",
"buyPaymentConfirmFailedAktionariat": "Es gibt ein technisches Problem. Bitte überprüfen Sie Ihr E-Mail-Postfach, möglicherweise fehlt noch eine Bestätigung Ihrer Blockchain-Adresse. Andernfalls versuchen Sie es später erneut. Falls der Fehler weiterhin besteht, kontaktieren Sie unseren Support.",
"buyPaymentInformation": "Zahlungsinformationen",
@@ -148,6 +148,8 @@
"legalDisclaimerTitle": "Wichtige rechtliche Hinweise für Investoren & Bestätigung des Wohnsitzes",
"legalDisclaimerTitle2": "Weitere rechtliche Hinweise",
"legalDisclaimerYes": "Zustimmen",
+ "legalDocumentLoadFailed": "Dokument konnte nicht geladen werden",
+ "legalDocumentLoadFailedDescription": "Beim Laden des Dokuments ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"legalDocuments": "Rechtsdokumente",
"location": "Ort",
"logout": "Abmelden",
diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb
index 4b92637c..56e4db7f 100644
--- a/assets/languages/strings_en.arb
+++ b/assets/languages/strings_en.arb
@@ -28,7 +28,7 @@
"buyExecutedReference": "Your reference",
"buyExecutedTitle": "Thank you.",
"buyMinAmount": "Minimum amount: ${amount} ${currency}",
- "buyPaymentConfirm": "Click here once you have made the transfer",
+ "buyPaymentConfirm": "Request payment instructions by email",
"buyPaymentConfirmFailed": "There is a technical problem. Please try again later. If the error persists, contact our support team.",
"buyPaymentConfirmFailedAktionariat": "There is a technical problem. Please check your email inbox — you may still need to confirm your blockchain address. Otherwise, please try again later. If the error persists, contact our support team.",
"buyPaymentInformation": "Payment information",
@@ -148,6 +148,8 @@
"legalDisclaimerTitle": "Important legal notices for investors & confirmation of residence",
"legalDisclaimerTitle2": "Further legal notices",
"legalDisclaimerYes": "Agree",
+ "legalDocumentLoadFailed": "Could not load the document",
+ "legalDocumentLoadFailedDescription": "Something went wrong while loading this document. Please try again.",
"legalDocuments": "Legal documents",
"location": "Location",
"logout": "Logout",
diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html
index f74f68c7..021f2748 100644
--- a/docs/handbook/de/index.html
+++ b/docs/handbook/de/index.html
@@ -608,6 +608,40 @@
.store-screenshots .src {
font-size: 0.85em;
}
+ /* Legal-downloads section (#spec-legal-downloads) — see
+ scripts/assemble-handbook-legal.py. Reuses the .test card chrome
+ (border + :target highlight) so each (document, language) entry has the
+ same direct-link affordance as the screenshot cards. */
+ .test.legal-doc .downloads {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 14px;
+ }
+ .test.legal-doc .doc-title {
+ flex-basis: 100%;
+ font-size: 13px;
+ color: var(--ink-2);
+ }
+ a.dl-btn {
+ text-decoration: none;
+ font-size: 12.5px;
+ font-weight: 600;
+ color: var(--brand);
+ background: var(--surface-2);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 5px 12px;
+ transition:
+ background 0.15s,
+ border-color 0.15s,
+ color 0.15s;
+ }
+ a.dl-btn:hover {
+ background: var(--surface);
+ border-color: var(--brand);
+ }
+ Single source of truth für die App-eigenen Rechtstexte sind die Markdown-Dateien
+ unter assets/legal/*.md. Die In-App-Ansicht (LegalDocumentPage)
+ und die Downloads hier rendern aus denselben Dateien; jede Änderung am Text geht
+ ausschliesslich über einen PR auf diese Markdown-Quellen. Pro Dokument und Sprache
+ gibt es einen PDF- und DOCX-Download (im Handbook-Image via
+ pandoc erzeugt) sowie einen Direkt-Link (🔗) auf den jeweiligen Eintrag
+ und einen ↗-Link auf die Markdown-Quelldatei.
+ DFX- und Aktionariat-Dokumente sowie die extern gehosteten Unternehmensdokumente
+ (Prospekte, Statuten) sind nicht Teil dieses Exports — sie haben keine
+ Markdown-Quelle im Repo.
+
+
+
diff --git a/handbook.nginx.conf b/handbook.nginx.conf
index 8d208f49..3cfa0332 100644
--- a/handbook.nginx.conf
+++ b/handbook.nginx.conf
@@ -78,6 +78,22 @@ server {
return 302 /de/;
}
+ # Legal downloads (/legal/*.pdf, /legal/*.docx) — the derived PDF/DOCX export
+ # of assets/legal/*.md. Force a download disposition (nginx's default
+ # mime.types has no .docx mapping; it would otherwise serve as
+ # application/octet-stream, which downloads but without an explicit
+ # filename). add_header is all-or-nothing per location, so the server-level
+ # security headers are restated here. auth_basic is inherited — these stay
+ # behind the same Basic-Auth gate as the rest of the handbook.
+ location ~* \.(pdf|docx)$ {
+ add_header Content-Disposition "attachment" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header Cache-Control "private, no-store" always;
+ try_files $uri =404;
+ }
+
location / {
try_files $uri $uri/ =404;
}
diff --git a/lib/packages/repository/wallet_repository.dart b/lib/packages/repository/wallet_repository.dart
index 13b9d582..e2ffbce7 100644
--- a/lib/packages/repository/wallet_repository.dart
+++ b/lib/packages/repository/wallet_repository.dart
@@ -44,6 +44,14 @@ class WalletRepository {
Future deleteWallet(int id) => _appDatabase.deleteWallet(id);
+ /// Full purge for the user-facing delete: removes the encrypted-seed row AND
+ /// the AES-GCM mnemonic key, so no recoverable seed material remains on
+ /// device.
+ Future purgeWallet(int id) async {
+ await _appDatabase.deleteWalletCompletely(id);
+ await _secureStorage.deleteMnemonicKey();
+ }
+
Future _decryptWalletInfo(WalletInfo info) async {
final key = await _secureStorage.getOrCreateMnemonicKey();
final decryptedSeed = SecureStorage.decryptSeed(key, info.seed);
diff --git a/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart b/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart
index e6ec146f..87158095 100644
--- a/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart
+++ b/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart
@@ -113,8 +113,8 @@ class RealUnitSellPaymentInfoService extends DFXAuthService {
chainId: paymentInfo.eip7702.domain.chainId,
address: paymentInfo.eip7702.delegatorAddress,
nonce: paymentInfo.eip7702.userNonce,
- r: '0x${authorizationSignature.r.toRadixString(16)}',
- s: '0x${authorizationSignature.s.toRadixString(16)}',
+ r: '0x${authorizationSignature.r.toRadixString(16).padLeft(64, '0')}',
+ s: '0x${authorizationSignature.s.toRadixString(16).padLeft(64, '0')}',
yParity: authorizationSignature.yParity,
),
),
diff --git a/lib/packages/service/wallet_service.dart b/lib/packages/service/wallet_service.dart
index 02322e93..a0a0fa70 100644
--- a/lib/packages/service/wallet_service.dart
+++ b/lib/packages/service/wallet_service.dart
@@ -274,7 +274,9 @@ class WalletService {
Future deleteCurrentWallet() async {
final id = _settingsRepository.currentWalletId!;
- await _repository.deleteWallet(id);
+ // Full purge (seed row + mnemonic key), not an account-only delete — so no
+ // recoverable seed survives delete.
+ await _repository.purgeWallet(id);
await _settingsRepository.removeCurrentWalletId();
}
diff --git a/lib/packages/storage/secure_storage.dart b/lib/packages/storage/secure_storage.dart
index 8472bae4..55c78b27 100644
--- a/lib/packages/storage/secure_storage.dart
+++ b/lib/packages/storage/secure_storage.dart
@@ -165,6 +165,14 @@ class SecureStorage {
return key;
}
+ /// Removes the AES-GCM key that decrypts stored seeds. Once gone, any
+ /// surviving encrypted seed is permanently undecryptable; a fresh key is
+ /// lazily minted on next creation.
+ // @no-integration-test: forwards to FlutterSecureStorage (Android Keystore /
+ // iOS Keychain) over a platform channel; real keystore removal is only
+ // verifiable on-device — the unit test mocks the plugin.
+ Future deleteMnemonicKey() => _secureStorage.delete(key: _mnemonicEncryptionKey);
+
static String encryptSeed(Uint8List key, String plaintext) {
final iv = _secureRandomBytes(12);
final cipher = GCMBlockCipher(AESEngine())
diff --git a/lib/packages/storage/wallet_storage.dart b/lib/packages/storage/wallet_storage.dart
index 5eb8c83a..81317d76 100644
--- a/lib/packages/storage/wallet_storage.dart
+++ b/lib/packages/storage/wallet_storage.dart
@@ -28,6 +28,14 @@ extension WalletStorage on AppDatabase {
Future deleteWallet(int walletId) =>
(delete(walletAccountInfos)..where((row) => row.wallet.equals(walletId))).go();
+ /// Deletes the `walletInfos` row itself (the encrypted-seed record) after
+ /// clearing its dependent `walletAccountInfos` rows (FK in
+ /// [WalletAccountInfos.wallet]).
+ Future deleteWalletCompletely(int walletId) => transaction(() async {
+ await (delete(walletAccountInfos)..where((row) => row.wallet.equals(walletId))).go();
+ await (delete(walletInfos)..where((row) => row.id.equals(walletId))).go();
+ });
+
Future get hasWallet => select(walletInfos).get().then((result) => result.isNotEmpty);
}
diff --git a/lib/screens/kyc/kyc_page_manager.dart b/lib/screens/kyc/kyc_page_manager.dart
index 66a2d6f6..93694a3f 100644
--- a/lib/screens/kyc/kyc_page_manager.dart
+++ b/lib/screens/kyc/kyc_page_manager.dart
@@ -76,7 +76,11 @@ class KycViewManager extends StatelessWidget {
KycStep.twoFa => const Kyc2FaPage(),
KycStep.ident => KycIdentPage(accessToken: urlOrToken ?? ''),
KycStep.financialData => KycFinancialDataPage(url: urlOrToken ?? ''),
- (_) => const Scaffold(),
+ // Exhaustive over KycStep so a new value is a compile error here
+ // (forced handling) rather than a silent blank Scaffold. dfxApproval
+ // was the missing case that fell through to the old blank fallback.
+ KycStep.dfxApproval =>
+ const KycPendingPage(pendingStep: KycStep.dfxApproval),
},
KycState() => const Scaffold(),
},
diff --git a/lib/screens/legal/subpages/legal_document_page.dart b/lib/screens/legal/subpages/legal_document_page.dart
index a4f71b40..4994b730 100644
--- a/lib/screens/legal/subpages/legal_document_page.dart
+++ b/lib/screens/legal/subpages/legal_document_page.dart
@@ -1,3 +1,5 @@
+import 'dart:developer' as developer;
+
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -7,6 +9,7 @@ import 'package:realunit_wallet/generated/i18n.dart';
import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart';
import 'package:realunit_wallet/screens/web_view/web_view_page.dart';
import 'package:realunit_wallet/setup/routing/routes/app_routes.dart';
+import 'package:realunit_wallet/styles/colors.dart';
import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart';
class LegalDocumentParams {
@@ -41,6 +44,7 @@ class LegalDocumentPage extends StatefulWidget {
class _LegalDocumentPageState extends State {
String? _markdownContent;
+ bool _loadFailed = false;
@override
void initState() {
@@ -54,16 +58,32 @@ class _LegalDocumentPageState extends State {
Future _loadMarkdown() async {
final code = context.read().state.language.code;
+ final assetPath = 'assets/legal/${widget.params.assetBaseName}_$code.md';
try {
- final content = await rootBundle.loadString(
- 'assets/legal/${widget.params.assetBaseName}_$code.md',
- );
+ // cache: false so Retry after a transient failure actually re-reads the
+ // asset instead of replaying rootBundle's cached error for this key.
+ final content = await rootBundle.loadString(assetPath, cache: false);
if (mounted) setState(() => _markdownContent = content);
- } catch (_) {
- if (mounted) setState(() => _markdownContent = '');
+ } catch (e, stackTrace) {
+ developer.log(
+ 'Failed to load legal document "${widget.params.assetBaseName}"',
+ name: 'realunit_wallet.legal',
+ error: e,
+ stackTrace: stackTrace,
+ level: 1000, // SEVERE
+ );
+ if (mounted) setState(() => _loadFailed = true);
}
}
+ void _retryLoad() {
+ setState(() {
+ _loadFailed = false;
+ _markdownContent = null;
+ });
+ _loadMarkdown();
+ }
+
String? get _pdfUrl {
if (widget.params.pdfUrls == null) return null;
final code = context.read().state.language.code;
@@ -75,7 +95,9 @@ class _LegalDocumentPageState extends State {
appBar: AppBar(
title: Text(widget.params.title),
),
- body: _markdownContent != null
+ body: _loadFailed
+ ? _buildError(context)
+ : _markdownContent != null
? Column(
children: [
Expanded(
@@ -118,6 +140,46 @@ class _LegalDocumentPageState extends State {
),
],
)
+ // Documents load from a bundled asset (effectively synchronous), so the
+ // brief null frame stays blank rather than flashing a spinner.
: const SizedBox.shrink(),
);
+
+ // Mirrors the canonical full-page error view in
+ // settings_currencies_page.dart (`_ErrorView`); keep the two in sync.
+ Widget _buildError(BuildContext context) => Center(
+ key: const ValueKey('legalDocumentLoadError'),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ const Icon(
+ Icons.error_outline,
+ size: 48,
+ color: RealUnitColors.neutral500,
+ ),
+ const SizedBox(height: 12),
+ Text(
+ S.of(context).legalDocumentLoadFailed,
+ style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ S.of(context).legalDocumentLoadFailedDescription,
+ style: const TextStyle(color: RealUnitColors.neutral500),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 16),
+ OutlinedButton(
+ key: const ValueKey('legalDocumentRetryButton'),
+ onPressed: _retryLoad,
+ child: Text(S.of(context).retry),
+ ),
+ ],
+ ),
+ ),
+ );
}
diff --git a/scripts/assemble-handbook-legal.py b/scripts/assemble-handbook-legal.py
new file mode 100755
index 00000000..471ad7d3
--- /dev/null
+++ b/scripts/assemble-handbook-legal.py
@@ -0,0 +1,218 @@
+#!/usr/bin/env python3
+"""Generate the handbook legal-downloads block from the in-app legal Markdown.
+
+The Markdown under assets/legal/ is the single source of truth for the three
+RealUnit documents that the app renders in-app (LegalDocumentPage reads
+assets/legal/_.md via rootBundle). The handbook section at
+handbook.realunit.app is a *derived export* of those files — never hand-edited.
+This mirrors the upstream/downstream relationship that the store-listing and
+mails/ sections already have.
+
+Usage:
+ scripts/assemble-handbook-legal.py
+
+Used by:
+ - Dockerfile.handbook (legal-docs-builder stage → rewrites docs/handbook/de/
+ index.html; the PDF/DOCX binaries are produced separately by
+ scripts/build-legal-downloads.sh — see the determinism note below)
+ - .github/workflows/handbook-build-check.yaml (sync gate: re-run + git diff)
+ - local previews (run it, then open docs/handbook/de/index.html)
+
+What it does:
+ 1. Discovers the document set: for each base in BASES, globs
+ assets/legal/_*.md to find the available languages. Errors out if a
+ base has zero languages.
+ 2. Resolves each document's title from assets/languages/strings_.arb
+ (the mapped ARB key per base, see TITLE_KEYS) so the handbook titles stay
+ in lockstep with the in-app titles. Falls back to the `de` title if a
+ language's ARB lacks the key.
+ 3. Renders scripts/templates/legal-downloads.html.tmpl into
+ /legal-downloads.html and substitutes the rendered block between the
+ / markers in
+ docs/handbook/de/index.html in place (idempotent).
+
+What it deliberately does NOT do:
+ - It does NOT invoke pandoc and does NOT emit any PDF/DOCX. Those binaries are
+ non-deterministic (embedded timestamps, tool-version metadata) and are
+ produced only inside the image by scripts/build-legal-downloads.sh, git-
+ ignored like the screenshots. Keeping pandoc out of this script is what lets
+ the rendered HTML block be deterministic and therefore sync-gateable.
+
+ Every value interpolated into the HTML (titles from ARB, discovered language
+ codes) is HTML-escaped; the document bases are a fixed allowlist (BASES).
+"""
+import html
+import re
+import sys
+from pathlib import Path
+
+BEGIN = ""
+END = ""
+
+# The exact three in-app documents rendered from repo-local Markdown. DFX,
+# Aktionariat and the externally-hosted corporate PDFs are out of scope — they
+# have no Markdown source in the repo and cannot be a derived export.
+BASES = ["privacy_policy", "terms_of_use", "registration_agreement"]
+
+# Maps each document base to the ARB key the app uses for its title, so the
+# handbook label matches what the user sees in-app.
+TITLE_KEYS = {
+ "privacy_policy": "legalDisclaimerCheckboxPrivacyPolicy",
+ "terms_of_use": "termsOfUse",
+ "registration_agreement": "legalDisclaimerCheckboxRegistrationAgreement",
+}
+
+# Languages are discovered, never hardcoded; this only validates that a token
+# extracted from a filename looks like a language code before it is used in an
+# id / href / download path.
+_LANG_RE = re.compile(r"^[a-z0-9-]+$")
+_PLACEHOLDER = re.compile(r"\{\{ (\w+) \}\}")
+
+REPO = "https://github.com/RealUnitCH/app"
+
+
+def _esc(value: str) -> str:
+ """HTML-escape a value for safe interpolation into text or an attribute."""
+ return html.escape(value, quote=True)
+
+
+def load_arb(repo: Path, lang: str) -> dict:
+ """Load assets/languages/strings_.arb (JSON). Errors if missing."""
+ import json
+
+ path = repo / "assets/languages" / f"strings_{lang}.arb"
+ if not path.is_file():
+ raise SystemExit(f"error: ARB file missing for discovered language '{lang}': {path}")
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def discover(repo: Path) -> "dict[str, list[str]]":
+ """For each base, glob assets/legal/_*.md → sorted language list."""
+ legal = repo / "assets/legal"
+ result = {}
+ for base in BASES:
+ langs = []
+ for md in legal.glob(f"{base}_*.md"):
+ lang = md.name[len(base) + 1 : -len(".md")]
+ if not _LANG_RE.match(lang):
+ raise SystemExit(f"error: unexpected language token '{lang}' from {md.name}")
+ langs.append(lang)
+ if not langs:
+ raise SystemExit(f"error: no source Markdown found for '{base}' (assets/legal/{base}_*.md)")
+ result[base] = sorted(langs)
+ return result
+
+
+def render_rows(doc_langs: "dict[str, list[str]]", titles: "dict[str, dict[str, str]]") -> str:
+ """Build the per-(base, lang) download cards. Deterministic ordering:
+ BASES order, then languages sorted alphabetically."""
+ parts = []
+ for base in BASES:
+ de_title = _esc(titles[base]["de"])
+ parts.append(
+ f'
')
+ for lang in doc_langs[base]:
+ anchor = f"legal-{base}-{lang}"
+ stem = f"{base}_{lang}"
+ title = _esc(titles[base][lang])
+ lang_e = _esc(lang)
+ parts.append(
+ f'
\n'
+ f'
\n'
+ f' {_esc(stem)}\n'
+ f' \n'
+ f' {lang_e}\n'
+ f'
\n'
+ f'
\n'
+ f' {title}\n'
+ f' PDF\n'
+ f' DOCX\n'
+ f' Markdown ↗\n'
+ f'
\n'
+ f'
'
+ )
+ parts.append('
')
+ return "\n".join(parts)
+
+
+def main() -> int:
+ if len(sys.argv) != 2:
+ print(f"usage: {sys.argv[0]} ", file=sys.stderr)
+ return 2
+
+ repo = Path(__file__).resolve().parent.parent
+ out = Path(sys.argv[1])
+
+ # 1) Discover documents + languages from the filesystem (never hardcoded).
+ doc_langs = discover(repo)
+
+ # 2) Resolve titles from the ARB files, falling back to `de`. Every document
+ # must have a `de` source, since `de` is the per-document title and
+ # heading fallback language (render_rows() uses titles[base]["de"]).
+ for base in BASES:
+ if "de" not in doc_langs[base]:
+ raise SystemExit(
+ f"error: '{base}' has no `de` document (assets/legal/{base}_de.md) — "
+ "`de` is the per-document title/heading fallback language"
+ )
+ all_langs = sorted({lang for langs in doc_langs.values() for lang in langs})
+ arbs = {lang: load_arb(repo, lang) for lang in all_langs}
+ titles = {}
+ for base in BASES:
+ key = TITLE_KEYS[base]
+ de_title = arbs["de"].get(key)
+ if not de_title:
+ raise SystemExit(f"error: ARB key '{key}' (for '{base}') missing from strings_de.arb")
+ titles[base] = {}
+ for lang in doc_langs[base]:
+ titles[base][lang] = arbs[lang].get(key) or de_title
+
+ # 3) Render the block from the template (single pass: each placeholder
+ # resolved exactly once, a substituted value is never re-scanned).
+ template = Path(__file__).parent / "templates/legal-downloads.html.tmpl"
+ ctx = {"rows": render_rows(doc_langs, titles)}
+ unknown = []
+
+ def substitute(match):
+ key = match.group(1)
+ if key not in ctx:
+ unknown.append(key)
+ return match.group(0)
+ return ctx[key]
+
+ rendered = _PLACEHOLDER.sub(substitute, template.read_text(encoding="utf-8").strip("\n"))
+ if unknown:
+ print(f"error: unknown template placeholder(s): {sorted(set(unknown))}", file=sys.stderr)
+ return 1
+
+ out.mkdir(parents=True, exist_ok=True)
+ (out / "legal-downloads.html").write_text(rendered + "\n", encoding="utf-8")
+
+ # 4) Substitute the block in docs/handbook/de/index.html in place (idempotent).
+ index = repo / "docs/handbook/de/index.html"
+ content = index.read_text(encoding="utf-8")
+ if BEGIN not in content or END not in content:
+ print(
+ f"error: {index} is missing the {BEGIN} / {END} markers — add them once "
+ "(see issue #658) before running this script.",
+ file=sys.stderr,
+ )
+ return 1
+ b = content.index(BEGIN)
+ e = content.index(END) + len(END)
+ new = content[:b] + BEGIN + "\n" + rendered + "\n" + END + content[e:]
+ index.write_text(new, encoding="utf-8")
+
+ n = sum(len(v) for v in doc_langs.values())
+ print(f"rendered legal-downloads block ({n} sources across {len(BASES)} docs) into {out}; synced {index}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/build-legal-downloads.sh b/scripts/build-legal-downloads.sh
new file mode 100755
index 00000000..1cd7a1e7
--- /dev/null
+++ b/scripts/build-legal-downloads.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+#
+# Build the downloadable PDF + DOCX of the in-app legal documents from their
+# Markdown sources under assets/legal/, into /legal/.
+#
+# Usage:
+# scripts/build-legal-downloads.sh
+#
+# Run ONLY inside the handbook image's legal-docs-builder stage (needs pandoc +
+# weasyprint). The output is intentionally NON-deterministic — pandoc embeds
+# timestamps and tool-version metadata — so it is treated like the assembled
+# screenshots: generated only in the image, git-ignored, never committed, and
+# never sync-gated. The deterministic HTML block (the download links) is the
+# separate concern of scripts/assemble-handbook-legal.py.
+#
+# weasyprint is used as the PDF engine (HTML/CSS based) deliberately, to avoid
+# pulling a full TeX Live into the image just for PDF rendering.
+#
+# Document discovery mirrors assemble-handbook-legal.py: the same three bases,
+# languages discovered by glob (never hardcoded), so a future assets/legal/
+# _.md is picked up automatically.
+set -euo pipefail
+
+if [ "$#" -ne 1 ]; then
+ echo "usage: $0 " >&2
+ exit 2
+fi
+
+out="$1"
+# Resolve the repo root from this script's location (scripts/..), so the script
+# works regardless of the caller's working directory.
+script_dir="$(cd "$(dirname "$0")" && pwd)"
+repo="$(cd "$script_dir/.." && pwd)"
+legal_src="$repo/assets/legal"
+legal_out="$out/legal"
+
+bases="privacy_policy terms_of_use registration_agreement"
+
+mkdir -p "$legal_out"
+
+count=0
+for base in $bases; do
+ found=0
+ for md in "$legal_src/$base"_*.md; do
+ # Guard against a literal no-match glob.
+ [ -e "$md" ] || continue
+ found=1
+ stem="$(basename "$md" .md)"
+ pandoc "$md" -o "$legal_out/$stem.docx"
+ pandoc "$md" --pdf-engine=weasyprint -o "$legal_out/$stem.pdf"
+ count=$((count + 2))
+ done
+ if [ "$found" -eq 0 ]; then
+ echo "error: no source Markdown found for '$base' ($legal_src/${base}_*.md)" >&2
+ exit 1
+ fi
+done
+
+echo "built $count legal download files into $legal_out"
diff --git a/scripts/templates/legal-downloads.html.tmpl b/scripts/templates/legal-downloads.html.tmpl
new file mode 100644
index 00000000..2e468421
--- /dev/null
+++ b/scripts/templates/legal-downloads.html.tmpl
@@ -0,0 +1,31 @@
+
+
+
+ Single source of truth für die App-eigenen Rechtstexte sind die Markdown-Dateien
+ unter assets/legal/*.md. Die In-App-Ansicht (LegalDocumentPage)
+ und die Downloads hier rendern aus denselben Dateien; jede Änderung am Text geht
+ ausschliesslich über einen PR auf diese Markdown-Quellen. Pro Dokument und Sprache
+ gibt es einen PDF- und DOCX-Download (im Handbook-Image via
+ pandoc erzeugt) sowie einen Direkt-Link (🔗) auf den jeweiligen Eintrag
+ und einen ↗-Link auf die Markdown-Quelldatei.
+ DFX- und Aktionariat-Dokumente sowie die extern gehosteten Unternehmensdokumente
+ (Prospekte, Statuten) sind nicht Teil dieses Exports — sie haben keine
+ Markdown-Quelle im Repo.
+
+
+{{ rows }}
+
diff --git a/test/goldens/screens/home/goldens/macos/home_page_loaded.png b/test/goldens/screens/home/goldens/macos/home_page_loaded.png
index 4dd0c568..84de9124 100644
Binary files a/test/goldens/screens/home/goldens/macos/home_page_loaded.png and b/test/goldens/screens/home/goldens/macos/home_page_loaded.png differ
diff --git a/test/goldens/screens/legal/legal_document_golden_test.dart b/test/goldens/screens/legal/legal_document_golden_test.dart
index b48d8a7b..1d7f5841 100644
--- a/test/goldens/screens/legal/legal_document_golden_test.dart
+++ b/test/goldens/screens/legal/legal_document_golden_test.dart
@@ -27,11 +27,15 @@ void main() {
);
group('$LegalDocumentPage', () {
+ // Empty-content placeholder: renders the empty Markdown body before any
+ // document text is present. Passing an empty string via the
+ // @visibleForTesting hook keeps this deterministic (no async asset load,
+ // so it never races into the new load-error state).
goldenTest(
- 'initial state before markdown loads',
+ 'empty document placeholder',
fileName: 'legal_document_page_default',
constraints: const BoxConstraints.tightFor(width: 390, height: 844),
- builder: () => wrapForGolden(buildSubject()),
+ builder: () => wrapForGolden(buildSubject(initialMarkdownContent: '')),
);
// The page reads its markdown from rootBundle in production; for the
diff --git a/test/packages/repository/wallet_repository_test.dart b/test/packages/repository/wallet_repository_test.dart
index f049bca4..b9ab2ded 100644
--- a/test/packages/repository/wallet_repository_test.dart
+++ b/test/packages/repository/wallet_repository_test.dart
@@ -145,5 +145,34 @@ void main() {
final afterAccounts = await db.getWalletAccounts(walletId);
expect(afterAccounts, isEmpty);
});
+
+ test('purgeWallet removes the walletInfos seed row AND the mnemonic key', () async {
+ // The user-facing delete must leave no recoverable seed material —
+ // neither the encrypted row nor the AES key.
+ when(() => secureStorage.deleteMnemonicKey()).thenAnswer((_) async {});
+
+ final walletId = await repo.createWallet(walletName, WalletType.software, seed, address);
+ await db.insertWalletAccount(walletId, 'acc-0', 0);
+ expect(await db.getWalletById(walletId), isNotNull);
+
+ await repo.purgeWallet(walletId);
+
+ expect(await db.getWalletById(walletId), isNull); // encrypted seed row gone
+ expect(await db.getWalletAccounts(walletId), isEmpty); // accounts gone
+ verify(() => secureStorage.deleteMnemonicKey()).called(1); // AES key removed
+ });
+
+ test('deleteWallet (account-only) leaves the seed row and mnemonic key intact', () async {
+ // Onboarding-regenerate contract: the account-only primitive must NOT
+ // wipe the seed row or the AES key.
+ final walletId = await repo.createWallet(walletName, WalletType.software, seed, address);
+ await db.insertWalletAccount(walletId, 'acc-0', 0);
+
+ await repo.deleteWallet(walletId);
+
+ expect(await db.getWalletById(walletId), isNotNull); // row survives
+ expect(await db.getWalletAccounts(walletId), isEmpty); // accounts gone
+ verifyNever(() => secureStorage.deleteMnemonicKey()); // key untouched
+ });
});
}
diff --git a/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart b/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart
index aacbe635..55695662 100644
--- a/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart
+++ b/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart
@@ -174,4 +174,59 @@ void main() {
);
});
});
+
+ // The EIP-7702 authorization r/s components must always be serialized as full
+ // 32-byte (64 hex char) big-endian values. `BigInt.toRadixString(16)` drops
+ // leading zero bytes, so any signature whose r or s is < 2^248 used to emit
+ // < 64 hex chars (an invalid 32-byte field). The fix mirrors the BitBox
+ // reference with `.padLeft(64, '0')`. This sweep signs across many nonces so
+ // at least one deterministic signature lands on a leading-zero byte, and
+ // asserts every emitted r/s is exactly 64 hex chars.
+ group('confirmPayment EIP-7702 r/s 32-byte padding', () {
+ SellPaymentInfo infoForNonce(int nonce) {
+ final json = _validEip7702Json()..['userNonce'] = nonce;
+ return SellPaymentInfo(
+ id: 42,
+ eip7702: Eip7702Data.fromJson(json),
+ amount: 100,
+ exchangeRate: 1.0,
+ rate: 1.0,
+ beneficiary: const BeneficiaryDto(iban: 'CH...'),
+ estimatedAmount: 100.0,
+ currency: Currency.chf,
+ depositAddress: '0xdeposit',
+ tokenAddress: '0xtoken',
+ chainId: 1,
+ ethBalance: 0.1,
+ requiredGasEth: 0.001,
+ );
+ }
+
+ test('authorization r and s are always 0x + 64 hex chars across nonces',
+ () async {
+ for (var nonce = 0; nonce < 96; nonce++) {
+ Map? body;
+ final client = MockClient((request) async {
+ body = jsonDecode(request.body) as Map;
+ return http.Response('{}', 200);
+ });
+
+ await build(client).confirmPayment(infoForNonce(nonce));
+
+ final authorization = (body!['eip7702']
+ as Map)['authorization'] as Map;
+ final r = authorization['r'] as String;
+ final s = authorization['s'] as String;
+
+ expect(r.startsWith('0x'), isTrue);
+ expect(s.startsWith('0x'), isTrue);
+ expect(r.substring(2).length, 64,
+ reason: 'r must be a full 32-byte component (nonce=$nonce)');
+ expect(s.substring(2).length, 64,
+ reason: 's must be a full 32-byte component (nonce=$nonce)');
+ // Value is unchanged, only left-zero-padded.
+ expect(BigInt.parse(r.substring(2), radix: 16), isA());
+ }
+ });
+ });
}
diff --git a/test/packages/service/wallet_service_test.dart b/test/packages/service/wallet_service_test.dart
index 91ea64d9..24f19cfa 100644
--- a/test/packages/service/wallet_service_test.dart
+++ b/test/packages/service/wallet_service_test.dart
@@ -57,6 +57,7 @@ void main() {
when(() => settings.saveCurrentWalletId(any())).thenAnswer((_) async => true);
when(() => settings.removeCurrentWalletId()).thenAnswer((_) async => true);
when(() => repo.deleteWallet(any())).thenAnswer((_) async {});
+ when(() => repo.purgeWallet(any())).thenAnswer((_) async {});
when(() => repo.updateAddress(any(), any())).thenAnswer((_) async {});
});
@@ -440,7 +441,10 @@ void main() {
await service.deleteCurrentWallet();
- verify(() => repo.deleteWallet(8)).called(1);
+ // User-facing delete must fully purge (seed row + mnemonic key), not
+ // the account-only delete.
+ verify(() => repo.purgeWallet(8)).called(1);
+ verifyNever(() => repo.deleteWallet(any()));
verify(() => settings.removeCurrentWalletId()).called(1);
});
});
diff --git a/test/packages/storage/secure_storage_test.dart b/test/packages/storage/secure_storage_test.dart
index 19508ca6..96df3098 100644
--- a/test/packages/storage/secure_storage_test.dart
+++ b/test/packages/storage/secure_storage_test.dart
@@ -120,6 +120,12 @@ void main() {
verify(() => mockStorage.delete(key: 'pin.salt')).called(1);
});
+ test('deleteMnemonicKey deletes the mnemonic encryption key', () async {
+ await secureStorage.deleteMnemonicKey();
+
+ verify(() => mockStorage.delete(key: 'wallet.mnemonic.encryption.key')).called(1);
+ });
+
test('getPinSalt returns null when no salt is stored', () async {
when(
() => mockStorage.read(key: 'pin.salt'),
diff --git a/test/screens/kyc/kyc_page_manager_test.dart b/test/screens/kyc/kyc_page_manager_test.dart
new file mode 100644
index 00000000..cf2f56b2
--- /dev/null
+++ b/test/screens/kyc/kyc_page_manager_test.dart
@@ -0,0 +1,130 @@
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/packages/service/app_store.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_level_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_session_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_step_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/user/dto/user_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart';
+import 'package:realunit_wallet/packages/wallet/wallet.dart';
+import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart';
+import 'package:realunit_wallet/screens/kyc/kyc_page_manager.dart';
+import 'package:realunit_wallet/screens/kyc/subpages/kyc_pending_page.dart';
+
+import '../../helper/helper.dart';
+
+class _MockDfxKycService extends Mock implements DfxKycService {}
+
+class _MockRealUnitRegistrationService extends Mock
+ implements RealUnitRegistrationService {}
+
+class _MockAppStore extends Mock implements AppStore {}
+
+class _MockAWallet extends Mock implements AWallet {}
+
+UserKycDto _kycHeader({KycLevel level = KycLevel.level0}) =>
+ UserKycDto(hash: 'h', level: level, dataComplete: false);
+
+UserDto _user({String? mail = 'test@example.com'}) =>
+ UserDto(mail: mail, kyc: _kycHeader());
+
+KycLevelDto _kycStatus({
+ required KycLevel level,
+ List steps = const [],
+ KycProcessStatus processStatus = KycProcessStatus.inProgress,
+}) => KycLevelDto(kycLevel: level, kycSteps: steps, processStatus: processStatus);
+
+KycSessionDto _session({
+ required KycLevel level,
+ required List steps,
+ KycStepSessionDto? currentStep,
+ KycProcessStatus processStatus = KycProcessStatus.inProgress,
+}) => KycSessionDto(
+ kycLevel: level,
+ kycSteps: steps,
+ currentStep: currentStep,
+ processStatus: processStatus,
+);
+
+KycStepSessionDto _currentStep(
+ KycStepName name, {
+ String url = 'https://example.com/session',
+ UrlType urlType = UrlType.browser,
+ KycStepStatus status = KycStepStatus.inProgress,
+}) => KycStepSessionDto(
+ session: KycSessionInfoDto(url: url, type: urlType),
+ name: name,
+ status: status,
+ sequenceNumber: 0,
+ isCurrent: true,
+);
+
+void main() {
+ late _MockDfxKycService kycService;
+ late _MockRealUnitRegistrationService registrationService;
+ late _MockAppStore appStore;
+ late _MockAWallet wallet;
+
+ setUp(() {
+ kycService = _MockDfxKycService();
+ registrationService = _MockRealUnitRegistrationService();
+ appStore = _MockAppStore();
+ wallet = _MockAWallet();
+ when(() => appStore.wallet).thenReturn(wallet);
+ when(() => wallet.walletType).thenReturn(WalletType.software);
+ when(() => registrationService.getRegistrationInfo()).thenAnswer(
+ (_) async => RealUnitRegistrationInfoDto(
+ state: RealUnitRegistrationState.alreadyRegistered,
+ ),
+ );
+ });
+
+ // An in-progress `dfxApproval` step used to land on a blank white Scaffold
+ // (the `(_) => const Scaffold()` fallback in KycViewManager). It must render
+ // the existing pending page instead.
+ testWidgets(
+ 'KycSuccess(dfxApproval) renders KycPendingPage, not a blank Scaffold',
+ (tester) async {
+ when(() => kycService.getKycStatus()).thenAnswer(
+ (_) async => _kycStatus(
+ level: KycLevel.level50,
+ processStatus: KycProcessStatus.inProgress,
+ ),
+ );
+ when(() => kycService.getUser()).thenAnswer((_) async => _user());
+ when(() => kycService.continueKyc()).thenAnswer(
+ (_) async => _session(
+ level: KycLevel.level50,
+ steps: const [],
+ currentStep: _currentStep(KycStepName.dfxApproval),
+ ),
+ );
+
+ final cubit = KycCubit(kycService, registrationService, appStore);
+ await tester.pumpApp(
+ BlocProvider.value(
+ value: cubit,
+ child: const KycViewManager(),
+ ),
+ );
+
+ cubit.markLegalDisclaimerAccepted();
+ await cubit.checkKyc();
+ await tester.pumpAndSettle();
+
+ // Sanity: the cubit really reached the bug-triggering state.
+ expect(cubit.state, isA());
+ expect((cubit.state as KycSuccess).currentStep, KycStep.dfxApproval);
+
+ // Regression assertion: the pending page renders (not a blank screen).
+ expect(find.byType(KycPendingPage), findsOneWidget);
+
+ await cubit.close();
+ },
+ );
+}
diff --git a/test/screens/legal/legal_document_page_test.dart b/test/screens/legal/legal_document_page_test.dart
new file mode 100644
index 00000000..0314e7e7
--- /dev/null
+++ b/test/screens/legal/legal_document_page_test.dart
@@ -0,0 +1,129 @@
+import 'dart:convert';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:realunit_wallet/generated/i18n.dart';
+import 'package:realunit_wallet/screens/legal/subpages/legal_document_page.dart';
+import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart';
+import 'package:realunit_wallet/styles/language.dart';
+
+import '../../helper/helper.dart';
+
+void main() {
+ late MockSettingsBloc settingsBloc;
+
+ setUp(() {
+ // The page loads its document from rootBundle, which caches results per
+ // asset path. Clear it between tests so a missing-asset load fails fresh
+ // each time instead of returning a sibling test's cached future.
+ rootBundle.clear();
+ settingsBloc = MockSettingsBloc();
+ when(() => settingsBloc.state).thenReturn(const SettingsState(language: Language.en));
+ });
+
+ Widget host({
+ String assetBaseName = 'terms',
+ String? initialMarkdownContent,
+ }) => BlocProvider.value(
+ value: settingsBloc,
+ child: LegalDocumentPage(
+ params: LegalDocumentParams(
+ title: 'Terms',
+ assetBaseName: assetBaseName,
+ ),
+ initialMarkdownContent: initialMarkdownContent,
+ ),
+ );
+
+ group('$LegalDocumentPage', () {
+ testWidgets(
+ 'shows an error with a retry action when the document asset fails to load '
+ '(regression for #19: silent blank page)',
+ (tester) async {
+ // No bundled asset for this base name -> rootBundle.loadString throws.
+ await tester.pumpApp(host(assetBaseName: 'missing_legal_asset_test'));
+ await tester.pumpAndSettle();
+
+ expect(
+ find.byKey(const ValueKey('legalDocumentLoadError')),
+ findsOneWidget,
+ reason: 'a failed asset load must surface an error state, not a blank page',
+ );
+ expect(
+ find.byKey(const ValueKey('legalDocumentRetryButton')),
+ findsOneWidget,
+ reason: 'the error state must offer a retry affordance',
+ );
+ expect(
+ find.byIcon(Icons.error_outline),
+ findsOneWidget,
+ reason: 'the error state should render the standard error icon',
+ );
+ expect(
+ find.text(S.current.legalDocumentLoadFailedDescription),
+ findsOneWidget,
+ reason: 'the error state should explain what went wrong',
+ );
+ },
+ );
+
+ testWidgets('renders the markdown body when content is provided', (tester) async {
+ await tester.pumpApp(host(initialMarkdownContent: '# Hello'));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(Markdown), findsOneWidget);
+ expect(find.byKey(const ValueKey('legalDocumentLoadError')), findsNothing);
+ });
+
+ testWidgets('retry re-runs the load and stays in the error state while the asset is missing', (
+ tester,
+ ) async {
+ await tester.pumpApp(host(assetBaseName: 'missing_legal_asset_test'));
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byKey(const ValueKey('legalDocumentRetryButton')));
+ await tester.pumpAndSettle();
+
+ // The asset is still missing, so we expect the error state to persist
+ // without throwing — exercising the retry path.
+ expect(find.byKey(const ValueKey('legalDocumentLoadError')), findsOneWidget);
+ });
+
+ testWidgets('retry recovers and renders the document once the asset loads', (
+ tester,
+ ) async {
+ const baseName = 'recoverable_legal_asset_test';
+ const assetKey = 'assets/legal/${baseName}_en.md';
+ var failNext = true;
+
+ // Mock the asset channel so the first load fails and, after retry, the
+ // second load succeeds — exercising the full error -> retry -> loaded path.
+ final messenger = tester.binding.defaultBinaryMessenger;
+ messenger.setMockMessageHandler('flutter/assets', (ByteData? message) async {
+ final key = utf8.decode(
+ message!.buffer.asUint8List(message.offsetInBytes, message.lengthInBytes),
+ );
+ if (key == assetKey && !failNext) {
+ return ByteData.sublistView(Uint8List.fromList(utf8.encode('# Recovered')));
+ }
+ return null; // missing asset -> loadString throws
+ });
+ addTearDown(() => messenger.setMockMessageHandler('flutter/assets', null));
+
+ await tester.pumpApp(host(assetBaseName: baseName));
+ await tester.pumpAndSettle();
+ expect(find.byKey(const ValueKey('legalDocumentLoadError')), findsOneWidget);
+
+ failNext = false;
+ await tester.tap(find.byKey(const ValueKey('legalDocumentRetryButton')));
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const ValueKey('legalDocumentLoadError')), findsNothing);
+ expect(find.byType(Markdown), findsOneWidget);
+ });
+ });
+}