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,49 @@ 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)

/**
* `ErrorAssetLockInputContested` (native code 43). The provisional
* sibling of [AssetLockInputConflict]: a confirmed transaction of
* this wallet already spent one of the tracked lock's inputs, so
* the resume stopped before broadcasting into a wait that cannot
* return — but that spender sits in an ordinary block a
* reorganization can still drop, so the verdict is NOT final.
*
* NO discard licence: keep the tracked lock and retry later (next
* launch, or after the next chainlock). The situation resolves
* itself — the sibling gets chainlock-buried and the next resume
* reports the terminal code 42, or a reorg drops the sibling and
* the next resume proceeds normally. The Android analog of Swift's
* `PlatformWalletError.assetLockInputContested`.
*/
class AssetLockInputContested(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause) {
override val isRetryable: Boolean get() = true
}

/**
* `ErrorShieldedNoRecordedAnchor` (native code 19). A shielded spend
* could not be built against a Platform-recorded anchor because the
Expand Down Expand Up @@ -526,6 +569,8 @@ sealed class DashSdkError(
}.getOrNull()
} ?: PlatformWallet.Generic(code, message, cause)
41 -> PlatformWallet.PlatformShieldCapacityExceeded(message, cause)
42 -> PlatformWallet.AssetLockInputConflict(message, cause) // ErrorAssetLockInputConflict
43 -> PlatformWallet.AssetLockInputContested(message, cause) // ErrorAssetLockInputContested
// 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,64 @@ 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: true) — " +
"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 assetLockInputContestedCode43MapsTypedAndRetryable() {
// PROVISIONAL: the confirmed spender is not yet chainlocked, so its
// block can still reorg away. The host keeps the tracked lock and
// retries later — it must never treat this as the terminal 42's
// discard licence, and it must never fall through to Generic.
val message =
"Asset lock a:0 cannot currently confirm: it spends b:1, which confirmed " +
"transaction c (block height Some(1234)) has taken — but that spender is " +
"not yet chainlocked, so the verdict is provisional; keep the lock and " +
"retry after the next chainlock"
val mapped = DashSdkError.fromNative(
DashSDKException(
DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 43,
message,
),
)

assertTrue(
"code 43 must not fall through to Generic",
mapped is DashSdkError.PlatformWallet.AssetLockInputContested,
)
assertEquals(message, mapped.message)
assertTrue(
"AssetLockInputContested is provisional — keep the lock and retry later",
mapped.isRetryable,
)
}

@Test
fun signingKeyUnavailableCode31MapsTyped() {
// The STRUCTURED discriminator (dashpay/platform#4060 finding 7):
Expand Down
21 changes: 17 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,22 @@ 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 {
// Double-spend verdicts route through the typed conversion
// so the host receives the real code: terminal
// ErrorAssetLockInputConflict (42) — the one code that
// authorises discarding a tracked lock — or the
// provisional ErrorAssetLockInputContested (43), which
// stops the wait but keeps the lock for a later retry.
// Flattening either to ErrorWalletOperation would leave
// the host with a spinner it can never resolve.
conflict @ (PlatformWalletError::AssetLockInputConflict { .. }
| PlatformWalletError::AssetLockInputContested { .. }) => conflict.into(),
other => PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
format!("{}", other),
),
}
}
}
}
Expand Down
113 changes: 113 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,9 @@ 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 (terminal)
// 43 ErrorAssetLockInputContested asset-lock double-spend detection (provisional)
//
// 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 +372,64 @@ 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 performed no additional broadcast
/// (a `Broadcast`-status lock was sent on an earlier call), and the
/// spender that took the input has reached ChainLock finality — its
/// block can never be reorganised away, so no retry of this outpoint
/// can ever succeed. The remedy is to drop the lock and build a new
/// one from currently-unspent inputs — fund-safe, because the
/// conflicting spender is necessarily this wallet's own transaction
/// (only this wallet can sign its outpoints): the value lives on in
/// the sibling. Contrast `ErrorTransactionBroadcastUnconfirmed`, where
/// the tx may well be alive and discarding it would strand real funds.
///
/// A confirmed-but-not-chainlocked spender reports
/// [`Self::ErrorAssetLockInputContested`] (43) instead — same
/// stopped-wait, NO discard licence — so this code's finality claim
/// is structural, not advisory.
///
/// 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 (always chainlocked for this code).
ErrorAssetLockInputConflict = 42,

/// Maps `PlatformWalletError::AssetLockInputContested`. Same detection
/// as [`Self::ErrorAssetLockInputConflict`] — a confirmed transaction
/// of this wallet already spent one of the tracked lock's inputs, so
/// the resume stopped without a further broadcast or a wait that cannot
/// return — but the spender sits in an ordinary block a
/// reorganisation can still drop, so the verdict is PROVISIONAL.
///
/// NOT a discard licence. The host keeps the tracked lock and retries
/// later (next launch, or after the next chainlock). The situation
/// resolves itself: either the sibling gets buried by a chainlock and
/// the next resume reports the terminal 42, or a reorg drops the
/// sibling and the next resume proceeds normally. Discarding tracking
/// state on this code risks stranding a lock that a replayed
/// broadcast could still confirm.
///
/// Message: the typed `Display` rendering, which names the asset-lock
/// outpoint, the conflicting input, the confirmed spender's txid and
/// height, and says the verdict is provisional.
ErrorAssetLockInputContested = 43,

/// The named thing does not exist.
///
/// Originally (and still mostly) the code for every `Option` returned as an
Expand Down Expand Up @@ -621,6 +682,16 @@ 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
}
PlatformWalletError::AssetLockInputContested { .. } => {
PlatformWalletFFIResultCode::ErrorAssetLockInputContested
}
// 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 +1655,48 @@ 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),
}
.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