-
Notifications
You must be signed in to change notification settings - Fork 14
feat(pubkey-manager): add pubkey watch function similar to balance watch #178
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
d6cc7c5
841bcea
d3cb60c
b440c24
34301f6
4be64e8
9499bd8
f678d1b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,24 +1,72 @@ | ||
| import 'dart:async'; | ||
|
|
||
| import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; | ||
| import 'package:komodo_defi_sdk/src/_internal_exports.dart'; | ||
| import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; | ||
| import 'package:komodo_defi_types/komodo_defi_types.dart'; | ||
|
|
||
| /// Interface defining the contract for pubkey management operations | ||
| abstract class IPubkeyManager { | ||
| /// Get pubkeys for a given asset, handling HD/non-HD differences internally | ||
| Future<AssetPubkeys> getPubkeys(Asset asset); | ||
|
|
||
| /// Watch pubkeys for a given asset, emitting the initial state if available | ||
| /// and polling for updates at a fixed interval. Optionally activates asset. | ||
| Stream<AssetPubkeys> watchPubkeys( | ||
| Asset asset, { | ||
| bool activateIfNeeded = true, | ||
| }); | ||
|
|
||
| /// Get the last known pubkeys for an asset without triggering a refresh. | ||
| /// Returns null if no pubkeys have been fetched yet. | ||
| AssetPubkeys? lastKnown(AssetId assetId); | ||
|
|
||
| /// Create a new pubkey for an asset if supported | ||
| Future<PubkeyInfo> createNewPubkey(Asset asset); | ||
|
|
||
| /// Streamed version of [createNewPubkey] | ||
| Stream<NewAddressState> createNewPubkeyStream(Asset asset); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: In line with the convention to add
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in 9499bd8 |
||
|
|
||
| /// Unban pubkeys according to [unbanBy] criteria | ||
| Future<UnbanPubkeysResult> unbanPubkeys(UnbanBy unbanBy); | ||
|
|
||
| /// Pre-caches pubkeys for an asset to warm the cache and notify listeners | ||
| Future<void> preCachePubkeys(Asset asset); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: "precache" is one word, so the name should be
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| /// Dispose of any resources | ||
| Future<void> dispose(); | ||
| } | ||
|
|
||
| /// Manager responsible for handling pubkey operations across different assets | ||
| class PubkeyManager { | ||
| PubkeyManager(this._client, this._auth, this._activationCoordinator); | ||
| class PubkeyManager implements IPubkeyManager { | ||
| PubkeyManager(this._client, this._auth, this._activationCoordinator) { | ||
| _authSubscription = _auth.authStateChanges.listen(_handleAuthStateChanged); | ||
| } | ||
|
|
||
| final ApiClient _client; | ||
| final KomodoDefiLocalAuth _auth; | ||
| final SharedActivationCoordinator _activationCoordinator; | ||
|
|
||
| // Internal state for watching pubkeys per asset | ||
| final Map<AssetId, AssetPubkeys> _pubkeysCache = {}; | ||
| final Map<AssetId, StreamSubscription<dynamic>> _activeWatchers = {}; | ||
| final Map<AssetId, StreamController<AssetPubkeys>> _pubkeysControllers = {}; | ||
|
|
||
| StreamSubscription<KdfUser?>? _authSubscription; | ||
| WalletId? _currentWalletId; | ||
| bool _isDisposed = false; | ||
| final Duration _defaultPollingInterval = const Duration(seconds: 30); | ||
|
|
||
| /// Get pubkeys for a given asset, handling HD/non-HD differences internally | ||
| @override | ||
| Future<AssetPubkeys> getPubkeys(Asset asset) async { | ||
| await retry(() => _activationCoordinator.activateAsset(asset)); | ||
| final strategy = await _resolvePubkeyStrategy(asset); | ||
| return strategy.getPubkeys(asset.id, _client); | ||
| } | ||
|
takenagain marked this conversation as resolved.
|
||
|
|
||
| /// Create a new pubkey for an asset if supported | ||
| @override | ||
| Future<PubkeyInfo> createNewPubkey(Asset asset) async { | ||
| await retry(() => _activationCoordinator.activateAsset(asset)); | ||
| final strategy = await _resolvePubkeyStrategy(asset); | ||
|
|
@@ -31,6 +79,7 @@ class PubkeyManager { | |
| } | ||
|
|
||
| /// Streamed version of [createNewPubkey] | ||
| @override | ||
| Stream<NewAddressState> createNewPubkeyStream(Asset asset) async* { | ||
| await retry(() => _activationCoordinator.activateAsset(asset)); | ||
| final strategy = await _resolvePubkeyStrategy(asset); | ||
|
|
@@ -44,6 +93,7 @@ class PubkeyManager { | |
| } | ||
|
|
||
| /// Unban pubkeys according to [unbanBy] criteria | ||
| @override | ||
| Future<UnbanPubkeysResult> unbanPubkeys(UnbanBy unbanBy) async { | ||
| final response = await _client.rpc.wallet.unbanPubkeys(unbanBy: unbanBy); | ||
| return response.result; | ||
|
|
@@ -57,8 +107,220 @@ class PubkeyManager { | |
| return asset.pubkeyStrategy(kdfUser: currentUser); | ||
| } | ||
|
|
||
| /// Stream of pubkeys per asset. Polls pubkeys (not balances) and emits updates. | ||
| /// Emits the initial known state if available. | ||
| @override | ||
| Stream<AssetPubkeys> watchPubkeys( | ||
| Asset asset, { | ||
| bool activateIfNeeded = true, | ||
| }) async* { | ||
| if (_isDisposed) { | ||
| throw StateError('PubkeyManager has been disposed'); | ||
| } | ||
|
|
||
| // Emit last known pubkeys immediately if available | ||
| final lastKnown = _pubkeysCache[asset.id]; | ||
| if (lastKnown != null) { | ||
| yield lastKnown; | ||
| } | ||
|
|
||
| final controller = _pubkeysControllers.putIfAbsent( | ||
| asset.id, | ||
| () => StreamController<AssetPubkeys>.broadcast( | ||
| onListen: () => _startWatchingPubkeys(asset, activateIfNeeded), | ||
| onCancel: () => _stopWatchingPubkeys(asset.id), | ||
| ), | ||
| ); | ||
|
|
||
|
takenagain marked this conversation as resolved.
|
||
| yield* controller.stream; | ||
| } | ||
|
takenagain marked this conversation as resolved.
takenagain marked this conversation as resolved.
|
||
|
|
||
| @override | ||
|
takenagain marked this conversation as resolved.
|
||
| AssetPubkeys? lastKnown(AssetId assetId) { | ||
| if (_isDisposed) { | ||
| throw StateError('PubkeyManager has been disposed'); | ||
| } | ||
| return _pubkeysCache[assetId]; | ||
| } | ||
|
|
||
| Future<void> _startWatchingPubkeys(Asset asset, bool activateIfNeeded) async { | ||
| final controller = _pubkeysControllers[asset.id]; | ||
| if (controller == null || _isDisposed) return; | ||
|
|
||
| // Cancel any existing watcher for this asset | ||
| await _activeWatchers[asset.id]?.cancel(); | ||
| _activeWatchers.remove(asset.id); | ||
|
|
||
| // Ensure user is authenticated | ||
| final user = await _auth.currentUser; | ||
| if (user == null) { | ||
| // Do not emit an error; wait for authentication changes | ||
| return; | ||
| } | ||
| _currentWalletId = user.walletId; | ||
|
|
||
| // Emit last known immediately if available | ||
| final maybeKnown = _pubkeysCache[asset.id]; | ||
| if (maybeKnown != null && !controller.isClosed) { | ||
| controller.add(maybeKnown); | ||
| } | ||
|
|
||
| try { | ||
| // Ensure activation if requested, otherwise only proceed if already active | ||
| bool isActive = await _activationCoordinator.isAssetActive(asset.id); | ||
| if (!isActive && activateIfNeeded) { | ||
| final activationResult = await _activationCoordinator.activateAsset( | ||
| asset, | ||
| ); | ||
| isActive = activationResult.isSuccess; | ||
| } | ||
|
|
||
| if (isActive) { | ||
| final first = await getPubkeys(asset); | ||
| _pubkeysCache[asset.id] = first; | ||
| if (!controller.isClosed) controller.add(first); | ||
| } | ||
|
takenagain marked this conversation as resolved.
|
||
|
|
||
| // Periodic polling for pubkeys updates | ||
| final periodicStream = Stream<void>.periodic(_defaultPollingInterval); | ||
| _activeWatchers[asset.id] = periodicStream | ||
| .asyncMap<AssetPubkeys?>((_) async { | ||
| if (_isDisposed) return null; | ||
|
|
||
| // Check that user is still authenticated and wallet hasn't changed | ||
| final currentUser = await _auth.currentUser; | ||
| if (currentUser == null || | ||
|
takenagain marked this conversation as resolved.
|
||
| currentUser.walletId != _currentWalletId) { | ||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| bool active = await _activationCoordinator.isAssetActive( | ||
| asset.id, | ||
| ); | ||
| if (!active && activateIfNeeded) { | ||
| final activationResult = await _activationCoordinator | ||
| .activateAsset(asset); | ||
| active = activationResult.isSuccess; | ||
| } | ||
| if (active) { | ||
| final pubkeys = await getPubkeys(asset); | ||
| _pubkeysCache[asset.id] = pubkeys; | ||
| return pubkeys; | ||
| } | ||
| } catch (_) { | ||
| // Swallow transient errors; continue with last known state | ||
| } | ||
| return _pubkeysCache[asset.id]; | ||
| }) | ||
| .listen( | ||
| (AssetPubkeys? pubkeys) { | ||
| if (pubkeys != null && !controller.isClosed) { | ||
| controller.add(pubkeys); | ||
| } | ||
| }, | ||
| onError: (Object error) { | ||
| if (!controller.isClosed) controller.addError(error); | ||
| }, | ||
| onDone: () => _stopWatchingPubkeys(asset.id), | ||
| cancelOnError: false, | ||
| ); | ||
| } catch (e) { | ||
| if (!controller.isClosed) controller.addError(e); | ||
| } | ||
| } | ||
|
|
||
| void _stopWatchingPubkeys(AssetId assetId) { | ||
| final watcher = _activeWatchers[assetId]; | ||
| if (watcher != null) { | ||
| watcher.cancel(); | ||
| _activeWatchers.remove(assetId); | ||
| } | ||
| } | ||
|
|
||
| @override | ||
|
takenagain marked this conversation as resolved.
|
||
| Future<void> preCachePubkeys(Asset asset) async { | ||
| if (_isDisposed) return; | ||
|
|
||
| final user = await _auth.currentUser; | ||
| if (user == null) return; | ||
|
|
||
| try { | ||
| final pubkeys = await getPubkeys(asset); | ||
| _pubkeysCache[asset.id] = pubkeys; | ||
|
|
||
| final controller = _pubkeysControllers[asset.id]; | ||
| if (controller != null && !controller.isClosed) { | ||
| controller.add(pubkeys); | ||
| } | ||
| } catch (_) { | ||
| // Fail silently; this is a best-effort cache warm-up | ||
| } | ||
| } | ||
|
|
||
| Future<void> _handleAuthStateChanged(KdfUser? user) async { | ||
| if (_isDisposed) return; | ||
| final newWalletId = user?.walletId; | ||
| if (_currentWalletId != newWalletId) { | ||
| await _resetState(); | ||
| _currentWalletId = newWalletId; | ||
| } | ||
| } | ||
|
|
||
| Future<void> _resetState() async { | ||
| // Cancel all active watchers | ||
| for (final subscription in _activeWatchers.values) { | ||
| await subscription.cancel(); | ||
| } | ||
| _activeWatchers.clear(); | ||
|
|
||
| // Wallet changes are normal user operations; do not signal as errors. | ||
| // If needed, consider using a dedicated event or state indicator. | ||
| for (final _ in _pubkeysControllers.values) { | ||
| // No-op: we intentionally keep controllers open so that consumers can | ||
| // re-listen, which will trigger a fresh watcher start when appropriate. | ||
| // Optionally, emit a state event here if the stream type supports it. | ||
| } | ||
|
|
||
| // Clear caches | ||
| _pubkeysCache.clear(); | ||
|
|
||
| // Restart watchers for existing controllers | ||
| final existingControllers = | ||
| Map<AssetId, StreamController<AssetPubkeys>>.from(_pubkeysControllers); | ||
| for (final entry in existingControllers.entries) { | ||
| if (!entry.value.isClosed) { | ||
| // Asset recreation from AssetId is intentionally not performed here during state reset. | ||
| // This is because reconstructing an Asset instance from just its AssetId is non-trivial: | ||
| // it may require additional metadata or context that is not available at this point, | ||
| // and could depend on external state or configuration. Instead, we rely on callers to | ||
| // re-listen, which will trigger a restart with a fully constructed Asset. This approach | ||
| // avoids potential errors and ensures that watchers are started with complete information. | ||
| // We simply keep controllers; new listeners will call _startWatchingPubkeys as needed. | ||
|
takenagain marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Dispose of any resources | ||
| @override | ||
| Future<void> dispose() async { | ||
| // No cleanup needed currently | ||
| if (_isDisposed) return; | ||
| _isDisposed = true; | ||
|
|
||
| await _authSubscription?.cancel(); | ||
| _authSubscription = null; | ||
|
|
||
| for (final subscription in _activeWatchers.values) { | ||
| await subscription.cancel(); | ||
| } | ||
| _activeWatchers.clear(); | ||
|
|
||
| for (final controller in _pubkeysControllers.values) { | ||
| await controller.close(); | ||
| } | ||
| _pubkeysControllers.clear(); | ||
|
|
||
| _pubkeysCache.clear(); | ||
| _currentWalletId = null; | ||
|
Comment on lines
+318
to
+374
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Improve the error handling to make it more resilient to errors being thrown early in the function. If it won't cause potential race conditions, consider putting all async operations into a
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a specific reason you opted to make the methods require the
Assettype instead ofassetId? Some of the behaviour may depend on theAsset's properties, but since this is a publicly exposed SDK method, it places a significant responsibility on projects consuming the SDK, as they must retain a reference to the entireAssetacross all states to use these methods.Also, it may cause unexpected behaviour if we're using the
Assetvalues passed since the consuming app could instantiate their ownAssetobject to alter the method's behaviour.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To align with the behaviour of existing
PubkeyManagermethods in terms of performing automatic activation of assets. TheAssettype is required for activation usingSharedActivationCoordinator.