Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions lib/packages/service/app_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ class AppStore {
throw Exception('No Wallet set');
}

/// Whether [wallet] is safe to read. False during the brief window between
/// app launch and the first `HomeBloc` event that calls the `wallet`
/// setter (`LoadCurrentWalletEvent` for an existing wallet,
/// `LoadWalletEvent` for a freshly created/restored one), plus the entire
/// onboarding flow until that happens. Lets services
/// (`WalletService.lockCurrentWallet`) early-return defensively from
/// app-lifecycle hooks that fire before any wallet exists.
///
/// Named distinctly from `WalletService.hasWallet()` β€” that one checks
/// persisted state (`SettingsRepository.currentWalletId`), this one checks
/// the in-memory load state. The two diverge during onboarding when a
/// wallet id has been persisted but `_wallet` is not yet populated.
bool get isWalletLoaded => _wallet != null;

ApiConfig get apiConfig => getApiConfig();

String get primaryAddress => wallet.currentAccount.primaryAddress.address.hex;
Expand Down
9 changes: 8 additions & 1 deletion lib/packages/service/wallet_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -169,12 +169,19 @@ class WalletService {
/// [SoftwareViewWallet] counterpart, dropping the mnemonic. Called after a
/// sign operation completes so the private key isn't kept resident for the
/// rest of the foreground session. No-op for wallet types that don't hold
/// a mnemonic.
/// a mnemonic, and no-op when no wallet has been loaded yet.
///
/// Respects [_activeUnlockHolders] β€” a second concurrent caller still
/// holding the unlocked contract keeps the wallet unlocked. The 60s safety
/// net runs through [_forceLock] instead so it can bypass the counter.
Future<void> lockCurrentWallet() async {
// Onboarding / pre-load guard. The app-lifecycle `hidden` hook can fire
// before [HomeBloc] populates [AppStore.wallet] β€” making the precondition
// explicit here keeps the lifecycle caller a one-liner and means a future
// lockCurrentWallet extension (DB write, etc.) won't get its errors
// silently caught at the call site.
if (!_appStore.isWalletLoaded) return;

if (_activeUnlockHolders > 0) _activeUnlockHolders--;
if (_activeUnlockHolders > 0) return;
_postUnlockLockTimer?.cancel();
Expand Down
8 changes: 8 additions & 0 deletions lib/setup/lifecycle_initializer.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:developer' as developer;

import 'package:flutter/widgets.dart';
import 'package:realunit_wallet/packages/service/app_store.dart';
import 'package:realunit_wallet/packages/service/balance_service.dart';
import 'package:realunit_wallet/packages/service/wallet_service.dart';
import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart';
import 'package:realunit_wallet/setup/di.dart';

Expand Down Expand Up @@ -51,6 +53,12 @@ class _LifecycleInitializerState extends State<LifecycleInitializer> {

void _onHidden() {
getIt<PinAuthCubit>().onAppHidden();
// Drop the mnemonic before iOS suspends the isolate. `lockCurrentWallet`
// is defensive on its own β€” no try/catch / catchError by design, so a
// Future.error surfaces in the Zone instead of being silently swallowed.
// Microtask race to watch: if `_onResumed` ever calls
// `ensureCurrentWalletUnlocked`, ordering against this pending lock matters.
unawaited(getIt<WalletService>().lockCurrentWallet());
}

void _onPaused() {
Expand Down
12 changes: 12 additions & 0 deletions test/packages/service/app_store_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,17 @@ void main() {
test('sessionCache is exposed unchanged', () {
expect(store.sessionCache, same(sessionCache));
});

test('isWalletLoaded is false before any wallet is set', () {
expect(store.isWalletLoaded, isFalse);
});

test('isWalletLoaded flips to true once a wallet is set', () {
expect(store.isWalletLoaded, isFalse);

store.wallet = SoftwareWallet(1, 'Main', _testMnemonic);

expect(store.isWalletLoaded, isTrue);
});
});
}
73 changes: 73 additions & 0 deletions test/packages/service/wallet_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,12 @@ void main() {
});

group('lockCurrentWallet', () {
// Tests in this group assume a loaded wallet β€” the "no wallet loaded
// yet" path is explicitly tested below by overriding to false.
setUp(() {
when(() => appStore.isWalletLoaded).thenReturn(true);
});

test('replaces an unlocked SoftwareWallet with its SoftwareViewWallet counterpart',
() async {
final unlocked = SoftwareWallet(9, 'Main', _testMnemonic);
Expand Down Expand Up @@ -345,9 +351,76 @@ void main() {
// No write happened.
verifyNever(() => appStore.wallet = any(that: isA<AWallet>()));
});

// Pre-load guard: the app-lifecycle `hidden` hook fires the first time
// the user backgrounds the app, which can happen during onboarding
// before HomeBloc has populated AppStore.wallet. The early-return on
// !isWalletLoaded keeps the lifecycle caller a one-liner β€” no try/catch
// around an "expected" Exception('No Wallet set') from appStore.wallet.
test('is a no-op when no wallet has been loaded yet', () async {
when(() => appStore.isWalletLoaded).thenReturn(false);

await service.lockCurrentWallet();

// Never even reaches the wallet getter β€” no MissingStubError, no
// write, no exception leaking to the unawaited caller.
verifyNever(() => appStore.wallet);
verifyNever(() => appStore.wallet = any(that: isA<AWallet>()));
});
});

group('ensure/lock reentrancy', () {
// Tests in this group exercise lockCurrentWallet end-to-end, so the
// pre-load guard expects a positive isWalletLoaded.
setUp(() {
when(() => appStore.isWalletLoaded).thenReturn(true);
});

// App-lifecycle hidden fires an unpaired lockCurrentWallet β€” i.e. one
// without a matching prior ensureCurrentWalletUnlocked. Sequence:
// flow X ensure β†’ counter 1, wallet unlocked
// _onHidden lock β†’ counter 0, wallet flipped to view
// flow X finally lock β†’ counter still 0 (underflow guard), _lockWalletInPlace
// no-ops because the wallet is already the view form.
// The 1:1 ensure↔lock invariant is technically broken by the unpaired
// lifecycle call, but the underflow guard + `is! SoftwareWallet` guard
// keep the state consistent. This test pins that contract.
test('unpaired lock from lifecycle leaves the holder counter at 0, never below', () async {
final stored = <AWallet>[SoftwareViewWallet(7, 'Main', _debugAddress)];
when(() => appStore.wallet).thenAnswer((_) => stored.last);
when(() => appStore.wallet = any(that: isA<AWallet>())).thenAnswer((inv) {
final newWallet = inv.positionalArguments.single as AWallet;
stored.add(newWallet);
return newWallet;
});
when(() => settings.currentWalletId).thenReturn(7);
when(() => repo.getUnlockedWalletById(7)).thenAnswer(
(_) async => _info(id: 7, name: 'Main', seed: _testMnemonic, type: WalletType.software),
);

// Sign flow opens the contract.
await service.ensureCurrentWalletUnlocked();
expect(stored.last, isA<SoftwareWallet>(),
reason: 'sign flow unlocked the wallet');

// App-lifecycle hidden fires concurrently β€” drops to view wallet.
await service.lockCurrentWallet();
expect(stored.last, isA<SoftwareViewWallet>(),
reason: 'lifecycle lock flipped the wallet to its view form');

// Sign flow finally β€” counter is already 0, must NOT underflow and
// must NOT crash on _lockWalletInPlace reading the (now view) wallet.
await service.lockCurrentWallet();
expect(stored.last, isA<SoftwareViewWallet>(),
reason: 'finally lock is idempotent β€” counter stays at 0');

// A subsequent ensure must still produce a usable unlocked wallet β€”
// i.e. the counter didn't drift negative and break the next cycle.
await service.ensureCurrentWalletUnlocked();
expect(stored.last, isA<SoftwareWallet>(),
reason: 'next ensure starts cleanly from counter == 0');
});

// Race: flow A and flow B both call ensureCurrentWalletUnlocked while
// the wallet is locked. A finishes its sign + lock first; B is still
// mid-sign and must see an unlocked wallet. Without the holder counter
Expand Down
99 changes: 99 additions & 0 deletions test/setup/lifecycle_initializer_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:mocktail/mocktail.dart';
import 'package:realunit_wallet/packages/service/app_store.dart';
import 'package:realunit_wallet/packages/service/balance_service.dart';
import 'package:realunit_wallet/packages/service/wallet_service.dart';
import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart';
import 'package:realunit_wallet/setup/lifecycle_initializer.dart';

class _MockAppStore extends Mock implements AppStore {}

class _MockBalanceService extends Mock implements BalanceService {}

class _MockPinAuthCubit extends Mock implements PinAuthCubit {}

class _MockWalletService extends Mock implements WalletService {}

void main() {
late _MockAppStore appStore;
late _MockBalanceService balanceService;
late _MockPinAuthCubit pinAuthCubit;
late _MockWalletService walletService;

setUp(() {
appStore = _MockAppStore();
balanceService = _MockBalanceService();
pinAuthCubit = _MockPinAuthCubit();
walletService = _MockWalletService();

final getIt = GetIt.instance;
getIt.registerSingleton<AppStore>(appStore);
getIt.registerSingleton<BalanceService>(balanceService);
getIt.registerSingleton<PinAuthCubit>(pinAuthCubit);
getIt.registerSingleton<WalletService>(walletService);

when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {});
});

tearDown(() => GetIt.instance.reset());

Future<void> pumpLifecycle(WidgetTester tester) =>
tester.pumpWidget(
const LifecycleInitializer(
child: SizedBox.shrink(),
),
);

testWidgets(
'AppLifecycleState.hidden drops the mnemonic via WalletService.lockCurrentWallet',
(tester) async {
await pumpLifecycle(tester);

tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
await tester.pump();

verify(() => walletService.lockCurrentWallet()).called(1);
},
);

// The architecture decision "no try/catch / catchError around the
// unawaited lockCurrentWallet" is locked in by the source itself β€” see
// the inline comment in `_onHidden`. We tried encoding it as a test, but
// every variant (`thenThrow`, `thenAnswer((_) async => throw …)`,
// `Future.error(...)`) routed the failure through the testWidgets
// framework's synchronous catch rather than the Zone uncaught-error sink
// that `tester.takeException()` reads from β€” the routing depends on
// Flutter's AppLifecycleListener dispatch (changes between 3.41 and 3.44).
// A brittle false-positive on CI is worse than relying on code review +
// the source comment to catch a future regression at the call site.

testWidgets(
'AppLifecycleState.paused does NOT lock the wallet β€” already covered by hidden',
(tester) async {
await pumpLifecycle(tester);

tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump();

verifyNever(() => walletService.lockCurrentWallet());
},
);

testWidgets(
'AppLifecycleState.resumed does NOT call lockCurrentWallet',
(tester) async {
when(() => appStore.primaryAddress).thenReturn('0xabc');
when(() => balanceService.updateBalance(any())).thenAnswer((_) async {});
when(() => pinAuthCubit.onAppResumed()).thenAnswer((_) {});

await pumpLifecycle(tester);

tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();

verifyNever(() => walletService.lockCurrentWallet());
},
);
}
Loading