-
Notifications
You must be signed in to change notification settings - Fork 159
Derive shutdown scripts without blocking on wallet persistence #1011
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
base: main
Are you sure you want to change the base?
Changes from 1 commit
273e4d0
1b27621
338dc8f
6ed64e8
075a40c
97df7cf
77164dc
5fd08ef
a7c52af
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 |
|---|---|---|
|
|
@@ -518,6 +518,36 @@ impl Wallet { | |
| Ok(address_info.address) | ||
| } | ||
|
|
||
| /// Returns a new address, persisting the revealed derivation index in the background rather | ||
| /// than waiting on it. | ||
| /// | ||
| /// This exists for sync callbacks (e.g., [`SignerProvider`]) that LDK invokes on runtime | ||
| /// worker threads while holding channel locks. Blocking such a callback on persistence can | ||
| /// deadlock the runtime: other tasks blocking synchronously on the same channel locks capture | ||
| /// the remaining workers, leaving none to drive the persistence future the callback waits on. | ||
| /// | ||
| /// If the node crashes before the background flush lands, the revealed index is lost and the | ||
| /// address may be handed out again after restart. BDK's keychain lookahead still detects any | ||
| /// funds it receives. | ||
| pub(crate) fn get_new_address_deferring_persist(self: &Arc<Self>) -> bitcoin::Address { | ||
| let address_info = | ||
| self.inner.lock().expect("lock").reveal_next_address(KeychainKind::External); | ||
|
|
||
| // Leave the change set staged: whichever flow next takes the persister lock and calls | ||
| // `take_staged` (possibly the task spawned here) persists the reveal, preserving the | ||
| // ordering that serializing those two steps under the persister lock establishes. | ||
| let wallet = Arc::clone(self); | ||
| self.runtime.spawn_background_task(async move { | ||
|
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. Codex:
This could probably be 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. 🤖 Done — refills now spawn on the cancellable set. This needed one accompanying fix: an abort could drop a refill's taken reveals, so the refill now stages them with the persister in the same synchronous critical section that takes them from the wallet, with a regression test covering it. Note that completed tasks still sit in the cancellable set until shutdown (#997 will reap them continuously) — so the practical gain today is that shutdown aborts an in-flight refill immediately instead of waiting on it. |
||
| let mut locked_persister = wallet.persister.lock().await; | ||
| let change_set = wallet.inner.lock().expect("lock").take_staged().unwrap_or_default(); | ||
| if let Err(e) = locked_persister.persist_changeset(change_set).await { | ||
| log_error!(wallet.logger, "Failed to persist wallet: {}", e); | ||
| } | ||
| }); | ||
|
|
||
| address_info.address | ||
| } | ||
|
|
||
| pub(crate) async fn get_new_internal_address(&self) -> Result<bitcoin::Address, Error> { | ||
| let mut locked_persister = self.persister.lock().await; | ||
| let (address_info, change_set) = { | ||
|
|
@@ -2090,16 +2120,16 @@ impl SignerProvider for WalletKeysManager { | |
| } | ||
|
|
||
| fn get_destination_script(&self, _channel_keys_id: [u8; 32]) -> Result<ScriptBuf, ()> { | ||
| let address = self.wallet.runtime.block_on(self.wallet.get_new_address()).map_err(|e| { | ||
| log_error!(self.logger, "Failed to retrieve new address from wallet: {}", e); | ||
| })?; | ||
| // LDK may invoke this callback on a runtime worker thread while holding channel locks. | ||
| // It must not block on the runtime, or the runtime can deadlock. | ||
| let address = self.wallet.get_new_address_deferring_persist(); | ||
| Ok(address.script_pubkey()) | ||
| } | ||
|
|
||
| fn get_shutdown_scriptpubkey(&self) -> Result<ShutdownScript, ()> { | ||
| let address = self.wallet.runtime.block_on(self.wallet.get_new_address()).map_err(|e| { | ||
| log_error!(self.logger, "Failed to retrieve new address from wallet: {}", e); | ||
| })?; | ||
| // LDK may invoke this callback on a runtime worker thread while holding channel locks. | ||
| // It must not block on the runtime, or the runtime can deadlock. | ||
| let address = self.wallet.get_new_address_deferring_persist(); | ||
|
|
||
| match address.witness_program() { | ||
| Some(program) => ShutdownScript::new_witness_program(&program).map_err(|e| { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
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.
Hmm, so looking at this again one issue is that BDK's lookahead is not used for incremental syncs - there they really just check (all) previously revealed spks. So, if we fail persistence in the background task, we will miss a derived SPK, leading to address reuse (maybe acceptable in this narrow case) and only discovering transactions once it has been reused (maybe not acceptable as it could be considered funds loss, even if temporarily/recoverable - the user might not realize it's recoverable).
An alternative approach would be to create an
AddressCachethat is refreshed and persisted in the background, and returnsErr(())if no addresses are available. Something like:On top, as an additional robustness measure we might want to consider adding a
wallet_persistence_pendingdirty marker than would trigger a full scan on next restart whenever we're not certain all persistence operations have cleanly succeeded before stopping?Thoughs?
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.
Yeah, the cache is a better approach. Claude implemented it with some deviations:
🤖 Good catch — you're right, incremental syncs only query the revealed SPKs of the persisted wallet, so a crash before the deferred flush lands would leave the handed-out script unwatched. That's not acceptable, even if recoverable. I reworked the PR along the lines you sketched.
The callbacks now pop from a pool of addresses whose reveal is already persisted, and fail closed (
Err(())) if the pool is empty. Newly revealed addresses are only published for handout after their change set persists; on failure they're retained and retried by the next refill, so no index is burned.Two deviations from your sketch, both aimed at keeping the revealed-but-unused window small, since every pooled address widens what incremental syncs must watch:
bdk_wallet/address_pool) and reloaded on startup, after validating them against the wallet's last revealed index. Without that, every restart would burn a pool's worth of fresh indices, permanently growing the watched set. The record is written before the reveals' change set, so a crash between the two writes just re-derives the same indices on restart instead of stranding them (a unit test replays a reload from every store-write boundary to check this).On the
wallet_persistence_pendingdirty marker: with the pool, unclean shutdown no longer risks an unwatched handed-out script, so I left it out of this PR — but it could still make sense as a general robustness measure for other in-flight wallet writes. Happy to explore it as a follow-up if you think it's worth it.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.
Codex:
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.
🤖 Implemented exactly this split: the signer callbacks only pop and accept the documented bounded reuse across a crash, while
get_new_addresspops from the same pool but persists the dequeue before returning. Both consume from a single FIFO pool in reveal order.