Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2687ae0
fix(platform-wallet): fail a double-spending asset lock with a typed …
QuantumExplorer Aug 10, 2026
7d9be71
fix(platform-wallet): report spender finality and carry the conflict …
QuantumExplorer Aug 11, 2026
9c955dc
fix(platform-wallet): give the conflict screen a source that survives…
romchornyi Aug 19, 2026
aaed39d
fix(platform-wallet): reserve the terminal conflict verdict for a cha…
QuantumExplorer Aug 19, 2026
679e860
fix(swift-sdk): make every isSpent writer monotonic, and tidy the rev…
QuantumExplorer Aug 19, 2026
b43dcb1
Merge remote-tracking branch 'origin/v4.2-dev' into pr-4356-work
QuantumExplorer Aug 19, 2026
ea40dd6
fix(platform-wallet-ffi): initialize the test entry as a struct literal
QuantumExplorer Aug 19, 2026
6731994
fix(platform-wallet): restore the spender records themselves and reti…
QuantumExplorer Aug 19, 2026
15a1cb6
fix(swift-sdk): publish the catch-up verdict the moment its task returns
QuantumExplorer Aug 19, 2026
a896e1f
fix(swift-sdk): hand the catch-up task a MainActor publish closure, n…
QuantumExplorer Aug 19, 2026
db2d890
fix(platform-wallet): survive the promotion-eviction and reconcile ev…
QuantumExplorer Aug 19, 2026
8b65c41
fix(platform-wallet): restored evidence stays provisional, seeds earl…
QuantumExplorer Aug 19, 2026
086fdd5
Merge origin/v4.2-dev: renumber the conflict verdicts to 47/48
QuantumExplorer Aug 28, 2026
0fc0845
fix(platform-wallet): the conflict screen emits only the provisional …
QuantumExplorer Aug 28, 2026
7cdb0b5
fix(platform-wallet): a conflict sighting bounds the resume, it never…
shumkov Aug 29, 2026
4c24207
Merge remote-tracking branch 'origin/v4.2-dev' into claude/nifty-shte…
shumkov Aug 30, 2026
9d5c0e2
fix(platform-wallet): initialize observed_input_conflicts in the fold…
shumkov Aug 30, 2026
9de7db7
Merge remote-tracking branch 'origin/v4.2-dev' into claude/nifty-shte…
shumkov Aug 30, 2026
717510c
fix(platform-wallet): bound every resumed asset lock
shumkov Aug 31, 2026
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.
///
/// 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
Loading
Loading