Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
117 changes: 117 additions & 0 deletions bdk_demo/lib/providers/address_providers.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import 'package:bdk_demo/models/wallet_record.dart';
import 'package:bdk_demo/providers/wallet_providers.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class ReceiveAddressState {
const ReceiveAddressState({
this.walletId,
this.address,
this.index,
this.isGenerating = false,
this.errorMessage,
});

static const empty = ReceiveAddressState();

final String? walletId;
final String? address;
final int? index;
final bool isGenerating;
final String? errorMessage;

ReceiveAddressState copyWith({
String? walletId,
String? address,
int? index,
bool? isGenerating,
String? errorMessage,
bool clearAddress = false,
bool clearIndex = false,
bool clearErrorMessage = false,
}) {
return ReceiveAddressState(
walletId: walletId ?? this.walletId,
address: clearAddress ? null : (address ?? this.address),
index: clearIndex ? null : (index ?? this.index),
isGenerating: isGenerating ?? this.isGenerating,
errorMessage: clearErrorMessage
? null
: (errorMessage ?? this.errorMessage),
);
}
}

final currentReceiveAddressProvider =
NotifierProvider<CurrentReceiveAddressNotifier, ReceiveAddressState>(
CurrentReceiveAddressNotifier.new,
);

class CurrentReceiveAddressNotifier extends Notifier<ReceiveAddressState> {
bool _inFlight = false;

@override
ReceiveAddressState build() {
ref.listen<WalletRecord?>(activeWalletRecordProvider, (previous, next) {
final current = state;
if (current.walletId == null && !current.isGenerating) return;
Comment thread
Johnosezele marked this conversation as resolved.
Outdated
if (next == null || current.walletId != next.id) {
state = ReceiveAddressState.empty;
}
});
return ReceiveAddressState.empty;
}

Future<void> generateForActiveWallet() async {
if (_inFlight) return;

final record = ref.read(activeWalletRecordProvider);
if (record == null) {
state = const ReceiveAddressState(errorMessage: 'No active wallet.');
return;
}

final walletId = record.id;
_inFlight = true;
state = ReceiveAddressState.empty.copyWith(
walletId: walletId,
isGenerating: true,
clearAddress: true,
clearIndex: true,
clearErrorMessage: true,
);

try {
final walletService = ref.read(walletServiceProvider);
final (addressInfo, updatedWallet) = await walletService.generateAddress(
record,
);

if (!_stillActive(walletId)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stale branch runs after generateAddress() has already revealed and persisted the address for the old wallet. Could we either avoid committing when _stillActive(walletId) is false, or cache the generated address by wallet id so it is shown when that wallet becomes active again? Otherwise switching wallets during generation advances the old wallet’s receive index without ever surfacing the address.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, what I want to do here is keep the generated receive address keyed by walletId instead of dropping it when the user switches wallets mid-generation... that way we still avoid showing stale state on the newly active wallet, but when the original wallet becomes active again we can show the address that was already revealed and persisted. I’ll also add a test for that path so the behavior is locked in.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that behavior makes sense to me. Since the address has already been revealed and persisted by the time the stale completion returns, we shouldn’t drop it. Caching the result by walletId seems right. One thing to tighten before resolving: the cached wallet ownership path looks fragile, and CI is currently panicking in the reactivation test, so I’d either cache just the receive-address state and reload the wallet from SQLite, or make the cached-wallet handoff explicit and covered by the test.

@Johnosezele Johnosezele Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, agreed. I’m going to cache only the receive address state by walletId, and dispose the stale returned wallet.

updatedWallet.dispose();
state = ReceiveAddressState.empty;
return;
}

ref.read(activeWalletProvider.notifier).replaceWallet(updatedWallet);
state = ReceiveAddressState(
walletId: walletId,
address: addressInfo.address.toString(),
index: addressInfo.index,
);
} catch (error) {
if (_stillActive(walletId)) {
state = ReceiveAddressState(
walletId: walletId,
errorMessage: error.toString(),
);
} else {
state = ReceiveAddressState.empty;
}
} finally {
_inFlight = false;
}
}

bool _stillActive(String walletId) =>
ref.read(activeWalletRecordProvider)?.id == walletId;
}
87 changes: 87 additions & 0 deletions bdk_demo/lib/services/wallet_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,93 @@ class WalletService {
);
}

Future<(AddressInfo, Wallet)> generateAddress(WalletRecord record) async {
final secrets = await _storage.getSecrets(record.id);
if (secrets == null) {
throw StateError(
'No secrets found for wallet "${record.name}" (${record.id}). '
'Cannot persist receive address.',
);
}

Descriptor? descriptor;
Descriptor? changeDescriptor;
Persister? persister;
Wallet? wallet;
var returnedWallet = false;
try {
final bdkNetworkKind = record.network.toBdkNetworkKind();
descriptor = Descriptor(
descriptor: secrets.descriptor,
networkKind: bdkNetworkKind,
);
changeDescriptor = Descriptor(
descriptor: secrets.changeDescriptor,
networkKind: bdkNetworkKind,
);
final dbPath = await WalletStoragePaths.sqlitePathForWallet(record.id);
wallet = await loadWalletFromRecord(record);

final receive = wallet.revealNextAddress(
keychain: KeychainKind.external_,
);
persister = Persister.newSqlite(path: dbPath);
await _ensureWalletPersistedToSqlite(
wallet,
persister,
descriptor,
changeDescriptor,
dbPath,
);
await _verifyReceiveIndexPersisted(
descriptor: descriptor,
changeDescriptor: changeDescriptor,
dbPath: dbPath,
revealedIndex: receive.index,
);
returnedWallet = true;
return (receive, wallet);
} finally {
if (!returnedWallet) {
wallet?.dispose();
}
persister?.dispose();
descriptor?.dispose();
changeDescriptor?.dispose();
}
}

Future<void> _verifyReceiveIndexPersisted({
required Descriptor descriptor,
required Descriptor changeDescriptor,
required String dbPath,
required int revealedIndex,
}) async {
Persister? verifierPersister;
Wallet? verifierWallet;
try {
verifierPersister = Persister.newSqlite(path: dbPath);
verifierWallet = _walletLoadRunner(
descriptor: descriptor,
changeDescriptor: changeDescriptor,
persister: verifierPersister,
lookahead: AppConstants.walletLookahead,
);
final nextExternalIndex = verifierWallet.nextDerivationIndex(
keychain: KeychainKind.external_,
);
if (nextExternalIndex <= revealedIndex) {
throw StateError(
'Wallet SQLite persistence did not save receive address index '
'$revealedIndex.',
);
}
} finally {
verifierWallet?.dispose();
verifierPersister?.dispose();
}
}

Future<Wallet> _reseedWalletToPrimarySqlite({
required String walletId,
required Descriptor descriptor,
Expand Down
Loading
Loading