Skip to content

Commit e8b9da2

Browse files
fix(platform-wallet): wait indefinitely for asset-lock ChainLock finality (#4006)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a1d40ca commit e8b9da2

10 files changed

Lines changed: 153 additions & 74 deletions

File tree

packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ pub unsafe extern "C" fn asset_lock_manager_resume(
5858
check_ptr!(out_derivation_path);
5959

6060
let out_point = parse_outpoint(txid, vout);
61-
let timeout = Duration::from_secs(timeout_secs);
61+
// `timeout_secs == 0` requests an unbounded wait (a ChainLock is
62+
// guaranteed finality; a broadcast lock is pending, never failed).
63+
let timeout = (timeout_secs != 0).then(|| Duration::from_secs(timeout_secs));
6264

6365
let option = ASSET_LOCK_MANAGER_STORAGE.with_item(handle, |manager| {
6466
runtime().block_on(manager.resume_asset_lock(&out_point, timeout))
@@ -92,7 +94,8 @@ pub unsafe extern "C" fn asset_lock_manager_resume(
9294
/// Returns `ok` on a successful proof resolution, an error on
9395
/// timeout / wait failure. The Swift caller is expected to schedule
9496
/// this on a background queue — `runtime().block_on(...)` parks the
95-
/// calling thread for up to `timeout_secs`.
97+
/// calling thread for up to `timeout_secs` (or **indefinitely** when
98+
/// `timeout_secs == 0`, since a ChainLock is guaranteed finality).
9699
#[no_mangle]
97100
pub unsafe extern "C" fn asset_lock_manager_catch_up_blocking(
98101
handle: Handle,
@@ -103,7 +106,9 @@ pub unsafe extern "C" fn asset_lock_manager_catch_up_blocking(
103106
check_ptr!(txid);
104107

105108
let out_point = parse_outpoint(txid, vout);
106-
let timeout = Duration::from_secs(timeout_secs);
109+
// `timeout_secs == 0` requests an unbounded wait (a ChainLock is
110+
// guaranteed finality; a broadcast lock is pending, never failed).
111+
let timeout = (timeout_secs != 0).then(|| Duration::from_secs(timeout_secs));
107112

108113
tracing::info!(
109114
outpoint = %out_point,

packages/rs-platform-wallet-ffi/src/shielded_send.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,9 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock(
918918
// pool-seeding path uses its own dedicated FFI entry point).
919919
0,
920920
None,
921+
// User-facing funding: wait for the ChainLock indefinitely —
922+
// a broadcast asset lock is pending finality, never failed.
923+
None,
921924
)
922925
.await
923926
});
@@ -1061,6 +1064,9 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset
10611064
// Resuming a single-note fund (not a seeding batch).
10621065
0,
10631066
None,
1067+
// User-facing funding: wait for the ChainLock indefinitely —
1068+
// a broadcast asset lock is pending finality, never failed.
1069+
None,
10641070
)
10651071
.await
10661072
});

packages/rs-platform-wallet/src/wallet/asset_lock/build.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,9 +386,13 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
386386
.await?;
387387
self.queue_asset_lock_changeset(cs_broadcast);
388388

389-
// 5. Wait for proof via SPV events.
389+
// 5. Wait for proof via SPV events. The 300s bound is an
390+
// InstantSend-preference window, NOT a finality timeout: on
391+
// expiry the resolver falls back to an unbounded ChainLock wait
392+
// (`upgrade_to_chain_lock_proof(None)`), so a broadcast lock is
393+
// never surfaced as "failed" just because IS was slow.
390394
let proof = self
391-
.wait_for_proof(&out_point, Duration::from_secs(300))
395+
.wait_for_proof(&out_point, Some(Duration::from_secs(300)))
392396
.await?;
393397

394398
// 5b. If we got an IS-lock proof, check whether the transaction is

packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,23 @@ use crate::wallet::asset_lock::manager::AssetLockManager;
4646
// Timeout policy
4747
// ---------------------------------------------------------------------------
4848

49-
/// Time we will wait for a ChainLock to materialise after an IS-lock
50-
/// fallback is triggered. 180s mirrors the existing fallback shape and
51-
/// is roughly the worst-case ChainLock latency we've observed in
52-
/// testnet operation. Promoted to a constant so the registration,
53-
/// top-up, and address-funding flows can't drift apart on this number.
49+
/// Bounded ChainLock wait used *only* by the shielded seed pool, where a
50+
/// `FinalityTimeout` is a deliberate pacing signal — rapid back-to-back
51+
/// batches chain unconfirmed L1 change outputs, and around core's
52+
/// unconfirmed-ancestor depth limit IS/CL proofs stop arriving until a
53+
/// block lands; the seed pool catches the timeout, pauses, and resumes
54+
/// the tracked lock (see `shielded/seed_pool.rs`).
55+
///
56+
/// The user-facing funding flows (identity registration / top-up,
57+
/// platform-address top-up, and user-initiated shielded funding) do NOT
58+
/// use this: they wait for a ChainLock **indefinitely**
59+
/// (`upgrade_to_chain_lock_proof(None)`), because a ChainLock is
60+
/// deterministic finality that will eventually cover any broadcast
61+
/// asset-lock tx — so a broadcast lock is *pending*, never *failed*.
62+
///
63+
/// Only the shielded seed pool consumes this, so it is `shielded`-gated
64+
/// to avoid a dead-code warning in builds without that feature.
65+
#[cfg(feature = "shielded")]
5466
pub(crate) const CL_FALLBACK_TIMEOUT: Duration = Duration::from_secs(180);
5567

5668
/// Delay between retries when Platform rejected with CL-height-too-low.
@@ -408,8 +420,12 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
408420
}
409421
}
410422
AssetLockFunding::FromExistingAssetLock { out_point } => {
423+
// 300s is an InstantSend-preference window, not a finality
424+
// timeout: on expiry the caller falls back to an unbounded
425+
// ChainLock wait, so a resumed broadcast lock never fails
426+
// just because IS was slow.
411427
match self
412-
.resume_asset_lock(&out_point, Duration::from_secs(300))
428+
.resume_asset_lock(&out_point, Some(Duration::from_secs(300)))
413429
.await
414430
{
415431
Ok((proof, path)) => Ok(FundingResolution::Resolved(ResolvedFunding {

packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs

Lines changed: 65 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,21 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
148148
/// Called from the recovery layer when `put_to_platform` fails with
149149
/// `InvalidInstantAssetLockProofSignature`. If the TX is already
150150
/// chain-locked, constructs the proof immediately. Otherwise, **waits**
151-
/// for a ChainLock via SPV events (up to 10 minutes) so the caller
152-
/// doesn't see a failure — just a longer wait.
151+
/// for a ChainLock via SPV events so the caller doesn't see a failure —
152+
/// just a longer wait.
153+
///
154+
/// `timeout` is `Option<Duration>`: `None` waits **indefinitely**. A
155+
/// ChainLock is deterministic finality that will eventually cover any
156+
/// broadcast asset-lock tx, so the user-facing funding flows
157+
/// (identity registration / top-up, platform-address top-up, shielded
158+
/// funding) pass `None` — a broadcast lock is pending, never failed.
159+
/// The only bounded caller is the shielded seed pool, where a
160+
/// `FinalityTimeout` is a deliberate pacing signal for the
161+
/// unconfirmed-ancestor stall (see `CL_FALLBACK_TIMEOUT`).
153162
pub(crate) async fn upgrade_to_chain_lock_proof(
154163
&self,
155164
out_point: &OutPoint,
156-
timeout: Duration,
165+
timeout: Option<Duration>,
157166
) -> Result<dpp::prelude::AssetLockProof, PlatformWalletError> {
158167
use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof;
159168
use key_wallet::transaction_checking::TransactionContext;
@@ -253,16 +262,18 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
253262
/// Wait for a ChainLock that covers the given transaction.
254263
///
255264
/// Subscribes to SPV events and waits until the transaction's block
256-
/// is chain-locked.
265+
/// is chain-locked. `timeout` is `Option<Duration>`: `None` waits
266+
/// **indefinitely** (a ChainLock is guaranteed finality that will
267+
/// eventually arrive, so a broadcast lock is pending, not failed).
257268
async fn wait_for_chain_lock(
258269
&self,
259270
account_index: u32,
260271
out_point: &OutPoint,
261-
timeout: Duration,
272+
timeout: Option<Duration>,
262273
) -> Result<u32, PlatformWalletError> {
263274
use key_wallet::transaction_checking::TransactionContext;
264275

265-
let deadline = tokio::time::Instant::now() + timeout;
276+
let deadline = timeout.map(|t| tokio::time::Instant::now() + t);
266277

267278
loop {
268279
// Arm the `Notify` future BEFORE the state check, closing
@@ -304,18 +315,26 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
304315
}
305316
}
306317

307-
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
308-
if remaining.is_zero() {
309-
return Err(PlatformWalletError::FinalityTimeout(*out_point));
310-
}
311-
312-
// Wait for a lock event notification or timeout. The
313-
// `notified` future is the one we armed above, so any
314-
// CL/IS event since then is already buffered into it.
315-
tokio::select! {
316-
_ = &mut notified => continue,
317-
_ = tokio::time::sleep(remaining) => {
318-
return Err(PlatformWalletError::FinalityTimeout(*out_point));
318+
// Wait for a lock event notification (or timeout, when one is
319+
// configured). The `notified` future is the one we armed above,
320+
// so any CL/IS event since then is already buffered into it.
321+
match deadline {
322+
Some(dl) => {
323+
let remaining = dl.saturating_duration_since(tokio::time::Instant::now());
324+
if remaining.is_zero() {
325+
return Err(PlatformWalletError::FinalityTimeout(*out_point));
326+
}
327+
tokio::select! {
328+
_ = &mut notified => continue,
329+
_ = tokio::time::sleep(remaining) => {
330+
return Err(PlatformWalletError::FinalityTimeout(*out_point));
331+
}
332+
}
333+
}
334+
// No deadline: wait indefinitely for the next lock event.
335+
None => {
336+
notified.as_mut().await;
337+
continue;
319338
}
320339
}
321340
}
@@ -330,17 +349,23 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
330349
///
331350
/// Returns a properly-constructed `AssetLockProof` on success, or
332351
/// `FinalityTimeout` if the timeout elapses first.
352+
///
353+
/// `timeout` is `Option<Duration>`: `None` waits **indefinitely** for
354+
/// either an InstantSend or a ChainLock proof. Bounded callers use the
355+
/// deadline as an InstantSend-preference window — on expiry they get a
356+
/// `FinalityTimeout` and fall back to an (unbounded) ChainLock wait via
357+
/// [`Self::upgrade_to_chain_lock_proof`].
333358
pub(in crate::wallet::asset_lock) async fn wait_for_proof(
334359
&self,
335360
out_point: &OutPoint,
336-
timeout: Duration,
361+
timeout: Option<Duration>,
337362
) -> Result<dpp::prelude::AssetLockProof, PlatformWalletError> {
338363
use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof;
339364
use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof;
340365
use key_wallet::transaction_checking::TransactionContext;
341366

342367
tracing::info!(outpoint = %out_point, ?timeout, "wait_for_proof: entered");
343-
let deadline = tokio::time::Instant::now() + timeout;
368+
let deadline = timeout.map(|t| tokio::time::Instant::now() + t);
344369
let mut iter: u32 = 0;
345370

346371
// Read account_index and transaction from the tracked lock.
@@ -520,18 +545,26 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
520545
}
521546
}
522547

523-
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
524-
if remaining.is_zero() {
525-
return Err(PlatformWalletError::FinalityTimeout(*out_point));
526-
}
527-
528-
// Wait for a lock event notification or timeout. The
529-
// `notified` future is the one we armed above, so any
530-
// IS/CL event since then is already buffered into it.
531-
tokio::select! {
532-
_ = &mut notified => continue,
533-
_ = tokio::time::sleep(remaining) => {
534-
return Err(PlatformWalletError::FinalityTimeout(*out_point));
548+
// Wait for a lock event notification (or timeout, when one is
549+
// configured). The `notified` future is the one we armed above,
550+
// so any IS/CL event since then is already buffered into it.
551+
match deadline {
552+
Some(dl) => {
553+
let remaining = dl.saturating_duration_since(tokio::time::Instant::now());
554+
if remaining.is_zero() {
555+
return Err(PlatformWalletError::FinalityTimeout(*out_point));
556+
}
557+
tokio::select! {
558+
_ = &mut notified => continue,
559+
_ = tokio::time::sleep(remaining) => {
560+
return Err(PlatformWalletError::FinalityTimeout(*out_point));
561+
}
562+
}
563+
}
564+
// No deadline: wait indefinitely for the next lock event.
565+
None => {
566+
notified.as_mut().await;
567+
continue;
535568
}
536569
}
537570
}

packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,10 +206,15 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
206206
/// registration or top-up via the `_with_signer` SDK methods. The
207207
/// caller passes `derivation_path` to the same signer used for the
208208
/// build phase when the credit output is later consumed on Platform.
209+
///
210+
/// `timeout` is `Option<Duration>` and is only consulted when the lock
211+
/// still needs a proof (`Built` / `Broadcast`): `None` waits
212+
/// **indefinitely** for finality. For `InstantSendLocked` / `ChainLocked`
213+
/// the proof already exists and no wait happens, so the value is moot.
209214
pub async fn resume_asset_lock(
210215
&self,
211216
out_point: &OutPoint,
212-
timeout: Duration,
217+
timeout: Option<Duration>,
213218
) -> Result<(dpp::prelude::AssetLockProof, DerivationPath), PlatformWalletError> {
214219
tracing::info!(outpoint = %out_point, ?timeout, "resume_asset_lock: entered");
215220

packages/rs-platform-wallet/src/wallet/identity/network/registration.rs

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ use dash_sdk::platform::transition::top_up_identity::TopUpIdentity;
6868
use crate::error::{is_instant_lock_proof_invalid, PlatformWalletError};
6969
use crate::wallet::asset_lock::orchestration::{
7070
out_point_from_proof, submit_with_cl_height_retry, FundingResolution, ResolvedFunding,
71-
CL_FALLBACK_TIMEOUT,
7271
};
7372
use crate::wallet::asset_lock::AssetLockFunding;
7473

@@ -184,7 +183,7 @@ impl IdentityWallet {
184183
);
185184
let chain_proof = self
186185
.asset_locks
187-
.upgrade_to_chain_lock_proof(&out_point, CL_FALLBACK_TIMEOUT)
186+
.upgrade_to_chain_lock_proof(&out_point, None)
188187
.await?;
189188
// Recover the credit-output derivation path. The
190189
// asset lock is now CL-attached (status advanced by
@@ -193,10 +192,7 @@ impl IdentityWallet {
193192
// proof branch and just re-derives the path. This is
194193
// cheap (no SPV wait) and avoids duplicating the
195194
// path-derivation logic here.
196-
let (_, path) = self
197-
.asset_locks
198-
.resume_asset_lock(&out_point, CL_FALLBACK_TIMEOUT)
199-
.await?;
195+
let (_, path) = self.asset_locks.resume_asset_lock(&out_point, None).await?;
200196
ResolvedFunding {
201197
proof: chain_proof,
202198
path,
@@ -248,7 +244,7 @@ impl IdentityWallet {
248244
);
249245
let chain_proof = self
250246
.asset_locks
251-
.upgrade_to_chain_lock_proof(&out_point, CL_FALLBACK_TIMEOUT)
247+
.upgrade_to_chain_lock_proof(&out_point, None)
252248
.await?;
253249
submit_with_cl_height_retry(settings, |s| {
254250
placeholder.put_to_platform_and_wait_for_response_with_signer(
@@ -404,12 +400,9 @@ impl IdentityWallet {
404400
);
405401
let chain_proof = self
406402
.asset_locks
407-
.upgrade_to_chain_lock_proof(&out_point, CL_FALLBACK_TIMEOUT)
408-
.await?;
409-
let (_, path) = self
410-
.asset_locks
411-
.resume_asset_lock(&out_point, CL_FALLBACK_TIMEOUT)
403+
.upgrade_to_chain_lock_proof(&out_point, None)
412404
.await?;
405+
let (_, path) = self.asset_locks.resume_asset_lock(&out_point, None).await?;
413406
ResolvedFunding {
414407
proof: chain_proof,
415408
path,
@@ -445,7 +438,7 @@ impl IdentityWallet {
445438
);
446439
let chain_proof = self
447440
.asset_locks
448-
.upgrade_to_chain_lock_proof(&out_point, CL_FALLBACK_TIMEOUT)
441+
.upgrade_to_chain_lock_proof(&out_point, None)
449442
.await?;
450443
submit_with_cl_height_retry(settings, |s| {
451444
identity.top_up_identity_with_signer(

0 commit comments

Comments
 (0)