Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,14 @@ sealed class DashSdkError(
* UTXO one of its own earlier asset locks had already consumed. Peers
* drop such a double spend without replying, so the lock cannot
* confirm while that spender stands and an unbounded proof wait would
* hang. The resume still runs: the sighting bounds that wait instead
* of replacing it, so the lock was (re-)broadcast and waited on (a
* `Broadcast`-status lock was also sent on an earlier call), and this
* is what the bounded wait expired with.
* hang. The resume still attempts recovery. With a ready transport,
* the sighting bounds the proof wait and this is what that wait
* expired with. In the `Broadcast` arm, after a readiness miss and
* pre-dispatch rejection, a still-standing conflict returns
* immediately after refreshing local finality, and the
* readiness-deferred retry owns the next proof wait. A
* `Broadcast`-status lock may also represent an earlier attempt that
* sent the transaction.
*
* The ONLY double-spend verdict the native side emits, and it is
* PROVISIONAL. NO discard licence: keep the tracked lock and retry
Expand Down
2 changes: 1 addition & 1 deletion packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ Fork-era numbers remain in the collision history, which is immutable record.
| ---: | --- | --- | --- |
| 28 | *(reserved — vacated)* | — | Vacated by #4185/#4256 on 2026-08-02; RESERVED, not reissuable — the next-free frontier is the only allocation source |
| 47 | `ErrorAssetLockInputConflict` | #4356 | Proposed — three-layer renumber from 42 **complete** on the branch (Rust value + pin test, Swift raw case, Kotlin arm + test all at 47). Merged #4451 had taken 42 for `ErrorMasternodeWithdrawalUnconfirmed` on 2026-08-22; merged ABI won and #4356 moved. **Reserved-with-no-emitter**: the wallet currently constructs only the provisional 48 — 47 is the terminal discard-licensing verdict, held for a future finalized-ancestry proof the SPV layer does not yet expose. The number is claimed ABI either way; Rule 1 makes 47 unavailable to any other contributor while #4356 is active |
| 48 | `ErrorAssetLockInputContested` | #4356 | Proposed — renumbered from 43 (which active #4313 holds) alongside 47's move. The provisional double-spend verdict the conflict screen always emits: the sighting BOUNDS the proof wait rather than replacing it, so the lock is still (re-)broadcast and waited on, and 48 is emitted only when that bounded wait expires with the conflict still standing; carries no discard licence. Rust value + Swift raw case + Kotlin typed arm and tests all at 48 on the branch |
| 48 | `ErrorAssetLockInputContested` | #4356 | Proposed — renumbered from 43 (which active #4313 holds) alongside 47's move. The provisional double-spend verdict the conflict screen always emits: with a ready transport the sighting bounds the proof wait and 48 reports its expiry; in the `Broadcast` arm, after a readiness miss and pre-dispatch rejection, a still-standing conflict returns immediately after refreshing local finality and the deferred retry owns the next proof wait. Carries no discard licence. Rust value + Swift raw case + Kotlin typed arm and tests all at 48 on the branch |
| 30 | *(reserved — vacated)* | — | Vacated by #4185/#4256 on 2026-08-02; RESERVED, not reissuable — the next-free frontier is the only allocation source |
| 32 | *(reserved — lapsed)* | — | Owner #4310 (successor of fork-era #4247) closed without merging; RESERVED, not reissuable |
| 33 | *(reserved — lapsed)* | — | Owner #4311 (successor of fork-era #4256) closed without merging; RESERVED, not reissuable |
Expand Down
10 changes: 6 additions & 4 deletions packages/rs-platform-wallet-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,10 +473,12 @@ pub enum PlatformWalletFFIResultCode {
/// Maps `PlatformWalletError::AssetLockInputContested`. The double-spend
/// screen's ONLY verdict: a confirmed transaction of this wallet
/// already spent one of the tracked lock's inputs. The resume still
/// ran — the sighting bounds the proof wait instead of replacing it,
/// so the lock was (re-)broadcast and waited on, and this is what that
/// bounded wait expired with. PROVISIONAL — the wallet cannot prove the
/// spender's block is on the finalized branch (see
/// attempts recovery. With a ready transport the sighting bounds the
/// proof wait and this is what that wait expired with; in the `Broadcast`
/// arm, after a readiness miss and pre-dispatch rejection, a still-standing
/// conflict returns immediately after refreshing local finality and leaves
/// the next proof wait to the readiness-deferred retry. PROVISIONAL — the
/// wallet cannot prove the spender's block is on the finalized branch (see
/// [`Self::ErrorAssetLockInputConflict`] (47), the reserved terminal
/// form), so this is what a chainlocked-looking spender reports too.
///
Expand Down
11 changes: 7 additions & 4 deletions packages/rs-platform-wallet/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -492,10 +492,13 @@ pub enum PlatformWalletError {
/// While the sibling stands, peers reject the lock as a double spend
/// and an unbounded proof wait would hang (Core stopped sending BIP61
/// `reject` by default in 0.17, so the drop is silent and looks
/// exactly like a slow network). The sighting therefore bounds the
/// wait rather than replacing it: the resume still (re-)broadcasts and
/// still waits, and this is what the bounded wait expired with — a
/// `Broadcast`-status lock was also already sent on an earlier call.
/// exactly like a slow network). The resume still attempts recovery. If
/// the transport is ready, the sighting bounds the proof wait and this is
/// what that wait expired with. In the `Broadcast` arm, if readiness was
/// missed and the send was rejected before dispatch, a still-standing
/// conflict returns immediately after refreshing local finality, and the
/// readiness-deferred retry owns the next proof wait. A `Broadcast`-status
/// lock may also represent an earlier attempt that sent the transaction.
///
/// The verdict is PROVISIONAL and carries NO licence to discard the
/// tracked lock. Keep the lock and retry later. Note what a retry can
Expand Down
163 changes: 137 additions & 26 deletions packages/rs-platform-wallet/src/wallet/asset_lock/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -878,10 +878,11 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
/// Broadcast half of [`Self::create_funded_asset_lock_proof`] — steps 1–4:
/// build + fund the asset-lock transaction, persist the funding account's
/// address pool, track the lifecycle row, and broadcast. Returns as soon as
/// the transaction is on the wire (status `Broadcast`), BEFORE any proof
/// wait, so a caller can durably record its own bookkeeping for the funded
/// lock (e.g. the inviter-side invitation row) between the broadcast and
/// the potentially long proof wait in
/// the broadcaster accepts the transaction, with the row at `Broadcast` or
/// a later status installed by a concurrent resume, BEFORE any proof wait.
/// This lets a caller durably record its own bookkeeping for the funded lock
/// (e.g. the inviter-side invitation row) between the broadcast and the
/// potentially long proof wait in
/// [`Self::wait_for_funded_asset_lock_proof`].
pub(crate) async fn broadcast_funded_asset_lock<S: ExtendedPubKeySigner>(
&self,
Expand Down Expand Up @@ -1197,12 +1198,12 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
// free, and a rebuild is safe; here the row is still
// tracked and resumable and its inputs are still reserved
// and fenced, so a caller honouring that promise would
// rebuild from other UTXOs and create a SECOND asset lock
// beside a transaction that has either reached the network
// already or is about to. The contract that matches what is
// actually true is the unknown outcome: do not retry, the
// row and its reservation are intact, resume the existing
// lock.
// rebuild from other UTXOs and create a SECOND asset lock.
// The concurrent resume may have sent the transaction or
// may still be committed to dispatch. The contract that
// matches what is actually true is the unknown outcome: do
// not retry; the row and its reservation are intact, so
// resume the existing lock.
//
// The price is that the reservation and the fence outlive
// this call: the fence ends on an observed spend, and no
Expand Down Expand Up @@ -1239,11 +1240,20 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
// inputs are still selectable here until the spend is observed.
in_broadcast_pin.settle_pending_spend();

// 4. Transition to Broadcast and queue the changeset.
let cs_broadcast = self
.advance_asset_lock_status(&out_point, AssetLockStatus::Broadcast, None)
.await?;
self.queue_asset_lock_changeset(cs_broadcast);
// 4. Transition to Broadcast only if no concurrent flow advanced the
// row while this call awaited the network. Replacing a finalized
// status here would retain its proof under the weaker status.
if let Some(cs_broadcast) = self
.advance_asset_lock_status_if(
&out_point,
|current| *current == AssetLockStatus::Built,
AssetLockStatus::Broadcast,
None,
)
.await?
{
self.queue_asset_lock_changeset(cs_broadcast);
}

Ok((path, out_point))
}
Expand Down Expand Up @@ -2069,9 +2079,9 @@ mod tests {
/// Broadcaster that simulates the racing interleave the release gate
/// exists for: "during" the broadcast a concurrent `resume_asset_lock`
/// advances the tracked row to `Broadcast`, then the original call still
/// comes back `Rejected`. The advanced row is positive evidence the
/// transaction reached the network, so the cleanup must keep it AND keep
/// the funding reservation.
/// comes back `Rejected`. The advanced row means another attempt owns the
/// transaction and may still deliver it, so the cleanup must keep it AND
/// keep the funding reservation.
struct RejectAfterConcurrentResumeBroadcaster {
wallet_manager: Arc<RwLock<WalletManager<PlatformWalletInfo>>>,
wallet_id: WalletId,
Expand Down Expand Up @@ -2105,8 +2115,8 @@ mod tests {
/// The error must say the same thing the cleanup did. The definite
/// rejection promises a released reservation and a safe rebuild, and
/// neither holds on this branch: a caller acting on that promise builds
/// a second asset lock beside a transaction the advance says reached the
/// network. Only the unknown outcome describes what actually happened.
/// a second asset lock beside a transaction another attempt may deliver.
/// Only the unknown outcome describes what actually happened.
#[tokio::test]
async fn rejected_broadcast_racing_concurrent_resume_keeps_row_and_reservation() {
let (wallet_manager, wallet_id, _balance, signer) =
Expand Down Expand Up @@ -2187,6 +2197,107 @@ mod tests {
);
}

/// Simulates a resume attaching finality while the create path is waiting
/// for its broadcast result.
struct FinalizeDuringCreateBroadcast {
wallet_manager: Arc<RwLock<WalletManager<PlatformWalletInfo>>>,
wallet_id: WalletId,
proof: Mutex<Option<dpp::prelude::AssetLockProof>>,
}

#[async_trait]
impl TransactionBroadcaster for FinalizeDuringCreateBroadcast {
async fn broadcast(&self, transaction: &Transaction) -> Result<Txid, BroadcastError> {
use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof;

let mut wm = self.wallet_manager.write().await;
let lock = wm
.get_wallet_info_mut(&self.wallet_id)
.expect("wallet present")
.tracked_asset_locks
.values_mut()
.next()
.expect("row tracked before broadcast");
assert_eq!(lock.status, AssetLockStatus::Built);
let proof = dpp::prelude::AssetLockProof::Chain(ChainAssetLockProof {
core_chain_locked_height: 1_234,
out_point: lock.out_point,
});
lock.status = AssetLockStatus::ChainLocked;
lock.proof = Some(proof.clone());
*self.proof.lock().expect("staged proof mutex") = Some(proof);
Ok(transaction.txid())
}
}

/// A successful create broadcast must not overwrite a status and proof
/// that a concurrent resume already advanced beyond `Built`.
#[tokio::test]
async fn create_broadcast_does_not_downgrade_a_concurrently_finalized_row() {
let (wallet_manager, wallet_id, _balance, signer) =
funded_wallet_manager(StandardAccountType::BIP44Account).await;
let broadcaster = Arc::new(FinalizeDuringCreateBroadcast {
wallet_manager: Arc::clone(&wallet_manager),
wallet_id,
proof: Mutex::new(None),
});
let persistence = Arc::new(CapturingPersistence::default());
let manager = AssetLockManager::new(
Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")),
Arc::clone(&wallet_manager),
wallet_id,
Arc::new(Notify::new()),
Arc::clone(&broadcaster),
WalletPersister::new(
wallet_id,
Arc::clone(&persistence) as Arc<dyn PlatformWalletPersistence>,
),
);

let (_path, out_point) = manager
.broadcast_funded_asset_lock(
1_000_000,
0,
AssetLockFundingType::IdentityRegistration,
0,
&signer,
)
.await
.expect("accepted create broadcast");
let staged_proof = broadcaster
.proof
.lock()
.expect("staged proof mutex")
.clone()
.expect("proof staged during broadcast");

{
let wm = wallet_manager.read().await;
let lock = wm
.get_wallet_info(&wallet_id)
.expect("wallet present")
.tracked_asset_locks
.get(&out_point)
.expect("row stays tracked");
assert_eq!(lock.status, AssetLockStatus::ChainLocked);
assert_eq!(lock.proof.as_ref(), Some(&staged_proof));
}

let stored = persistence
.stored
.lock()
.expect("capturing persistence mutex");
let persisted_downgrade = stored
.iter()
.filter_map(|changeset| changeset.asset_locks.as_ref())
.filter_map(|changeset| changeset.asset_locks.get(&out_point))
.any(|entry| entry.status == AssetLockStatus::Broadcast && entry.proof.is_some());
assert!(
!persisted_downgrade,
"no persisted snapshot may combine Broadcast with an attached proof"
);
}

/// Persistence stub whose FIRST address-pool store blocks on a 2-party
/// barrier until the test arrives, holding that build inside its persist
/// while the other build runs. Later stores pass straight through.
Expand Down Expand Up @@ -2483,12 +2594,12 @@ mod tests {
);
}

/// The broadcast half returns as soon as the transaction is on the wire:
/// the tracked row is `Broadcast` (recoverable/resumable) and the
/// invitation funding pool was persisted AND flushed — all BEFORE any
/// proof wait (the test completing at all proves no SPV wait ran), so a
/// caller can durably record its own bookkeeping for the funded lock
/// between the broadcast and the proof wait.
/// The broadcast half returns when the broadcaster accepts the transaction:
/// the tracked row is `Broadcast` (recoverable/resumable) and the invitation
/// funding pool was persisted AND flushed — all BEFORE any proof wait (the
/// test completing at all proves no SPV wait ran), so a caller can durably
/// record its own bookkeeping for the funded lock between the broadcast and
/// the proof wait.
#[tokio::test]
async fn broadcast_half_leaves_broadcast_row_and_flushed_pool() {
let persistence = Arc::new(CapturingPersistence::default());
Expand Down
Loading
Loading