Problem
WalletManager in key-wallet embeds a tokio::sync::broadcast::Sender<WalletEvent> and the WalletInterface trait exposes subscribe_events() -> broadcast::Receiver<WalletEvent>. This forces a tokio runtime dependency on key-wallet, which should be a pure wallet primitives library.
The tokio dependency exists solely for this broadcast channel — there is no other runtime usage. It was inherited from the key-wallet-manager crate when it was merged into key-wallet in PR #503. PR #584 gates it behind a feature flag as a stopgap, but the root cause remains: event emission is baked into the state machine.
Why this matters
key-wallet cannot be used in non-tokio environments (WASM, embedded, synchronous apps) with the manager enabled
- The
WalletInterface trait leaks a tokio type in its public API
- Events are emitted during processing, which means consumers can observe partial state (e.g., a
TransactionReceived event fires while the block is still being processed)
- The broadcast channel silently drops events when there are no subscribers (
let _ = self.event_sender.send(event))
Proposed Architecture: Outbox Pattern
Mutation methods return events instead of emitting them internally. The WalletManager becomes a pure state machine that accumulates events as side effects of state transitions and returns them to the caller.
Before (current)
WalletManager::process_block()
→ internally sends WalletEvent on broadcast channel
→ caller spawns a monitor task to receive events
→ monitor task calls EventHandler::on_wallet_event()
After (proposed)
WalletManager::process_block()
→ returns (BlockProcessingResult, Vec<WalletEvent>)
→ caller dispatches events directly via EventHandler::on_wallet_event()
Specific changes
WalletInterface trait — return events from mutation methods:
| Method |
Current return |
New return |
process_block |
BlockProcessingResult |
(BlockProcessingResult, Vec<WalletEvent>) |
process_mempool_transaction |
MempoolTransactionResult |
(MempoolTransactionResult, Vec<WalletEvent>) |
process_instant_send_lock |
() |
Vec<WalletEvent> |
update_synced_height |
() |
Vec<WalletEvent> |
subscribe_events |
broadcast::Receiver |
removed |
WalletManager struct:
- Remove
event_sender: broadcast::Sender<WalletEvent> field
- Rename
emit_balance_changes() → collect_balance_changes() returning Vec<WalletEvent>
check_transaction_in_all_wallets() returns (CheckTransactionsResult, Vec<WalletEvent>)
dash-spv — dispatch at call sites:
BlocksManager::process_buffered_blocks() collects wallet events from returned tuples and passes them up to the SyncCoordinator
MempoolManager::handle_tx() and mark_instant_send() do the same
SyncCoordinator dispatches wallet events through EventHandler::on_wallet_event() alongside sync events
- Remove the wallet event broadcast monitor task from
DashSpvClient::run()
dash-spv-ffi — no changes needed. FFIEventCallbacks already implements EventHandler::on_wallet_event() which dispatches to C callbacks. It's called the same way regardless of where the call originates.
key-wallet Cargo.toml:
- Remove
tokio from [dependencies] entirely
- Add
tokio to [dev-dependencies] only (for #[tokio::test])
- The
manager feature becomes dependency-free
Benefits
key-wallet with manager has zero runtime dependencies (no tokio, no rayon with the existing feature gate)
- Events are delivered after the method returns — no partial-state observations
- No silent event drops — events are always returned, caller decides what to do
- Simpler testing — assert on the returned
Vec<WalletEvent> directly instead of subscribing to a channel
- The
WalletInterface trait has no tokio types in its API
Migration Risks
- Multi-subscriber loss: The broadcast channel supported multiple subscribers. After this change, the
EventHandler is the single dispatch point. Tests that need direct event access should use a TestEventHandler that republishes events.
- Event delivery timing: Events are now batched per method call rather than emitted inline. For
process_block, all transaction events are returned together after the full block is processed. This is actually safer but is a behavior change.
- Breaking trait change:
WalletInterface signature changes affect MockWallet, NonMatchingMockWallet, and any external implementors.
Suggested PR Sequence
PR 1 (key-wallet): Change return types on WalletInterface methods and WalletManager internals to return Vec<WalletEvent>. Keep subscribe_events() temporarily with #[deprecated]. Update key-wallet tests.
PR 2 (dash-spv): Update BlocksManager, MempoolManager, and SyncCoordinator to consume returned events and dispatch via EventHandler. Remove the broadcast monitor task. Update integration tests.
PR 3 (key-wallet cleanup): Remove subscribe_events(), remove event_sender field, remove tokio from dependencies. Final verification that cargo check -p key-wallet --features manager works without tokio.
Problem
WalletManagerinkey-walletembeds atokio::sync::broadcast::Sender<WalletEvent>and theWalletInterfacetrait exposessubscribe_events() -> broadcast::Receiver<WalletEvent>. This forces a tokio runtime dependency onkey-wallet, which should be a pure wallet primitives library.The tokio dependency exists solely for this broadcast channel — there is no other runtime usage. It was inherited from the
key-wallet-managercrate when it was merged intokey-walletin PR #503. PR #584 gates it behind a feature flag as a stopgap, but the root cause remains: event emission is baked into the state machine.Why this matters
key-walletcannot be used in non-tokio environments (WASM, embedded, synchronous apps) with the manager enabledWalletInterfacetrait leaks a tokio type in its public APITransactionReceivedevent fires while the block is still being processed)let _ = self.event_sender.send(event))Proposed Architecture: Outbox Pattern
Mutation methods return events instead of emitting them internally. The
WalletManagerbecomes a pure state machine that accumulates events as side effects of state transitions and returns them to the caller.Before (current)
After (proposed)
Specific changes
WalletInterfacetrait — return events from mutation methods:process_blockBlockProcessingResult(BlockProcessingResult, Vec<WalletEvent>)process_mempool_transactionMempoolTransactionResult(MempoolTransactionResult, Vec<WalletEvent>)process_instant_send_lock()Vec<WalletEvent>update_synced_height()Vec<WalletEvent>subscribe_eventsbroadcast::ReceiverWalletManagerstruct:event_sender: broadcast::Sender<WalletEvent>fieldemit_balance_changes()→collect_balance_changes()returningVec<WalletEvent>check_transaction_in_all_wallets()returns(CheckTransactionsResult, Vec<WalletEvent>)dash-spv— dispatch at call sites:BlocksManager::process_buffered_blocks()collects wallet events from returned tuples and passes them up to theSyncCoordinatorMempoolManager::handle_tx()andmark_instant_send()do the sameSyncCoordinatordispatches wallet events throughEventHandler::on_wallet_event()alongside sync eventsDashSpvClient::run()dash-spv-ffi— no changes needed.FFIEventCallbacksalready implementsEventHandler::on_wallet_event()which dispatches to C callbacks. It's called the same way regardless of where the call originates.key-walletCargo.toml:tokiofrom[dependencies]entirelytokioto[dev-dependencies]only (for#[tokio::test])managerfeature becomes dependency-freeBenefits
key-walletwithmanagerhas zero runtime dependencies (no tokio, no rayon with the existing feature gate)Vec<WalletEvent>directly instead of subscribing to a channelWalletInterfacetrait has no tokio types in its APIMigration Risks
EventHandleris the single dispatch point. Tests that need direct event access should use aTestEventHandlerthat republishes events.process_block, all transaction events are returned together after the full block is processed. This is actually safer but is a behavior change.WalletInterfacesignature changes affectMockWallet,NonMatchingMockWallet, and any external implementors.Suggested PR Sequence
PR 1 (key-wallet): Change return types on
WalletInterfacemethods andWalletManagerinternals to returnVec<WalletEvent>. Keepsubscribe_events()temporarily with#[deprecated]. Update key-wallet tests.PR 2 (dash-spv): Update
BlocksManager,MempoolManager, andSyncCoordinatorto consume returned events and dispatch viaEventHandler. Remove the broadcast monitor task. Update integration tests.PR 3 (key-wallet cleanup): Remove
subscribe_events(), removeevent_senderfield, remove tokio from dependencies. Final verification thatcargo check -p key-wallet --features managerworks without tokio.