Skip to content
Open
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 @@ -115,6 +115,29 @@ sealed class DashSdkError(
class AssetLockFundingMismatch(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* `ErrorAssetLockInputConflict` (native code 42). The tracked
* asset-lock transaction spends an outpoint that a different,
* already-confirmed transaction of the same wallet spent first —
* typically a restored wallet whose rescan resurrected a UTXO one of
* its own earlier asset locks had already consumed. Peers drop such a
* double spend without replying, so the lock can never confirm and its
* proof wait would hang. The conflict screen stops the current resume
* before it broadcasts again or enters the proof wait (a
* `Broadcast`-status lock was sent on an earlier call).
*
* TERMINAL and NOT retryable: this is the one code that lets a host
* offer to discard the asset lock and rebuild it from currently-unspent
* inputs — a fund-safe action, because the confirmed spender is this
* wallet's own transaction, so the value either stays in the sibling
* or (after a freak reorg) returns to the spendable set. Its absence is
* not proof of liveness: the Rust-side scan cannot see conflicts whose
* spender was already pruned. The Android analog of Swift's
* `PlatformWalletError.assetLockInputConflict`.
*/
class AssetLockInputConflict(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* `ErrorShieldedNoRecordedAnchor` (native code 19). A shielded spend
* could not be built against a Platform-recorded anchor because the
Expand Down Expand Up @@ -526,6 +549,7 @@ sealed class DashSdkError(
}.getOrNull()
} ?: PlatformWallet.Generic(code, message, cause)
41 -> PlatformWallet.PlatformShieldCapacityExceeded(message, cause)
42 -> PlatformWallet.AssetLockInputConflict(message, cause) // ErrorAssetLockInputConflict
// ErrorSigningKeyUnavailable — the STRUCTURED signer
// discriminator (dashpay/platform#4060 finding 7): the typed
// completion code rides the whole Rust round-trip, no message
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,35 @@ class DashSdkErrorTest {
)
}

@Test
fun assetLockInputConflictCode42MapsTyped() {
// TERMINAL: the one platform-wallet code that authorises a host to
// discard a tracked asset lock (fund-safe — the confirmed spender is
// the wallet's own transaction). It must never fall through to
// Generic, or the host is left waiting on a lock that can never
// confirm.
val message =
"Asset lock a:0 can never confirm: it spends b:1, which was already spent by " +
"confirmed transaction c (block height Some(1234), chainlocked: false) — " +
"the lock is a double spend and no peer will relay it"
val mapped = DashSdkError.fromNative(
DashSDKException(
DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 42,
message,
),
)

assertTrue(
"code 42 must not fall through to Generic",
mapped is DashSdkError.PlatformWallet.AssetLockInputConflict,
)
assertEquals(message, mapped.message)
assertFalse(
"AssetLockInputConflict is terminal — rebuild from unspent inputs, do not retry",
mapped.isRetryable,
)
}

@Test
fun signingKeyUnavailableCode31MapsTyped() {
// The STRUCTURED discriminator (dashpay/platform#4060 finding 7):
Expand Down
17 changes: 13 additions & 4 deletions packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::error::*;
use crate::handle::*;
use crate::runtime::runtime;
use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return};
use platform_wallet::PlatformWalletError;
use std::ffi::CString;
use std::os::raw::c_char;
use std::time::Duration;
Expand Down Expand Up @@ -146,10 +147,18 @@ pub unsafe extern "C" fn asset_lock_manager_catch_up_blocking(
error = %e,
"asset_lock_manager_catch_up_blocking: resume_asset_lock failed"
);
PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
format!("{}", e),
)
match e {
// Terminal double spend: route through the typed conversion
// so the host still receives ErrorAssetLockInputConflict
// (42) — the one code that authorises discarding a tracked
// lock. Flattening it to ErrorWalletOperation here would
// leave the host with a spinner it can never resolve.
conflict @ PlatformWalletError::AssetLockInputConflict { .. } => conflict.into(),
other => PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
format!("{}", other),
),
}
}
}
}
Expand Down
85 changes: 85 additions & 0 deletions packages/rs-platform-wallet-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ pub enum PlatformWalletFFIResultCode {
// 38 ErrorDocumentPriceChanged DPNS username marketplace
// 39 ErrorInsufficientIdentityCredits DPNS username marketplace
// 40 ErrorContestedNameNotTradable DPNS username marketplace
// 41 ErrorShieldedInsufficientBalance Platform→Shielded capacity preflight
// 42 ErrorAssetLockInputConflict asset-lock double-spend detection
//
// 38/39/40 carry a STABLE JSON detail object in the result `message`
// instead of the typed `Display` rendering — see each variant's doc for
Expand Down Expand Up @@ -369,6 +371,39 @@ pub enum PlatformWalletFFIResultCode {
/// shortfall, not a shielded-note shortfall.
ErrorShieldedInsufficientBalance = 41,

/// Maps `PlatformWalletError::AssetLockInputConflict`. The tracked
/// asset-lock transaction spends an outpoint that a different,
/// already-confirmed transaction of the same wallet spent first — the
/// classic restored-wallet failure, where a rescan resurrects a UTXO
/// the wallet's own earlier asset lock had long since consumed. Such a
/// transaction is a double spend: peers drop it at the mempool
/// boundary and send nothing back (no BIP61 `reject`), so it can never
/// be mined or IS-locked and the resume's proof wait would hang
/// indefinitely.
///
/// TERMINAL, and the only code here that authorises a host to discard
/// a tracked asset lock: this resume broadcast nothing and no retry of
/// this outpoint can ever succeed while the confirmed spender stands.
/// The remedy is to drop the lock and build a new one from
/// currently-unspent inputs — a fund-safe action either way, because
/// the conflicting spender is necessarily this wallet's own
/// transaction (only this wallet can sign its outpoints): the value
/// lives in the sibling, and even a freak reorg that removed the
/// sibling would simply return the inputs to the spendable set.
/// Contrast `ErrorTransactionBroadcastUnconfirmed`, where the tx may
/// well be alive and discarding it would strand real funds.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +384 to +394

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Correct the Broadcast-state description

Code 41 can be returned for both Built and Broadcast locks. By definition, a Broadcast lock was sent during an earlier call, and resume_asset_lock normally performs a defensive rebroadcast for that state. The statement that "nothing was broadcast, nothing is in flight" is therefore false and can mislead hosts about the lock's history. State instead that conflict detection prevents the current resume from performing an additional broadcast or entering the proof wait. Apply the same correction to PlatformWalletResult.swift:145-148 and PlatformWalletResult.swift:419-425.

source: ['coderabbit']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — Correct the Broadcast-state description no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

///
/// Raised only on a positive detection; its ABSENCE is not a liveness
/// signal. The wallet-side scan reads confirmed records still held in
/// memory, and under the default `keep-finalized-transactions = OFF`
/// build those are pruned once chainlocked, so an old conflict can go
/// unseen and surface as the usual finality timeout instead.
///
/// Message: the typed `Display` rendering, which names the asset-lock
/// outpoint, the conflicting input, the confirmed spender's txid, and
/// the spender's finality (chainlocked or merely in a block).
ErrorAssetLockInputConflict = 42,

/// The named thing does not exist.
///
/// Originally (and still mostly) the code for every `Option` returned as an
Expand Down Expand Up @@ -621,6 +656,13 @@ impl From<PlatformWalletError> for PlatformWalletFFIResult {
PlatformWalletError::AssetLockFundingMismatch { .. } => {
PlatformWalletFFIResultCode::ErrorAssetLockFundingMismatch
}
// Terminal double spend. Distinct from every other asset-lock
// code because it is the one that tells a host the lock is dead
// rather than pending: without it this reached `ErrorUnknown`,
// which no host may act on destructively.
PlatformWalletError::AssetLockInputConflict { .. } => {
PlatformWalletFFIResultCode::ErrorAssetLockInputConflict
}
// A quiesce/drain barrier that did not complete within budget
// (clear/reset paths). The host must fail closed: keep its
// callback context alive and skip any paired persistence wipe.
Expand Down Expand Up @@ -1584,6 +1626,49 @@ mod tests {
);
}

/// The terminal double-spend verdict is the one code a host may act on
/// destructively (discard the tracked lock), so both halves of the
/// contract are pinned: the number the Swift/Kotlin mirrors decode, and
/// the conversion that keeps it from flattening to `ErrorUnknown`. The
/// message must carry the typed `Display` — including the spender's
/// finality — since that is the only detail channel the frozen
/// `{ code, message }` ABI has.
#[test]
fn asset_lock_input_conflict_code_is_pinned_at_42() {
use dashcore::OutPoint;

assert_eq!(
PlatformWalletFFIResultCode::ErrorAssetLockInputConflict as i32,
42
);

let out_point = OutPoint::null();
let result: PlatformWalletFFIResult = PlatformWalletError::AssetLockInputConflict {
out_point,
input: OutPoint {
txid: out_point.txid,
vout: 3,
},
spent_by: out_point.txid,
height: Some(1_234),
spender_chain_locked: true,
}
.into();
assert_eq!(
result.code,
PlatformWalletFFIResultCode::ErrorAssetLockInputConflict
);
let message = message_of(&result);
assert!(
message.contains("can never confirm"),
"the typed Display must survive the conversion: {message}"
);
assert!(
message.contains("chainlocked: true"),
"the spender's finality must reach the host: {message}"
);
}

/// `MessageSigningFailed` is intentionally unmapped: its causes are
/// internal invariant breaks, which should read as a bug rather than as a
/// key-repair prompt, so it falls through to ErrorUnknown carrying the
Expand Down
44 changes: 43 additions & 1 deletion packages/rs-platform-wallet-ffi/src/shielded_send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -617,13 +617,21 @@ fn map_spend_result(
/// boundary while keeping every other funding failure on the existing generic
/// error path. The wallet retains nonterminal consumption-unknown state; the
/// host must not interpret this code as authenticated completion.
///
/// The terminal double-spend report rides the same typed conversion (both the
/// fresh-build and resume entry points funnel through here, and the resume is
/// where the pre-broadcast conflict screen actually fires). Its
/// `ErrorAssetLockInputConflict` (42) is the only code that authorises a host
/// to discard a tracked lock, so flattening it to `ErrorWalletOperation` would
/// strand the user on a lock that can never confirm.
fn map_asset_lock_funding_result(
result: Result<(), PlatformWalletError>,
operation: &str,
) -> PlatformWalletFFIResult {
match result {
Ok(()) => PlatformWalletFFIResult::ok(),
Err(e @ PlatformWalletError::AssetLockAlreadyConsumed(_)) => e.into(),
Err(e @ PlatformWalletError::AssetLockInputConflict { .. }) => e.into(),
Err(e) => PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
format!("{operation} failed: {e}"),
Expand Down Expand Up @@ -1852,8 +1860,13 @@ mod tests {
);
}

/// The two terminal asset-lock verdicts keep their own codes through
/// this wrapper — both funding entry points (fresh build and resume)
/// flatten everything else to `ErrorWalletOperation`, and a host that
/// saw the flattened code could neither hold the consumption-unknown
/// state nor offer to discard a lock that can never confirm.
#[test]
fn map_asset_lock_funding_result_preserves_already_consumed_code_only() {
fn map_asset_lock_funding_result_preserves_terminal_asset_lock_codes() {
let out_point = dashcore::OutPoint {
txid: dashcore::Txid::all_zeros(),
vout: 7,
Expand All @@ -1868,6 +1881,35 @@ mod tests {
);
assert!(message_of(&result).contains("Platform completion is unconfirmed"));

// The resume endpoint is where the pre-broadcast conflict screen
// fires, and it funnels through this same wrapper.
let conflict = map_asset_lock_funding_result(
Err(PlatformWalletError::AssetLockInputConflict {
out_point,
input: dashcore::OutPoint {
txid: dashcore::Txid::all_zeros(),
vout: 3,
},
spent_by: dashcore::Txid::all_zeros(),
height: Some(1_234),
spender_chain_locked: false,
}),
"shielded resume fund-from-asset-lock",
);
assert_eq!(
conflict.code,
PlatformWalletFFIResultCode::ErrorAssetLockInputConflict
);
let conflict_message = message_of(&conflict);
assert!(
conflict_message.contains("can never confirm"),
"the typed Display must survive the wrapper: {conflict_message}"
);
assert!(
conflict_message.contains("chainlocked: false"),
"the spender's finality must reach the host: {conflict_message}"
);

let unrelated = map_asset_lock_funding_result(
Err(PlatformWalletError::ShieldedNoUnspentNotes),
"shielded fund-from-asset-lock",
Expand Down
55 changes: 54 additions & 1 deletion packages/rs-platform-wallet/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use dpp::address_funds::PlatformAddress;
use dpp::consensus::state::address_funds::AddressInvalidNonceError;
use dpp::fee::Credits;
use dpp::identifier::Identifier;
use dpp::prelude::AddressNonce;
use dpp::prelude::{AddressNonce, CoreBlockHeight};
use key_wallet::account::StandardAccountType;
use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType;
use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference;
Expand Down Expand Up @@ -282,6 +282,59 @@ pub enum PlatformWalletError {
actual_identity_index: u32,
},

/// The tracked asset-lock transaction spends an outpoint that a
/// **different, already-confirmed** transaction of this same wallet
/// spent first. The lock is permanently dead: every peer rejects it
/// as a double spend at the mempool boundary and therefore relays
/// nothing, so no IS-lock and no ChainLock can ever be produced for
/// it. Peers do not answer with BIP61 `reject` (Core stopped sending
/// those by default in 0.17), so the drop is silent — without this
/// variant the condition is indistinguishable from "the network is
/// slow", and the wallet's proof wait (unbounded for the user-facing
/// funding flows) simply never returns.
///
/// The typical origin is a restored wallet: a rescan repopulates the
/// UTXO set from chain data, an asset-lock build selects an input the
/// restored view still believes is unspent, and the transaction that
/// actually spent it — often one of the wallet's own earlier asset
/// locks — has been confirmed for a long time.
///
/// Terminal, not retryable: the funds behind `input` are gone into
/// `spent_by`, so the only recovery is to discard this lock and build
/// a new one from currently-unspent inputs. `height` is the block
/// height of the confirmed spender when the record carries block info,
/// and `spender_chain_locked` reports whether that spender has reached
/// ChainLock finality — hosts show it as confidence, never as a gate.
///
/// A merely-`InBlock` spender is enough to condemn the lock, and the
/// verdict stays fund-safe even then. Confirmed spends of one outpoint
/// are mutually exclusive, and the spender is necessarily this wallet's
/// OWN transaction — only this wallet can sign its outpoints — so the
/// value is never lost by discarding the conflicted lock: it either
/// lives on in the sibling, or, in the freak case where a reorg unmines
/// the sibling, the inputs simply return to this wallet's spendable set
/// and fund a fresh lock. Waiting for `spender_chain_locked` before
/// reporting would buy no safety and would in practice never fire (see
/// the detection helper).
///
/// Raising this error is a definite verdict; NOT raising it proves
/// nothing — see the detection helper in
/// `wallet::asset_lock::sync::recovery` for why the scan is
/// best-effort.
#[error(
"Asset lock {out_point} can never confirm: it spends {input}, which was \
already spent by confirmed transaction {spent_by} (block height \
{height:?}, chainlocked: {spender_chain_locked}) — the lock is a \
double spend and no peer will relay it"
)]
AssetLockInputConflict {
out_point: dashcore::OutPoint,
input: dashcore::OutPoint,
spent_by: dashcore::Txid,
height: Option<CoreBlockHeight>,
spender_chain_locked: bool,
},

#[error("SDK error: {0}")]
Sdk(#[from] dash_sdk::Error),

Expand Down
Loading
Loading