diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index b9b15168467..d5f32274deb 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -609,7 +609,7 @@ "host": "kotlin", "kind": "unit", "file": "packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/InvitationReclaimLogicTest.kt", - "id": "consensusConsumedWithMarkerIsExplicitlyAmbiguousNeverReclaimed", + "id": "consensusConsumedWithMarkerIsUnknown", "command": "cd packages/kotlin-sdk && ./gradlew :app:testDebugUnitTest --tests '*InvitationReclaimLogicTest*'", "covers_restart": false }, diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/InvitationReclaimLogic.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/InvitationReclaimLogic.kt index f3be8de485b..7e853669829 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/InvitationReclaimLogic.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/InvitationReclaimLogic.kt @@ -11,23 +11,13 @@ import org.dashfoundation.dashsdk.errors.DashSdkError */ object InvitationReclaimLogic { - /** The terminal state a reclaim attempt resolves to. */ + /** The state a failed reclaim attempt resolves to. */ enum class ReclaimOutcome { /** - * This wallet retained a tombstone written after its successful - * local consume, so the interrupted reclaim recovers definitively. + * The lock was reported as consumed, but neither consumption nor this + * reclaim's completion is authenticated. */ - RECLAIMED, - - /** The voucher was consumed with no local attempt in flight — a foreign claim. */ - CLAIMED, - - /** - * Provably consumed (deterministic Platform rejection), but our own - * in-flight attempt makes the consumer ambiguous. Resolves to the - * conservative terminal Claimed, never an inferred Reclaimed. - */ - CONSUMED_AMBIGUOUS, + CONSUMPTION_UNKNOWN, /** * The wallet no longer tracks the voucher lock and our own attempt @@ -42,25 +32,15 @@ object InvitationReclaimLogic { /** * Pure decision for the reclaim failure path. A typed wallet - * [DashSdkError.PlatformWallet.AssetLockAlreadyConsumed] comes from a - * retained local tombstone written only after this wallet's successful - * consume, and therefore recovers Reclaimed. Consensus wording is only - * proof the lock is consumed — not who consumed it — so the prior - * in-flight marker splits that fallback into a foreign claim vs an - * explicitly ambiguous consumption. + * [DashSdkError.PlatformWallet.AssetLockAlreadyConsumed] and the legacy + * consensus wording are both consumption-unknown signals. Code 24 cannot + * distinguish a local tombstone from an unauthenticated remote report. */ fun classifyReclaimFailure( error: Throwable, hadPriorReclaimInFlight: Boolean, ): ReclaimOutcome { - if (isLocallyConsumedTombstone(error)) return ReclaimOutcome.RECLAIMED - if (isAlreadyConsumed(error.message.orEmpty())) { - return if (hadPriorReclaimInFlight) { - ReclaimOutcome.CONSUMED_AMBIGUOUS - } else { - ReclaimOutcome.CLAIMED - } - } + if (isAlreadyConsumed(error)) return ReclaimOutcome.CONSUMPTION_UNKNOWN // A retry after our own crash-interrupted consume can also fail // LOCALLY ("…is not tracked") — before Platform. Consistent with the // consume having landed but not proof of it, so it gets its own @@ -79,7 +59,7 @@ object InvitationReclaimLogic { * proof the consume never started, so the freshly-set marker is stale * (leaving it would degrade an identical retry into the two-attempt * false-ambiguity outcome). Every other error keeps the marker so a later - * "already consumed" stays classified as ambiguous. + * "already consumed" retains its consumption-unknown recovery state. */ fun shouldClearInFlightMarker( error: Throwable, @@ -87,26 +67,20 @@ object InvitationReclaimLogic { ): Boolean = !hadPriorReclaimInFlight && isLockNoLongerTracked(error.message.orEmpty()) /** - * The wallet's typed local consumed tombstone — written only after this - * wallet's own successful consume, so it can safely recover Reclaimed. - */ - fun isLocallyConsumedTombstone(error: Throwable): Boolean = - error is DashSdkError.PlatformWallet.AssetLockAlreadyConsumed - - /** - * The deterministic consensus 10504 rejection. Matched on the exact + * Legacy consensus-10504 compatibility fallback. Matched on the exact * canonical Display phrase of * `IdentityAssetLockTransactionOutPointAlreadyConsumedError` ONLY — - * broader phrases would widen false-positive risk (misclassifying an - * unrelated failure as a benign "already claimed" wrongly flips the row - * to Claimed). The typed tombstone above is the primary signal; this - * wording is the compatibility fallback for errors originating below the - * typed wallet boundary. (A typed FFI code for 10504 is a known - * follow-up shared with iOS.) + * broader phrases would widen false-positive risk. The report is not + * authenticated and never proves the requested operation completed. The + * typed code is the primary signal; wording remains for older boundaries. */ fun isAlreadyConsumed(message: String): Boolean = message.lowercase().contains("already completely used") + fun isAlreadyConsumed(error: Throwable): Boolean = + error is DashSdkError.PlatformWallet.AssetLockAlreadyConsumed || + isAlreadyConsumed(error.message.orEmpty()) + /** * The wallet's LOCAL "asset lock … is not tracked" resume-guard failure * — distinct from the network's already-consumed rejection; used only diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ReclaimInvitationSheet.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ReclaimInvitationSheet.kt index 1ea47402bf0..dba53d47d68 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ReclaimInvitationSheet.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ReclaimInvitationSheet.kt @@ -47,8 +47,7 @@ import org.dashfoundation.example.util.toHex * - In-memory [isReclaiming] single-flights submit AND dismissal — the * persisted `reclaimInFlight` marker is crash forensics, never the * concurrency guard (a Room-Flow re-emit recomposes this sheet; an - * unguarded second consume would let the loser's classifier overwrite - * Reclaimed with Claimed). + * unguarded second consume could overwrite the first attempt's state). * - The marker is persisted (and the write MUST succeed) only immediately * before the on-chain consume; the register arm's key pre-persist runs * BEFORE the marker so a purely local failure never strands one. @@ -118,8 +117,8 @@ fun ReclaimInvitationSheet( // The write must SUCCEED before the consume may run (an // unpersisted marker + consume + crash would strand the // row). The persisted prior value is captured first: it is - // what downgrades a later "already consumed" from - // "provably a foreign claim" to "explicitly ambiguous". + // retained as crash evidence when a later submission + // reports consumption but cannot prove the operation. suspend fun markInFlight() { hadPriorReclaimInFlight = dao.getByOutPointHex(hex)?.reclaimInFlight ?: false @@ -191,26 +190,13 @@ fun ReclaimInvitationSheet( onClose() } catch (t: Throwable) { when (InvitationReclaimLogic.classifyReclaimFailure(t, hadPriorReclaimInFlight)) { - ReclaimOutcome.RECLAIMED -> { - dao.setStatusAndMarker(hex, 2, false, System.currentTimeMillis()) - infoMessage = "This invitation was already reclaimed by this " + - "wallet. The credits were delivered to the target selected " + - "for that reclaim." - } - ReclaimOutcome.CLAIMED -> { - // Neutral copy — the claimant is intentionally not named. - dao.setStatusAndMarker(hex, 1, false, System.currentTimeMillis()) - infoMessage = "This invitation was already claimed." - } - ReclaimOutcome.CONSUMED_AMBIGUOUS -> { - // Provably consumed, but attribution is unknowable with - // our own attempt in flight — conservative terminal - // Claimed, never an inferred Reclaimed. - dao.setStatusAndMarker(hex, 1, false, System.currentTimeMillis()) - infoMessage = "This invitation was already consumed — by the " + - "invitee's claim, or possibly by your own earlier " + - "interrupted reclaim. If that reclaim went through, the " + - "credits were delivered to the target you selected then." + ReclaimOutcome.CONSUMPTION_UNKNOWN -> { + // Code 24 can mean either a retained local tombstone or + // an unauthenticated Platform report. Retain status and + // the in-flight marker because completion is unknown. + errorMessage = "This asset lock was reported as already used, but " + + "the wallet could not verify whether this reclaim completed. " + + "Sync and check the selected target before retrying." } ReclaimOutcome.UNTRACKED_AFTER_OWN_ATTEMPT -> { // No on-chain proof of consumption at all — status and diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/InvitationReclaimLogicTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/InvitationReclaimLogicTest.kt index e58578c5ed9..c7c41680c1c 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/InvitationReclaimLogicTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/InvitationReclaimLogicTest.kt @@ -30,21 +30,21 @@ class InvitationReclaimLogicTest { // ── Outcome matrix ──────────────────────────────────────────────── @Test - fun typedTombstoneRecoversReclaimedRegardlessOfMarker() { + fun typedAlreadyConsumedIsUnknownRegardlessOfMarker() { assertEquals( - ReclaimOutcome.RECLAIMED, + ReclaimOutcome.CONSUMPTION_UNKNOWN, InvitationReclaimLogic.classifyReclaimFailure(typedTombstone(), false), ) assertEquals( - ReclaimOutcome.RECLAIMED, + ReclaimOutcome.CONSUMPTION_UNKNOWN, InvitationReclaimLogic.classifyReclaimFailure(typedTombstone(), true), ) } @Test - fun consensusConsumedWithoutMarkerIsAForeignClaim() { + fun consensusConsumedWithoutMarkerIsUnknown() { assertEquals( - ReclaimOutcome.CLAIMED, + ReclaimOutcome.CONSUMPTION_UNKNOWN, InvitationReclaimLogic.classifyReclaimFailure( RuntimeException(consumedMessage), hadPriorReclaimInFlight = false, ), @@ -52,9 +52,9 @@ class InvitationReclaimLogicTest { } @Test - fun consensusConsumedWithMarkerIsExplicitlyAmbiguousNeverReclaimed() { + fun consensusConsumedWithMarkerIsUnknown() { assertEquals( - ReclaimOutcome.CONSUMED_AMBIGUOUS, + ReclaimOutcome.CONSUMPTION_UNKNOWN, InvitationReclaimLogic.classifyReclaimFailure( RuntimeException(consumedMessage), hadPriorReclaimInFlight = true, ), @@ -123,8 +123,8 @@ class InvitationReclaimLogicTest { fun alreadyConsumedMatchesTheExactCanonicalPhraseOnly() { assertTrue(InvitationReclaimLogic.isAlreadyConsumed(consumedMessage)) assertTrue(InvitationReclaimLogic.isAlreadyConsumed(consumedMessage.uppercase())) - // Broader wordings must NOT match — a false positive would wrongly - // flip the row to Claimed. + // Broader wordings must NOT match — a false positive would hide an + // unrelated failure behind consumption-unknown recovery guidance. assertFalse(InvitationReclaimLogic.isAlreadyConsumed("asset lock already consumed")) assertFalse(InvitationReclaimLogic.isAlreadyConsumed("output already used")) assertFalse(InvitationReclaimLogic.isAlreadyConsumed("completely unrelated")) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 01ab6ebeb16..29986168bc0 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -104,6 +104,11 @@ sealed class DashSdkError( class AssetLockNotTracked(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * The one-shot output cannot be reused. This may be a retained local + * tombstone or an unauthenticated Platform report; operation completion + * must not be inferred from this signal. + */ class AssetLockAlreadyConsumed(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 806ab0d62ac..993fd2f8a7d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -134,7 +134,8 @@ class PlatformWalletPersistenceHandler( CAPABILITY_PROVIDER_TRANSACTIONS or CAPABILITY_UNSIGNED_TOKEN_STORAGE or CAPABILITY_WALLET_RESTORE or - CAPABILITY_DPNS_NAME_STATES + CAPABILITY_DPNS_NAME_STATES or + CAPABILITY_TRACKED_ASSET_LOCKS /** * The single-thread executor created when no [dispatcher] is injected. @@ -3176,6 +3177,7 @@ class PlatformWalletPersistenceHandler( internal const val CAPABILITY_UNSIGNED_TOKEN_STORAGE: Long = 0x20 internal const val CAPABILITY_WALLET_RESTORE: Long = 0x80 internal const val CAPABILITY_DPNS_NAME_STATES: Long = 0x100 + internal const val CAPABILITY_TRACKED_ASSET_LOCKS: Long = 0x200 private const val TAG = "DashPersistence" diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 1d216a594bb..07d143c1c57 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -59,6 +59,7 @@ data class PlatformWalletPersistenceCapabilities( const val PENDING_CONTACT_CRYPTO: Long = 1L shl 6 const val WALLET_RESTORE: Long = 1L shl 7 const val DPNS_NAME_STATES: Long = 1L shl 8 + const val TRACKED_ASSET_LOCKS: Long = 1L shl 9 } } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 1e2f0daa34b..0ab618d6db4 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -65,7 +65,7 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(0L, noOpBridge.persistenceCapabilitiesBits()) assertEquals(1, handler.persistenceCapabilitiesVersion()) - assertEquals(0x1bfL, handler.persistenceCapabilitiesBits()) + assertEquals(0x3bfL, handler.persistenceCapabilitiesBits()) // Android has no pending-contact-crypto callback, so it must not // attest that semantic contract. assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x40L) @@ -77,6 +77,7 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.ATOMIC_CHANGESETS)) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.INVITATIONS)) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.DPNS_NAME_STATES)) + assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.TRACKED_ASSET_LOCKS)) } // ── Standalone (non-bracketed) writes ───────────────────────────── diff --git a/packages/rs-platform-wallet-ffi/src/asset_lock/manager.rs b/packages/rs-platform-wallet-ffi/src/asset_lock/manager.rs index 988fa5807aa..2e5e9068292 100644 --- a/packages/rs-platform-wallet-ffi/src/asset_lock/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/asset_lock/manager.rs @@ -24,8 +24,8 @@ pub struct TrackedAssetLockFFI { /// Amount in duffs. pub amount: u64, /// Status (0=Built, 1=Broadcast, 2=InstantSendLocked, 3=ChainLocked, - /// 4=Consumed, 5=RecoveredFromChain — finality proven by the restore - /// scan, Platform-side consumption unknown). + /// 4=Consumed, 5=RecoveredFromChain — Core finality proven by restore + /// reconstruction or live reconciliation, Platform-side consumption unknown). pub status: u32, /// Whether a proof is attached. pub has_proof: bool, diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 340bb1973eb..444573c5dbc 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -197,7 +197,8 @@ pub enum PlatformWalletFFIResultCode { ErrorCoreInsufficientFunds = 22, /// Existing-lock recovery referenced an outpoint not owned/tracked by the wallet. ErrorAssetLockNotTracked = 23, - /// Existing-lock recovery referenced a one-shot output already consumed. + /// Asset-lock funding cannot reuse this one-shot output; Platform + /// completion for the requested operation is unconfirmed. ErrorAssetLockAlreadyConsumed = 24, /// Existing-lock recovery attempted to use a lock for the wrong funding /// family or bound identity index. diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index dccbcf72bb0..04a4e29ea1d 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -114,6 +114,7 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DEFERRED_CONTACT_CRYPTO: u64 = PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PENDING_CONTACT_CRYPTO; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_WALLET_RESTORE: u64 = 1 << 7; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DPNS_NAME_STATES: u64 = 1 << 8; +pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_ASSET_LOCKS: u64 = 1 << 9; /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately /// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by @@ -203,9 +204,8 @@ pub struct PersistenceCallbacks { /// Fired once at the top of every [`FFIPersister::store`] call, /// before any per-kind sub-callback runs. Clients use this as a /// hook to open a transaction / begin a batch / snapshot context - /// state; paired with `on_changeset_end_fn`. Return value is - /// advisory — a non-zero result is logged but does NOT abort the - /// round. + /// state; paired with `on_changeset_end_fn`. A non-zero result + /// aborts the round before any per-kind callback runs. pub on_changeset_begin_fn: Option i32>, /// Fired once at the bottom of every [`FFIPersister::store`] @@ -220,12 +220,15 @@ pub struct PersistenceCallbacks { /// itself failed (e.g. the atomic `save()` threw and the staged /// writes were rolled back); `store()` then returns `Err` so the /// caller does not advance state against data that never reached - /// durable storage. (Unlike `on_changeset_begin_fn`, this return - /// is honored, not advisory.) + /// durable storage. pub on_changeset_end_fn: Option< unsafe extern "C" fn(context: *mut c_void, wallet_id: *const u8, success: bool) -> i32, >, - /// Called when a changeset is stored. Returns 0 on success. + /// Legacy notification fired after `on_changeset_end_fn` and the in-memory + /// pending merge. When an end callback committed the round, this return is + /// advisory because the durable write cannot be rolled back. Without that + /// atomic boundary, a non-zero value retains the legacy `store()` error + /// contract. pub on_store_fn: Option i32>, /// Called when flush is requested. Returns 0 on success. @@ -1047,6 +1050,9 @@ impl FFIPersister { if wallet_restore { capabilities = capabilities.union(PersistenceCapabilities::WALLET_RESTORE); } + if self.callbacks.on_persist_asset_locks_fn.is_some() { + capabilities = capabilities.union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); + } if self.callbacks.on_persist_wallet_changeset_fn.is_some() && wallet_restore && capabilities.contains(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) @@ -1086,6 +1092,12 @@ impl PlatformWalletPersistence for FFIPersister { .intersection(self.callback_capabilities()) } + fn store_commits_inline(&self) -> bool { + // The end callback commits (or rolls back) the host transaction before + // `store` returns. `flush` is only a later general-purpose notification. + self.callbacks.on_changeset_end_fn.is_some() + } + fn store( &self, wallet_id: WalletId, @@ -2259,14 +2271,24 @@ impl PlatformWalletPersistence for FFIPersister { .and_modify(|existing| existing.merge(changeset.clone())) .or_insert(changeset); - // Notify caller. + // Preserve the legacy notification phase. With an end callback, the + // host transaction is already committed and a notification failure is + // advisory. Without that atomic boundary, preserve the established + // `store()` error contract for legacy hosts that use this callback as + // their durable-write boundary. if let Some(cb) = self.callbacks.on_store_fn { let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; if result != 0 { - return Err(PersistenceError::backend(format!( - "Persistence store callback returned error code {}", - result - ))); + if self.callbacks.on_changeset_end_fn.is_some() { + eprintln!( + "Persistence store callback returned post-commit error code {result}; \ + ignored" + ); + } else { + return Err(PersistenceError::backend(format!( + "Persistence store callback returned error code {result}" + ))); + } } } @@ -5971,6 +5993,16 @@ mod tests { ) -> i32 { 0 } + unsafe extern "C" fn noop_asset_locks( + _ctx: *mut c_void, + _wallet_id: *const u8, + _upserts_ptr: *const AssetLockEntryFFI, + _upserts_count: usize, + _removed_ptr: *const [u8; 36], + _removed_count: usize, + ) -> i32 { + 0 + } unsafe extern "C" fn noop_load_wallets( _ctx: *mut c_void, out_entries: *mut *const WalletRestoreEntryFFI, @@ -6094,6 +6126,17 @@ mod tests { assert!(!persister.persists_durably()); } + #[test] + fn end_callback_marks_store_as_inline_commit_boundary() { + let callbacks = PersistenceCallbacks { + on_changeset_end_fn: Some(noop_end), + ..PersistenceCallbacks::default() + }; + + assert!(FFIPersister::new(callbacks).store_commits_inline()); + assert!(!FFIPersister::new(PersistenceCallbacks::default()).store_commits_inline()); + } + /// Partial callback pairs attest only complete, independently testable /// contracts. Atomicity must not imply invitation support, and a pool /// callback without its registration callback must not attest pools. @@ -6144,6 +6187,48 @@ mod tests { assert!(!capabilities.contains(PersistenceCapabilities::WALLET_RESTORE)); } + #[test] + fn asset_lock_reconciliation_requires_every_callback_leg() { + fn complete_callbacks() -> PersistenceCallbacks { + PersistenceCallbacks { + on_changeset_begin_fn: Some(noop_begin), + on_changeset_end_fn: Some(noop_end), + on_persist_asset_locks_fn: Some(noop_asset_locks), + on_load_wallet_list_fn: Some(noop_load_wallets), + on_load_wallet_list_free_fn: Some(noop_free_wallets), + ..Default::default() + } + } + + let required = PersistenceCapabilities::ASSET_LOCK_RECONCILIATION; + assert!(declared_persister(complete_callbacks(), required) + .persistence_capabilities() + .contains(required)); + + let mut missing_begin = complete_callbacks(); + missing_begin.on_changeset_begin_fn = None; + let mut missing_end = complete_callbacks(); + missing_end.on_changeset_end_fn = None; + let mut missing_asset_locks = complete_callbacks(); + missing_asset_locks.on_persist_asset_locks_fn = None; + let mut missing_load = complete_callbacks(); + missing_load.on_load_wallet_list_fn = None; + let mut missing_load_free = complete_callbacks(); + missing_load_free.on_load_wallet_list_free_fn = None; + + for callbacks in [ + missing_begin, + missing_end, + missing_asset_locks, + missing_load, + missing_load_free, + ] { + assert!(!declared_persister(callbacks, required) + .persistence_capabilities() + .contains(required)); + } + } + /// A complete non-shielded vtable exposes every capability representable /// by its callbacks. Deferred contact crypto remains absent because the /// vtable has no callback contract for that queue. @@ -6155,12 +6240,14 @@ mod tests { .union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) .union(PersistenceCapabilities::PROVIDER_TRANSACTIONS) .union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE) - .union(PersistenceCapabilities::WALLET_RESTORE); + .union(PersistenceCapabilities::WALLET_RESTORE) + .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); cb.on_changeset_begin_fn = Some(noop_begin); cb.on_changeset_end_fn = Some(noop_end); cb.on_persist_account_registrations_fn = Some(noop_registrations); cb.on_persist_account_address_pools_fn = Some(noop_pools); cb.on_persist_invitations_fn = Some(noop_invitations); + cb.on_persist_asset_locks_fn = Some(noop_asset_locks); cb.on_load_wallet_list_fn = Some(noop_load_wallets); cb.on_load_wallet_list_free_fn = Some(noop_free_wallets); cb.on_persist_wallet_changeset_fn = Some(noop_wallet_changeset); @@ -6281,6 +6368,10 @@ mod tests { PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DPNS_NAME_STATES, PersistenceCapabilities::DPNS_NAME_STATES.bits() ); + assert_eq!( + PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_ASSET_LOCKS, + PersistenceCapabilities::TRACKED_ASSET_LOCKS.bits() + ); assert_eq!( PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ACCOUNT_ADDRESS_POOLS, PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_FUNDING_INDICES @@ -7761,6 +7852,108 @@ mod tests { drop(probe); } + #[test] + fn store_notification_remains_post_commit_and_advisory() { + struct StoreNotificationProbe { + end_called: AtomicBool, + store_called: AtomicBool, + store_saw_end: AtomicBool, + } + + extern "C" fn failing_store(ctx: *mut TestCVoid, _wallet_id: *const u8) -> i32 { + let probe = unsafe { &*(ctx as *const StoreNotificationProbe) }; + probe.store_called.store(true, Ordering::SeqCst); + probe + .store_saw_end + .store(probe.end_called.load(Ordering::SeqCst), Ordering::SeqCst); + 7 + } + + extern "C" fn recording_end( + ctx: *mut TestCVoid, + _wallet_id: *const u8, + success: bool, + ) -> i32 { + let probe = unsafe { &*(ctx as *const StoreNotificationProbe) }; + probe.end_called.store(success, Ordering::SeqCst); + 0 + } + + let probe = StoreNotificationProbe { + end_called: AtomicBool::new(false), + store_called: AtomicBool::new(false), + store_saw_end: AtomicBool::new(false), + }; + let callbacks = PersistenceCallbacks { + context: &probe as *const StoreNotificationProbe as *mut TestCVoid, + on_store_fn: Some(failing_store), + on_changeset_end_fn: Some(recording_end), + ..PersistenceCallbacks::default() + }; + let persister = FFIPersister::new(callbacks); + + persister + .store([1u8; 32], PlatformWalletChangeSet::default()) + .expect("post-commit notification failure must remain advisory"); + + assert!(probe.end_called.load(Ordering::SeqCst)); + assert!(probe.store_called.load(Ordering::SeqCst)); + assert!(probe.store_saw_end.load(Ordering::SeqCst)); + + let legacy_probe = StoreNotificationProbe { + end_called: AtomicBool::new(false), + store_called: AtomicBool::new(false), + store_saw_end: AtomicBool::new(false), + }; + let callbacks = PersistenceCallbacks { + context: &legacy_probe as *const StoreNotificationProbe as *mut TestCVoid, + on_store_fn: Some(failing_store), + ..PersistenceCallbacks::default() + }; + FFIPersister::new(callbacks) + .store([1u8; 32], PlatformWalletChangeSet::default()) + .expect_err("legacy notification failure must retain its store error contract"); + assert!(legacy_probe.store_called.load(Ordering::SeqCst)); + assert!(!legacy_probe.store_saw_end.load(Ordering::SeqCst)); + + extern "C" fn failing_metadata( + _ctx: *mut TestCVoid, + _wallet_id: *const u8, + _network: FFINetwork, + _wallet_group_id: *const u8, + _birth_height: u32, + ) -> i32 { + 7 + } + + let rejected_probe = StoreNotificationProbe { + end_called: AtomicBool::new(false), + store_called: AtomicBool::new(false), + store_saw_end: AtomicBool::new(false), + }; + let callbacks = PersistenceCallbacks { + context: &rejected_probe as *const StoreNotificationProbe as *mut TestCVoid, + on_persist_wallet_metadata_fn: Some(failing_metadata), + on_store_fn: Some(failing_store), + on_changeset_end_fn: Some(recording_end), + ..PersistenceCallbacks::default() + }; + let persister = FFIPersister::new(callbacks); + let changeset = PlatformWalletChangeSet { + wallet_metadata: Some(platform_wallet::changeset::WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [1u8; 32], + birth_height: 1, + }), + ..PlatformWalletChangeSet::default() + }; + + persister + .store([1u8; 32], changeset) + .expect_err("a rejected per-kind callback must fail before notification"); + assert!(!rejected_probe.store_called.load(Ordering::SeqCst)); + } + /// A nonzero `begin` return is fatal: the client failed to open its /// transaction, so `store()` must abort before any per-kind write and /// leave the round CLOSED (so the next `store()` isn't wedged). diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index e2cc4a37950..21d98fac4db 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -613,6 +613,24 @@ fn map_spend_result( } } +/// Preserve the typed "already consumed" funding report across the FFI +/// 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. +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) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("{operation} failed: {e}"), + ), + } +} + /// IdentityCreateFromShieldedPool (Type 20): spend `account`'s shielded notes to fund a brand-new /// Platform identity. /// @@ -1094,13 +1112,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock( ) .await }); - if let Err(e) = result { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorWalletOperation, - format!("shielded fund-from-asset-lock failed: {e}"), - ); - } - PlatformWalletFFIResult::ok() + map_asset_lock_funding_result(result, "shielded fund-from-asset-lock") } /// Fund the shielded pool by DRAINING the wallet's CoinJoin account @@ -1360,13 +1372,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset ) .await }); - if let Err(e) = result { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorWalletOperation, - format!("shielded resume fund-from-asset-lock failed: {e}"), - ); - } - PlatformWalletFFIResult::ok() + map_asset_lock_funding_result(result, "shielded resume fund-from-asset-lock") } /// Seed the shielded pool's anonymity set up to `target_total_notes` by @@ -1845,4 +1851,35 @@ mod tests { "the dedicated code is a Platform-to-shielded contract only" ); } + + #[test] + fn map_asset_lock_funding_result_preserves_already_consumed_code_only() { + let out_point = dashcore::OutPoint { + txid: dashcore::Txid::all_zeros(), + vout: 7, + }; + let result = map_asset_lock_funding_result( + Err(PlatformWalletError::AssetLockAlreadyConsumed(out_point)), + "shielded fund-from-asset-lock", + ); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorAssetLockAlreadyConsumed + ); + assert!(message_of(&result).contains("Platform completion is unconfirmed")); + + let unrelated = map_asset_lock_funding_result( + Err(PlatformWalletError::ShieldedNoUnspentNotes), + "shielded fund-from-asset-lock", + ); + assert_eq!( + unrelated.code, + PlatformWalletFFIResultCode::ErrorWalletOperation + ); + + assert_eq!( + map_asset_lock_funding_result(Ok(()), "shielded fund-from-asset-lock").code, + PlatformWalletFFIResultCode::Success + ); + } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index ef0890f4e8b..d331530e9b2 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -817,10 +817,15 @@ impl Drop for SqlitePersister { } impl PlatformWalletPersistence for SqlitePersister { + fn store_commits_inline(&self) -> bool { + self.config.flush_mode == FlushMode::Immediate + } + fn persistence_capabilities(&self) -> PersistenceCapabilities { // Every `flush_inner` applies the complete changeset in one SQLite // transaction. The current schema also has lossless token balances, - // invitations, account pools, and deferred-contact-crypto queue rows. + // invitations, account pools, tracked asset locks, and + // deferred-contact-crypto queue rows. // Do NOT attest WALLET_RESTORE (and therefore not provider restore): // `load()` still reports `ClientStartState::wallets` in // `LOAD_UNIMPLEMENTED`. Shielded state lives in a separate store. @@ -830,6 +835,7 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE) .union(PersistenceCapabilities::PENDING_CONTACT_CRYPTO) .union(PersistenceCapabilities::DPNS_NAME_STATES) + .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) } /// Merge `changeset` into the per-wallet buffer. diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index 61c4afa50f8..0364200492f 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -48,6 +48,9 @@ impl PersistenceCapabilities { pub const WALLET_RESTORE: Self = Self(1 << 7); /// DPNS name-state (username marketplace) rows can be persisted. pub const DPNS_NAME_STATES: Self = Self(1 << 8); + /// Tracked asset-lock rows, including status and proof updates, can be + /// persisted. Restart hydration is the separate `WALLET_RESTORE` contract. + pub const TRACKED_ASSET_LOCKS: Self = Self(1 << 9); /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -61,6 +64,11 @@ impl PersistenceCapabilities { pub const SHIELDED_FVK_RESTART: Self = Self(Self::ATOMIC_CHANGESETS.0 | Self::SHIELDED_VIEWING_KEYS.0); + /// Capabilities required to durably reconcile an asset-lock status and + /// restore that exact row after process restart. + pub const ASSET_LOCK_RECONCILIATION: Self = + Self(Self::ATOMIC_CHANGESETS.0 | Self::TRACKED_ASSET_LOCKS.0 | Self::WALLET_RESTORE.0); + pub const fn from_bits_retain(bits: u64) -> Self { Self(bits) } @@ -119,6 +127,10 @@ impl PersistenceCapabilities { PersistenceCapabilities::DPNS_NAME_STATES, "dpns_name_states", ), + ( + PersistenceCapabilities::TRACKED_ASSET_LOCKS, + "tracked_asset_locks", + ), ]; KNOWN @@ -147,6 +159,11 @@ mod tests { assert_eq!(PersistenceCapabilities::PENDING_CONTACT_CRYPTO.bits(), 0x40); assert_eq!(PersistenceCapabilities::WALLET_RESTORE.bits(), 0x80); assert_eq!(PersistenceCapabilities::DPNS_NAME_STATES.bits(), 0x100); + assert_eq!(PersistenceCapabilities::TRACKED_ASSET_LOCKS.bits(), 0x200); + assert_eq!( + PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), + 0x281 + ); } #[test] diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 21653019b0e..7dcd3bee816 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -205,6 +205,16 @@ impl PersistenceError { /// to guarantee a batch flush, it should call `flush` explicitly after all /// `store` calls and treat `store` as a best-effort buffer hint. pub trait PlatformWalletPersistence: Send + Sync { + /// Whether a successful [`store`](Self::store) makes its changeset + /// durable before [`flush`](Self::flush) is called. + /// + /// The default is conservative for buffered backends. Inline-committing + /// implementations override this so callers do not roll back live state + /// after a later, post-commit flush-notification failure. + fn store_commits_inline(&self) -> bool { + false + } + /// Feature-specific contracts this backend can persist and, where the /// capability requires it, restore after process restart. /// diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index ef165191583..8349eb1df21 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -259,9 +259,11 @@ pub enum PlatformWalletError { #[error("Asset lock {0} is not tracked by this wallet")] AssetLockNotTracked(dashcore::OutPoint), - /// A one-shot asset lock has already funded a successful Platform - /// transition and cannot be resumed again. - #[error("Asset lock {0} has already been consumed")] + /// A one-shot asset lock outpoint cannot be reused. This can come from a + /// local `Consumed` tombstone or an unauthenticated Platform consumption + /// report; callers must not infer completion of the requested operation + /// from this signal alone. + #[error("Asset lock {0} cannot be reused; Platform completion is unconfirmed")] AssetLockAlreadyConsumed(dashcore::OutPoint), /// A tracked outpoint belongs to another funding family or identity @@ -749,6 +751,31 @@ fn consensus_error_of(error: &dash_sdk::Error) -> Option<&dpp::consensus::Consen } } +/// Whether Platform rejected a transition because the exact asset-lock +/// outpoint it submitted has already been consumed. +/// +/// Matches the structured consensus error carried by both CheckTx +/// (`Protocol(ConsensusError)`) and wait-stream +/// (`StateTransitionBroadcastError`) failures. The outpoint comparison is +/// deliberate: callers may only recognize a report for the tracked lock they +/// actually submitted, never an unrelated outpoint mentioned by a malformed +/// error. This signal alone does not authenticate terminal consumption. +pub fn is_asset_lock_already_consumed( + error: &dash_sdk::Error, + out_point: &dashcore::OutPoint, +) -> bool { + use dpp::consensus::basic::BasicError; + use dpp::consensus::ConsensusError; + + matches!( + consensus_error_of(error), + Some(ConsensusError::BasicError( + BasicError::IdentityAssetLockTransactionOutPointAlreadyConsumedError(e), + )) if e.transaction_id() == out_point.txid + && e.output_index() == out_point.vout as usize + ) +} + /// Promote a document-trade consensus rejection to its typed /// [`PlatformWalletError`] so callers get structured data instead of a /// stringified verdict: @@ -1071,3 +1098,87 @@ mod address_nonce_tests { assert_eq!(got.expected_nonce(), 10); } } + +#[cfg(test)] +mod asset_lock_already_consumed_tests { + use super::*; + use dash_sdk::error::StateTransitionBroadcastError; + use dashcore::hashes::Hash; + use dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointAlreadyConsumedError; + use dpp::consensus::basic::UnsupportedProtocolVersionError; + + fn out_point() -> dashcore::OutPoint { + dashcore::OutPoint::new(dashcore::Txid::all_zeros(), 7) + } + + fn consensus_error() -> dpp::consensus::ConsensusError { + let out_point = out_point(); + IdentityAssetLockTransactionOutPointAlreadyConsumedError::new( + out_point.txid, + out_point.vout as usize, + ) + .into() + } + + fn unrelated_consensus_error() -> dpp::consensus::ConsensusError { + UnsupportedProtocolVersionError::new(2, 1).into() + } + + #[test] + fn recognizes_protocol_consensus_error_for_exact_outpoint() { + let error = dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( + consensus_error(), + ))); + + assert!(is_asset_lock_already_consumed(&error, &out_point())); + } + + #[test] + fn recognizes_broadcast_consensus_error_for_exact_outpoint() { + let error = dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 10504, + message: "asset lock already consumed".to_string(), + cause: Some(consensus_error()), + }); + + assert!(is_asset_lock_already_consumed(&error, &out_point())); + } + + #[test] + fn ignores_unrelated_errors_and_different_outpoints() { + let error = dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( + consensus_error(), + ))); + let different_out_point = dashcore::OutPoint::new(dashcore::Txid::all_zeros(), 8); + + assert!(!is_asset_lock_already_consumed( + &error, + &different_out_point + )); + assert!(!is_asset_lock_already_consumed( + &dash_sdk::Error::Generic("boom".to_string()), + &out_point() + )); + + let unrelated_protocol = dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(unrelated_consensus_error()), + )); + assert!(!is_asset_lock_already_consumed( + &unrelated_protocol, + &out_point() + )); + + let unrelated_broadcast = + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 10504, + // Deliberately resembles the target message: matching must + // depend on the structured cause, never this display text. + message: "asset lock output already completely used".to_string(), + cause: Some(unrelated_consensus_error()), + }); + assert!(!is_asset_lock_already_consumed( + &unrelated_broadcast, + &out_point() + )); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs index 58f3963cc40..4f3d415aa4b 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -1,6 +1,6 @@ //! Submission-side orchestration shared across asset-lock-funded //! flows (identity registration, identity top-up, platform-address -//! funding). +//! funding, and shielded funding). //! //! The asset-lock acquisition pipeline (build tx → wait IS/CL) lives //! in [`crate::wallet::asset_lock::build`] / @@ -39,7 +39,9 @@ use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundin use dash_sdk::platform::transition::put_settings::PutSettings; use crate::broadcaster::TransactionBroadcaster; -use crate::error::{as_asset_lock_proof_cl_height_too_low, PlatformWalletError}; +use crate::error::{ + as_asset_lock_proof_cl_height_too_low, is_asset_lock_already_consumed, PlatformWalletError, +}; use crate::wallet::asset_lock::manager::AssetLockManager; // --------------------------------------------------------------------------- @@ -388,6 +390,45 @@ pub(crate) fn out_point_from_proof(proof: &AssetLockProof) -> OutPoint { // --------------------------------------------------------------------------- impl AssetLockManager { + /// Normalize the final result of an asset-lock-funded Platform submit. + /// + /// A matching `already consumed` consensus response is not authenticated, + /// so it cannot prove that the requested operation completed. Promote the + /// lock to an SPV-backed ChainLock proof, durably retain it as + /// consumption-unknown, and preserve the typed host signal. Successful and + /// unrelated results pass through unchanged. + pub(crate) async fn reconcile_asset_lock_submit_result( + &self, + result: Result, + out_point: &OutPoint, + effective_proof: &AssetLockProof, + chain_lock_timeout: Option, + ) -> Result { + let error = match result { + Ok(value) => return Ok(value), + Err(error) => error, + }; + if !is_asset_lock_already_consumed(&error, out_point) { + return Err(PlatformWalletError::Sdk(error)); + } + + let chain_proof = match effective_proof { + AssetLockProof::Chain(_) => effective_proof.clone(), + AssetLockProof::Instant(_) => { + self.upgrade_to_chain_lock_proof(out_point, chain_lock_timeout) + .await? + } + }; + self.mark_asset_lock_consumption_unknown(out_point, chain_proof) + .await?; + tracing::warn!( + outpoint = %out_point, + "recorded unauthenticated already-consumed report as consumption unknown" + ); + + Err(PlatformWalletError::AssetLockAlreadyConsumed(*out_point)) + } + /// Resolve an [`AssetLockFunding`] to a concrete proof + path + /// (optional) tracked outpoint, capturing the IS-lock timeout case /// as a structured outcome so the caller can drive a CL retry. diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs index d6051217077..3fa36441b64 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs @@ -4,6 +4,8 @@ use crate::broadcaster::TransactionBroadcaster; use dashcore::OutPoint; use crate::changeset::changeset::AssetLockChangeSet; +use crate::changeset::changeset::PlatformWalletChangeSet; +use crate::changeset::PersistenceCapabilities; use crate::error::PlatformWalletError; use super::super::manager::AssetLockManager; @@ -167,6 +169,94 @@ impl AssetLockManager { Ok(cs) } + /// Record that Platform reported an asset lock as already consumed without + /// claiming authenticated Platform-side completion. + /// + /// The supplied proof must be a Core-chain-authenticated ChainLock proof. + /// The row moves to [`RecoveredFromChain`](AssetLockStatus::RecoveredFromChain), + /// whose contract is deliberately nonterminal: Core finality is known, but + /// Platform consumption is not. The proof is retained so a future explicit + /// recovery remains possible. + /// + /// Unlike the ordinary queued status updates, this user-visible recovery + /// marker is stored and flushed synchronously. A failure rolls back the + /// in-memory mutation only when the backend has not committed the store and + /// did not retain a transient retry buffer. Before mutating, the backend + /// must attest atomic tracked-asset-lock persistence and restart restore. + pub(crate) async fn mark_asset_lock_consumption_unknown( + &self, + out_point: &OutPoint, + chain_proof: dpp::prelude::AssetLockProof, + ) -> Result { + if !matches!(&chain_proof, dpp::prelude::AssetLockProof::Chain(_)) { + return Err(PlatformWalletError::AssetLockProofWait(format!( + "Asset lock {} cannot enter consumption-unknown state without a ChainLock proof", + out_point + ))); + } + + let capabilities = self.persister.persistence_capabilities(); + let required = PersistenceCapabilities::ASSET_LOCK_RECONCILIATION; + if !capabilities.contains(required) { + let missing = capabilities.missing(required); + return Err(PlatformWalletError::Persistence(format!( + "asset-lock reconciliation requires persistence capabilities {:?} \ + (missing mask 0x{:x})", + missing.names(), + missing.bits(), + ))); + } + + let (previous, candidate, cs) = { + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let entry = info + .tracked_asset_locks + .get_mut(out_point) + .ok_or_else(|| PlatformWalletError::AssetLockNotTracked(*out_point))?; + let previous = entry.clone(); + entry.status = AssetLockStatus::RecoveredFromChain; + entry.proof = Some(chain_proof); + let candidate = entry.clone(); + let mut cs = AssetLockChangeSet::default(); + cs.asset_locks.insert(*out_point, (&*entry).into()); + (previous, candidate, cs) + }; + + let store_commits_inline = self.persister.store_commits_inline(); + let persist_failure = match self.persister.store(PlatformWalletChangeSet { + asset_locks: Some(cs.clone()), + ..Default::default() + }) { + Err(error) => Some((error, true)), + Ok(()) => self.persister.flush().err().map(|error| { + let may_rollback = !store_commits_inline; + (error, may_rollback) + }), + }; + if let Some((error, may_rollback)) = persist_failure { + if error.is_transient() || !may_rollback { + return Err(PlatformWalletError::Persistence(error.to_string())); + } + let mut wm = self.wallet_manager.write().await; + if let Some(current) = wm + .get_wallet_info_mut(&self.wallet_id) + .and_then(|info| info.tracked_asset_locks.get_mut(out_point)) + { + // Do not overwrite a concurrent lifecycle advance. Roll back + // only while the exact candidate written above is still live. + if current.status == candidate.status && current.proof == candidate.proof { + *current = previous; + } + } + return Err(PlatformWalletError::Persistence(error.to_string())); + } + + Ok(cs) + } + /// Advance the status of a tracked asset lock and optionally attach the proof. /// /// Returns an [`AssetLockChangeSet`] carrying a full snapshot of the diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs index e44f85e9da2..a85a7d79cce 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs @@ -55,12 +55,17 @@ pub enum AssetLockStatus { /// (amount, identity index, funding tx) but is excluded from any /// "still actionable" predicate. Consumed, - /// Reconstructed from a **chain-locked** on-chain record rather - /// than tracked live through the build → broadcast → proof - /// pipeline — the restore-scan path - /// (`wallet::asset_lock::sync::reconstruction`) emits this for - /// finalized asset-lock transactions whose credit outputs pay this - /// wallet's funding accounts. Non-final detections (mempool / + /// Core finality is authenticated, but Platform-side consumption is + /// unknown. Most entries reach this state after reconstruction from a + /// **chain-locked** on-chain record rather than live tracking through the + /// build → broadcast → proof pipeline. It is also the safe state after an + /// explicit retry receives an unauthenticated "already consumed" report: + /// the wallet first obtains a ChainLock proof, retains it, and records no + /// claim that Platform actually consumed the output. + /// + /// The restore-scan path (`wallet::asset_lock::sync::reconstruction`) + /// emits this for finalized asset-lock transactions whose credit outputs + /// pay this wallet's funding accounts. Non-final detections (mempool / /// unconfirmed-block sightings) enter as /// [`Broadcast`](Self::Broadcast) / /// [`InstantSendLocked`](Self::InstantSendLocked) like any other diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs index e0924008335..d1a8c965880 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs @@ -224,7 +224,7 @@ impl IdentityWallet { // CL-height retry also iterates inside the IS→CL fallback branch // so a freshly-upgraded CL proof gets the same patience. let proof_out_point = out_point_from_proof(&proof); - let identity = match submit_with_cl_height_retry(settings, |s| { + let (submit_result, effective_proof) = match submit_with_cl_height_retry(settings, |s| { placeholder.put_to_platform_and_wait_for_response_with_signer( &self.sdk, proof.clone(), @@ -236,7 +236,7 @@ impl IdentityWallet { }) .await { - Ok(identity) => identity, + Ok(identity) => (Ok(identity), proof.clone()), Err(e) if is_instant_lock_proof_invalid(&e) => { let out_point = proof_out_point; tracing::warn!( @@ -248,7 +248,7 @@ impl IdentityWallet { .asset_locks .upgrade_to_chain_lock_proof(&out_point, None) .await?; - submit_with_cl_height_retry(settings, |s| { + let submit_result = submit_with_cl_height_retry(settings, |s| { placeholder.put_to_platform_and_wait_for_response_with_signer( &self.sdk, chain_proof.clone(), @@ -258,11 +258,20 @@ impl IdentityWallet { s, ) }) - .await - .map_err(PlatformWalletError::Sdk)? + .await; + (submit_result, chain_proof) } - Err(e) => return Err(PlatformWalletError::Sdk(e)), + Err(e) => (Err(e), proof.clone()), }; + let identity = self + .asset_locks + .reconcile_asset_lock_submit_result( + submit_result, + &proof_out_point, + &effective_proof, + None, + ) + .await?; // Step 4 (best-effort): bookkeeping — add to local // IdentityManager + record key derivation breadcrumbs. @@ -457,7 +466,7 @@ impl IdentityWallet { // cache, and IS-lock rejection triggers an IS→CL upgrade on the // same outpoint. let proof_out_point = out_point_from_proof(&proof); - let new_balance = match submit_with_cl_height_retry(settings, |s| { + let (submit_result, effective_proof) = match submit_with_cl_height_retry(settings, |s| { identity.top_up_identity_with_signer( &self.sdk, proof.clone(), @@ -468,7 +477,7 @@ impl IdentityWallet { }) .await { - Ok(balance) => balance, + Ok(balance) => (Ok(balance), proof.clone()), Err(e) if is_instant_lock_proof_invalid(&e) => { let out_point = proof_out_point; tracing::warn!( @@ -480,7 +489,7 @@ impl IdentityWallet { .asset_locks .upgrade_to_chain_lock_proof(&out_point, None) .await?; - submit_with_cl_height_retry(settings, |s| { + let submit_result = submit_with_cl_height_retry(settings, |s| { identity.top_up_identity_with_signer( &self.sdk, chain_proof.clone(), @@ -489,11 +498,20 @@ impl IdentityWallet { s, ) }) - .await - .map_err(PlatformWalletError::Sdk)? + .await; + (submit_result, chain_proof) } - Err(e) => return Err(PlatformWalletError::Sdk(e)), + Err(e) => (Err(e), proof.clone()), }; + let new_balance = self + .asset_locks + .reconcile_asset_lock_submit_result( + submit_result, + &proof_out_point, + &effective_proof, + None, + ) + .await?; // Step 4 (best-effort): persist the new balance + clean up the // tracked lock. diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index e2eafb8a871..c7b111809df 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -39,6 +39,10 @@ impl WalletPersister { self.inner.flush(self.wallet_id) } + pub(crate) fn store_commits_inline(&self) -> bool { + self.inner.store_commits_inline() + } + /// Feature-specific persistence contracts exposed by the backend. pub(crate) fn persistence_capabilities(&self) -> PersistenceCapabilities { self.inner.persistence_capabilities() diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs index 2858c63fa6d..416b068edd6 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs @@ -204,7 +204,7 @@ impl PlatformAddressWallet { let proof_out_point = out_point_from_proof(&proof); // `proof_height` is the broadcast proof's committed block — the // height pin for the reconciled absolutes below. - let (address_infos, proof_height) = match submit_with_cl_height_retry(settings, |s| { + let (submit_result, effective_proof) = match submit_with_cl_height_retry(settings, |s| { addresses.top_up_with_signers( &self.sdk, proof.clone(), @@ -217,7 +217,7 @@ impl PlatformAddressWallet { }) .await { - Ok(infos) => infos, + Ok(infos) => (Ok(infos), proof.clone()), Err(e) if is_instant_lock_proof_invalid(&e) => { let out_point = proof_out_point; tracing::warn!( @@ -247,7 +247,7 @@ impl PlatformAddressWallet { ) .await?; self.asset_locks.queue_asset_lock_changeset(cs); - submit_with_cl_height_retry(settings, |s| { + let submit_result = submit_with_cl_height_retry(settings, |s| { addresses.top_up_with_signers( &self.sdk, chain_proof.clone(), @@ -258,11 +258,20 @@ impl PlatformAddressWallet { s, ) }) - .await - .map_err(PlatformWalletError::Sdk)? + .await; + (submit_result, chain_proof) } - Err(e) => return Err(PlatformWalletError::Sdk(e)), + Err(e) => (Err(e), proof.clone()), }; + let (address_infos, proof_height) = self + .asset_locks + .reconcile_asset_lock_submit_result( + submit_result, + &proof_out_point, + &effective_proof, + None, + ) + .await?; // Step 4: bookkeeping + cleanup. Write the proof-attested // balances back into ManagedPlatformAccount, then consume the diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 07058ae6568..82e568db79e 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -19,10 +19,10 @@ //! `key_wallet::signer::Signer` (the host never sees the raw key). //! IS→CL fallback fires on Platform-side IS rejection //! (`is_instant_lock_proof_invalid`). -//! 4. **Consume lock** — terminal `consume_asset_lock` on the tracked -//! outpoint. Notes themselves arrive via the next shielded sync; -//! the shielded changeset doesn't materialise post-submit the way -//! the address-funding `AddressInfos` does. +//! 4. **Finalize lock state** — a verified submit consumes the tracked +//! outpoint. An unauthenticated "already consumed" rejection is recorded +//! only as nonterminal consumption-unknown state after Core ChainLock +//! finality; it never creates a terminal `Consumed` tombstone. use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::transition::put_settings::PutSettings; @@ -376,65 +376,74 @@ impl PlatformWallet { // bundle, so only the landed attempt's actions are the ones a // later scan will recover; `build_and_broadcast_shielded` returns // them on success. - let landed_actions: Vec = - match submit_with_cl_height_retry(settings, |s| { - build_and_broadcast_shielded( - sdk.clone(), - recipient, - shield_amount, - proof.clone(), - path.clone(), - asset_lock_signer, - &prover, - sender_ovk.clone(), - surplus_output, - dummy_outputs, - s, - ) - }) - .await - { - Ok(actions) => actions, - Err(e) if is_instant_lock_proof_invalid(&e) => { - let out_point = proof_out_point; - tracing::warn!( - "IS-lock proof rejected by Platform for shielded fund-from-asset-lock \ + let (submit_result, effective_proof) = match submit_with_cl_height_retry(settings, |s| { + build_and_broadcast_shielded( + sdk.clone(), + recipient, + shield_amount, + proof.clone(), + path.clone(), + asset_lock_signer, + &prover, + sender_ovk.clone(), + surplus_output, + dummy_outputs, + s, + ) + }) + .await + { + Ok(actions) => (Ok(actions), proof.clone()), + Err(e) if is_instant_lock_proof_invalid(&e) => { + let out_point = proof_out_point; + tracing::warn!( + "IS-lock proof rejected by Platform for shielded fund-from-asset-lock \ (tx {}), retrying with ChainLock proof", - out_point.txid - ); - let chain_proof = self - .asset_locks - .upgrade_to_chain_lock_proof(&out_point, cl_wait) - .await?; - let cs = self - .asset_locks - .advance_asset_lock_status( - &out_point, - crate::wallet::asset_lock::tracked::AssetLockStatus::ChainLocked, - Some(chain_proof.clone()), - ) - .await?; - self.asset_locks.queue_asset_lock_changeset(cs); - submit_with_cl_height_retry(settings, |s| { - build_and_broadcast_shielded( - sdk.clone(), - recipient, - shield_amount, - chain_proof.clone(), - path.clone(), - asset_lock_signer, - &prover, - sender_ovk.clone(), - surplus_output, - dummy_outputs, - s, - ) - }) - .await - .map_err(PlatformWalletError::Sdk)? - } - Err(e) => return Err(PlatformWalletError::Sdk(e)), - }; + out_point.txid + ); + let chain_proof = self + .asset_locks + .upgrade_to_chain_lock_proof(&out_point, cl_wait) + .await?; + let cs = self + .asset_locks + .advance_asset_lock_status( + &out_point, + crate::wallet::asset_lock::tracked::AssetLockStatus::ChainLocked, + Some(chain_proof.clone()), + ) + .await?; + self.asset_locks.queue_asset_lock_changeset(cs); + let submit_result = submit_with_cl_height_retry(settings, |s| { + build_and_broadcast_shielded( + sdk.clone(), + recipient, + shield_amount, + chain_proof.clone(), + path.clone(), + asset_lock_signer, + &prover, + sender_ovk.clone(), + surplus_output, + dummy_outputs, + s, + ) + }) + .await; + (submit_result, chain_proof) + } + Err(e) => (Err(e), proof.clone()), + }; + + let landed_actions: Vec = self + .asset_locks + .reconcile_asset_lock_submit_result( + submit_result, + &proof_out_point, + &effective_proof, + cl_wait, + ) + .await?; // Record a live `ShieldFromAssetLock` activity entry over the // landed bundle. One entry per call (= one per seed-pool batch), @@ -795,7 +804,26 @@ pub(super) fn validate_shielded_recipients( #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + + use dashcore::{Network, OutPoint}; + use dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointAlreadyConsumedError; + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet_manager::WalletManager; + use tokio::sync::{Notify, RwLock}; + use super::*; + use crate::changeset::{ + ClientStartState, PersistenceCapabilities, PersistenceError, PersistenceErrorKind, + PlatformWalletChangeSet, PlatformWalletPersistence, + }; + use crate::test_support::{funded_wallet_manager, AlwaysRejectedBroadcaster}; + use crate::wallet::asset_lock::manager::AssetLockManager; + use crate::wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; + use crate::wallet::persister::WalletPersister; + use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; // The preflight is a pure length/cardinality check; the // recipient type is irrelevant for what we're testing. Using @@ -843,4 +871,448 @@ mod tests { let v: Vec<(u8, Option)> = vec![(0, None)]; validate_shielded_recipients(&v).expect("single recipient with None must pass"); } + + #[derive(Default)] + struct RecordingPersistence { + stored: Mutex>, + fail_next_store: AtomicBool, + fail_next_flush: Mutex>, + store_commits_inline: AtomicBool, + omit_reconciliation_capabilities: AtomicBool, + } + + impl PlatformWalletPersistence for RecordingPersistence { + fn store_commits_inline(&self) -> bool { + self.store_commits_inline.load(Ordering::SeqCst) + } + + fn persistence_capabilities(&self) -> PersistenceCapabilities { + if self.omit_reconciliation_capabilities.load(Ordering::SeqCst) { + PersistenceCapabilities::NONE + } else { + PersistenceCapabilities::ASSET_LOCK_RECONCILIATION + } + } + + fn store( + &self, + _wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + if self.fail_next_store.swap(false, Ordering::SeqCst) { + return Err(PersistenceError::backend( + "simulated asset-lock store failure", + )); + } + self.stored + .lock() + .expect("recording persistence mutex") + .push(changeset); + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + let failure = self + .fail_next_flush + .lock() + .expect("recording persistence mutex") + .take(); + if let Some(kind) = failure { + Err(PersistenceError::backend_with_kind( + kind, + "simulated asset-lock flush failure", + )) + } else { + Ok(()) + } + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + struct ConsumptionReportContext { + manager: AssetLockManager, + wallet_manager: Arc>>, + wallet_id: WalletId, + out_point: OutPoint, + proof: AssetLockProof, + persistence: Arc, + } + + async fn consumption_report_context() -> ConsumptionReportContext { + consumption_report_context_for(AssetLockFundingType::AssetLockShieldedAddressTopUp).await + } + + async fn consumption_report_context_for( + funding_type: AssetLockFundingType, + ) -> ConsumptionReportContext { + let (wallet_manager, wallet_id, _generation, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let persistence = Arc::new(RecordingPersistence::default()); + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(Network::Testnet) + .build() + .expect("mock sdk"), + ); + let manager = AssetLockManager::new( + sdk, + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + Arc::new(AlwaysRejectedBroadcaster), + WalletPersister::new(wallet_id, Arc::::clone(&persistence)), + ); + let (transaction, _path) = manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + ) + .await + .expect("build asset lock"); + let out_point = OutPoint::new(transaction.txid(), 0); + let proof = AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 42, + out_point, + }); + { + let mut wm = wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&wallet_id) + .expect("wallet must remain registered"); + info.tracked_asset_locks.insert( + out_point, + TrackedAssetLock { + out_point, + transaction, + account_index: 0, + funding_type, + identity_index: 0, + amount: 1_000_000, + status: AssetLockStatus::ChainLocked, + proof: Some(proof.clone()), + }, + ); + } + + ConsumptionReportContext { + manager, + wallet_manager, + wallet_id, + out_point, + proof, + persistence, + } + } + + fn already_consumed_error(out_point: OutPoint) -> dash_sdk::Error { + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( + IdentityAssetLockTransactionOutPointAlreadyConsumedError::new( + out_point.txid, + out_point.vout as usize, + ) + .into(), + ))) + } + + #[tokio::test] + async fn successful_submit_result_passes_through_without_mutation() { + let ctx = consumption_report_context().await; + let stored_before = ctx + .persistence + .stored + .lock() + .expect("recording persistence mutex") + .len(); + + let value = ctx + .manager + .reconcile_asset_lock_submit_result( + Ok::<_, dash_sdk::Error>(42u64), + &ctx.out_point, + &ctx.proof, + None, + ) + .await + .expect("successful submission must pass through"); + assert_eq!(value, 42); + + let wm = ctx.wallet_manager.read().await; + let lock = wm + .get_wallet_info(&ctx.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&ctx.out_point) + .expect("tracked lock"); + assert_eq!(lock.status, AssetLockStatus::ChainLocked); + assert_eq!(lock.proof, Some(ctx.proof)); + assert_eq!( + ctx.persistence + .stored + .lock() + .expect("recording persistence mutex") + .len(), + stored_before + ); + } + + #[tokio::test] + async fn consumed_report_reconciliation_is_funding_role_agnostic() { + for funding_type in [ + AssetLockFundingType::IdentityRegistration, + AssetLockFundingType::IdentityTopUp, + AssetLockFundingType::IdentityInvitation, + AssetLockFundingType::AssetLockAddressTopUp, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + ] { + let ctx = consumption_report_context_for(funding_type).await; + let error = ctx + .manager + .reconcile_asset_lock_submit_result::<()>( + Err(already_consumed_error(ctx.out_point)), + &ctx.out_point, + &ctx.proof, + None, + ) + .await + .expect_err("already-consumed report must stay a typed error"); + assert!(matches!( + error, + PlatformWalletError::AssetLockAlreadyConsumed(actual) + if actual == ctx.out_point + )); + let wm = ctx.wallet_manager.read().await; + let lock = wm + .get_wallet_info(&ctx.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&ctx.out_point) + .expect("tracked lock"); + assert_eq!(lock.funding_type, funding_type); + assert_eq!(lock.status, AssetLockStatus::RecoveredFromChain); + } + } + + #[tokio::test] + async fn consumed_report_returns_typed_error_and_persists_nonterminal_state() { + let ctx = consumption_report_context().await; + + let error = ctx + .manager + .reconcile_asset_lock_submit_result::<()>( + Err(already_consumed_error(ctx.out_point)), + &ctx.out_point, + &ctx.proof, + None, + ) + .await + .expect_err("already-consumed report remains a typed host signal"); + assert!(matches!( + error, + PlatformWalletError::AssetLockAlreadyConsumed(actual) if actual == ctx.out_point + )); + + let wm = ctx.wallet_manager.read().await; + let lock = wm + .get_wallet_info(&ctx.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&ctx.out_point) + .expect("tracked lock is retained"); + assert_eq!(lock.status, AssetLockStatus::RecoveredFromChain); + assert!(matches!(lock.proof, Some(AssetLockProof::Chain(_)))); + drop(wm); + + let persisted_status = ctx + .persistence + .stored + .lock() + .expect("recording persistence mutex") + .iter() + .filter_map(|cs| cs.asset_locks.as_ref()) + .filter_map(|asset_locks| asset_locks.asset_locks.get(&ctx.out_point)) + .map(|entry| entry.status.clone()) + .next_back(); + assert_eq!(persisted_status, Some(AssetLockStatus::RecoveredFromChain)); + } + + #[tokio::test] + async fn consumed_report_without_persistence_contract_keeps_pending_state() { + let ctx = consumption_report_context().await; + ctx.persistence + .omit_reconciliation_capabilities + .store(true, Ordering::SeqCst); + + let error = ctx + .manager + .reconcile_asset_lock_submit_result::<()>( + Err(already_consumed_error(ctx.out_point)), + &ctx.out_point, + &ctx.proof, + None, + ) + .await + .expect_err("unsupported persistence must fail before reconciliation"); + assert!(matches!(error, PlatformWalletError::Persistence(_))); + + let wm = ctx.wallet_manager.read().await; + let lock = wm + .get_wallet_info(&ctx.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&ctx.out_point) + .expect("tracked lock"); + assert_eq!(lock.status, AssetLockStatus::ChainLocked); + assert_eq!(lock.proof, Some(ctx.proof)); + assert!(ctx + .persistence + .stored + .lock() + .expect("recording persistence mutex") + .is_empty()); + } + + #[tokio::test] + async fn unrelated_or_mismatched_errors_do_not_mutate_asset_lock() { + let ctx = consumption_report_context().await; + let stored_before = ctx + .persistence + .stored + .lock() + .expect("recording persistence mutex") + .len(); + + for error in [ + dash_sdk::Error::Generic("unrelated".to_string()), + already_consumed_error(OutPoint::new(ctx.out_point.txid, ctx.out_point.vout + 1)), + ] { + let mapped = ctx + .manager + .reconcile_asset_lock_submit_result::<()>( + Err(error), + &ctx.out_point, + &ctx.proof, + None, + ) + .await + .expect_err("submit error must remain an error"); + assert!(matches!(mapped, PlatformWalletError::Sdk(_))); + } + + let wm = ctx.wallet_manager.read().await; + let lock = wm + .get_wallet_info(&ctx.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&ctx.out_point) + .expect("tracked lock"); + assert_eq!(lock.status, AssetLockStatus::ChainLocked); + assert_eq!(lock.proof, Some(ctx.proof)); + assert_eq!( + ctx.persistence + .stored + .lock() + .expect("recording persistence mutex") + .len(), + stored_before + ); + } + + #[tokio::test] + async fn persistence_failure_rolls_back_consumption_unknown_state() { + let ctx = consumption_report_context().await; + ctx.persistence + .fail_next_store + .store(true, Ordering::SeqCst); + + let error = ctx + .manager + .reconcile_asset_lock_submit_result::<()>( + Err(already_consumed_error(ctx.out_point)), + &ctx.out_point, + &ctx.proof, + None, + ) + .await + .expect_err("host persistence rejection must surface"); + assert!(matches!(error, PlatformWalletError::Persistence(_))); + + let wm = ctx.wallet_manager.read().await; + let lock = wm + .get_wallet_info(&ctx.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&ctx.out_point) + .expect("tracked lock"); + assert_eq!(lock.status, AssetLockStatus::ChainLocked); + assert_eq!(lock.proof, Some(ctx.proof)); + } + + #[tokio::test] + async fn transient_flush_failure_keeps_buffered_candidate_in_memory() { + let ctx = consumption_report_context().await; + *ctx.persistence + .fail_next_flush + .lock() + .expect("recording persistence mutex") = Some(PersistenceErrorKind::Transient); + + let error = ctx + .manager + .reconcile_asset_lock_submit_result::<()>( + Err(already_consumed_error(ctx.out_point)), + &ctx.out_point, + &ctx.proof, + None, + ) + .await + .expect_err("host flush rejection must surface"); + assert!(matches!(error, PlatformWalletError::Persistence(_))); + + let wm = ctx.wallet_manager.read().await; + let lock = wm + .get_wallet_info(&ctx.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&ctx.out_point) + .expect("tracked lock"); + assert_eq!(lock.status, AssetLockStatus::RecoveredFromChain); + } + + #[tokio::test] + async fn fatal_post_commit_flush_failure_keeps_durable_candidate_in_memory() { + let ctx = consumption_report_context().await; + ctx.persistence + .store_commits_inline + .store(true, Ordering::SeqCst); + *ctx.persistence + .fail_next_flush + .lock() + .expect("recording persistence mutex") = Some(PersistenceErrorKind::Fatal); + + let error = ctx + .manager + .reconcile_asset_lock_submit_result::<()>( + Err(already_consumed_error(ctx.out_point)), + &ctx.out_point, + &ctx.proof, + None, + ) + .await + .expect_err("post-commit flush rejection must surface"); + assert!(matches!(error, PlatformWalletError::Persistence(_))); + + let wm = ctx.wallet_manager.read().await; + let lock = wm + .get_wallet_info(&ctx.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&ctx.out_point) + .expect("tracked lock"); + assert_eq!(lock.status, AssetLockStatus::RecoveredFromChain); + } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift index 1ddf368a9b5..751f4bc4e5e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift @@ -119,10 +119,10 @@ public final class PersistentAssetLock { /// compares against 0/1/2/3 and the resumable-locks filter against /// 4 to hide already-spent rows). /// - /// `5` (RecoveredFromChain) rows are written by the SDK's - /// restore-scan reconstruction: the lock is confirmed on chain but - /// its Platform-side consumption is unknown, so UIs must treat it - /// as neither pending (1…3) nor done (4). + /// `5` (RecoveredFromChain) rows are written by restore reconstruction or + /// live reconciliation of an unauthenticated already-consumed report. The + /// lock is confirmed on Core but its Platform-side consumption is unknown, + /// so UIs must treat it as neither pending (1…3) nor done (4). public var statusRaw: Int /// Bincode-encoded `AssetLockProof` (`dpp::bincode::config::standard()`). diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift index 5188293dbb4..c7dd57e8f2c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift @@ -54,9 +54,8 @@ public final class ManagedAssetLockManager: @unchecked Sendable { /// locked amount); a Consumed lock cannot fund another /// identity. case consumed = 4 - /// Reconstructed from on-chain history after a wallet restore - /// (the SDK's restore-scan reconstruction) rather than tracked - /// live through the build pipeline. Core-side finality is + /// Produced by restore reconstruction or live reconciliation of an + /// unauthenticated already-consumed report. Core-side finality is /// known, but Platform-side consumption is UNKNOWN — the lock /// may have funded an identity long ago, or be unspent /// stranded value. Deliberately outside both the pending diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 90a2a555818..4a0f6584125 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -62,6 +62,9 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// counterparty) are mirrored durably. Mirrors /// `PersistenceCapabilities::DPNS_NAME_STATES`. public static let dpnsNameStates: UInt64 = 1 << 8 + /// Tracked asset-lock rows, including status and proof updates, can be + /// persisted. Restart hydration is separately attested by `walletRestore`. + public static let trackedAssetLocks: UInt64 = 1 << 9 public let version: UInt32 public let bits: UInt64 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index d37ccc483f2..06b5289ed8e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1476,6 +1476,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.unsignedTokenStorage | PlatformWalletPersistenceCapabilities.walletRestore | PlatformWalletPersistenceCapabilities.dpnsNameStates + | PlatformWalletPersistenceCapabilities.trackedAssetLocks ) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index c09e480a489..8528fe091dd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -317,6 +317,9 @@ public enum PlatformWalletError: LocalizedError { case noSelectableInputs(String) case coreInsufficientFunds(String) case assetLockNotTracked(String) + /// The one-shot output cannot be reused. This may come from a retained + /// local tombstone or an unauthenticated Platform report, so it does not + /// prove that the requested operation completed. case assetLockAlreadyConsumed(String) case assetLockFundingMismatch(String) case walletAlreadyExists(String) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift index c911a35b74d..57ee7f841b4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ReclaimInvitationSheet.swift @@ -11,16 +11,10 @@ import SwiftUI /// the voucher. /// /// On success the row's `statusRaw` flips to Reclaimed locally — SwiftData is the -/// UI source of truth here (no Rust re-emit). If the voucher was already consumed -/// (the invitee claimed it), the reclaim is rejected deterministically and the -/// row flips to Claimed with a neutral message instead. -/// -/// Reclaimed is asserted on a successful consume observed by this attempt, or -/// when the wallet returns its typed `assetLockAlreadyConsumed` error from a -/// retained local tombstone. That tombstone is written only after this wallet -/// successfully consumed the lock, so it is stronger evidence than the -/// persisted `reclaimInFlight` marker. A consensus-message fallback has no such -/// attribution and remains Claimed or explicitly ambiguous. +/// UI source of truth here (no Rust re-emit). A typed or legacy already-consumed +/// error is not success evidence: it can be an unauthenticated Platform report, +/// so the row and in-flight marker remain unchanged until another source proves +/// the outcome. struct ReclaimInvitationSheet: View { let invitation: PersistentInvitation let walletId: Data @@ -198,15 +192,14 @@ struct ReclaimInvitationSheet: View { // consume — never before pre-broadcast local work (e.g. register's // key pre-persist). The marker does NOT attribute a later "already // consumed" rejection (the invitee can race our crash-interrupted - // consume); it only downgrades the classification from "provably a - // foreign claim" to "explicitly ambiguous". Setting it earlier - // would let a purely local failure leave the marker set, degrading - // a subsequent genuine foreign claim into the ambiguous message. + // consume); it remains crash evidence while the operation outcome + // is unknown. Setting it earlier would let a purely local failure + // leave a misleading recovery marker. // `hadPriorReclaimInFlight` captures the PERSISTED prior value first. // The save must SUCCEED before the consume may run: an unpersisted // marker followed by a consume + crash would strand the row (a local // "is not tracked" retry classifies as an error, and a Platform - // "already consumed" as a foreign claim). On a failed save the + // "already consumed" as consumption unknown). On a failed save the // in-memory flag is rolled back so an unrelated later save can't // leak a marker that never reached disk, and the throw aborts the // reclaim before anything irreversible. @@ -273,43 +266,15 @@ struct ReclaimInvitationSheet: View { error: error, hadPriorReclaimInFlight: hadPriorReclaimInFlight ) { - case .reclaimed: - // The wallet retained a local consumed tombstone. It is - // written only after this wallet's consume succeeds, so a - // retry can safely recover the local row as Reclaimed. - invitation.statusRaw = 2 - invitation.reclaimInFlight = false - invitation.updatedAt = Date() - try? modelContext.save() - infoMessage = - "This invitation was already reclaimed by this wallet. " - + "The credits were delivered to the target selected " - + "for that reclaim." - case .claimed: - // Someone else claimed the voucher first. Reflect the terminal - // state with a neutral message (the claimant is intentionally - // not named). - invitation.statusRaw = 1 - invitation.reclaimInFlight = false - invitation.updatedAt = Date() - try? modelContext.save() - infoMessage = "This invitation was already claimed." - case .consumedAmbiguous: - // The voucher is provably consumed (Platform's deterministic - // rejection), but with our own earlier attempt in flight the - // consumer could be EITHER that attempt or a racing claim — - // the marker is not evidence tied to a submitted transition, - // so never upgrade to Reclaimed. Terminal-Claimed is the - // conservative resolution; the message states the ambiguity. - invitation.statusRaw = 1 - invitation.reclaimInFlight = false - invitation.updatedAt = Date() - try? modelContext.save() - infoMessage = - "This invitation was already consumed — by the invitee's " - + "claim, or possibly by your own earlier interrupted " - + "reclaim. If that reclaim went through, the credits were " - + "delivered to the target you selected then." + case .consumptionUnknown: + // Code 24 can mean either a retained local tombstone or an + // unauthenticated Platform report. Retain both status and + // the in-flight marker because this reclaim's completion + // cannot be inferred safely. + errorMessage = + "This asset lock was reported as already used, but the " + + "wallet could not verify whether this reclaim completed. " + + "Sync and check the selected target before retrying." case .untrackedAfterOwnAttempt: // The wallet no longer tracks the voucher lock and our own // attempt was in flight — consistent with that attempt's @@ -369,23 +334,15 @@ struct ReclaimInvitationSheet: View { return highest == UInt32.max ? UInt32.max : highest + 1 } - /// The terminal state a reclaim attempt resolves to. + /// The state a failed reclaim attempt resolves to. /// - /// A typed local consumed-tombstone result can recover `.reclaimed`. The - /// persisted `reclaimInFlight` marker alone cannot: it is not tied to a - /// submitted transition or target, so consensus-message fallback remains - /// conservatively Claimed or ambiguous. + /// Code 24 and the legacy consensus wording share one conservative outcome: + /// neither distinguishes a local tombstone from an unauthenticated remote + /// report, so the specific operation's completion remains unknown. enum ReclaimOutcome: Equatable { - /// This wallet retained a tombstone written after its successful local - /// consume, so the interrupted reclaim can be recovered definitively. - case reclaimed - /// The voucher was consumed and no local attempt was in flight — a - /// foreign claim, unambiguously. - case claimed - /// The voucher is provably consumed (deterministic Platform - /// rejection), but our own in-flight attempt makes the consumer - /// ambiguous: it could be that attempt or a racing foreign claim. - case consumedAmbiguous + /// The lock was reported as consumed, but neither consumption nor this + /// reclaim's completion is authenticated. + case consumptionUnknown /// The wallet no longer tracks the voucher lock and our own attempt /// was in flight — consistent with that attempt's consume having /// landed, but with no on-chain proof of consumption at all. Leave @@ -396,26 +353,15 @@ struct ReclaimInvitationSheet: View { } /// Pure decision for the reclaim `catch`. A typed wallet - /// `assetLockAlreadyConsumed` error comes from a retained local tombstone - /// written after a successful consume, and therefore recovers Reclaimed. - /// Consensus wording is only proof that the lock is consumed, not who - /// consumed it, so the prior in-flight marker splits that fallback into a - /// foreign claim vs an explicitly ambiguous consumption. Kept - /// side-effect-free and `nonisolated` so it is the unit-tested seam for all - /// outcomes; the view maps the outcome to `statusRaw`/message/save. + /// `assetLockAlreadyConsumed` and the legacy consensus wording are both + /// consumption-unknown signals. Kept side-effect-free and `nonisolated` so + /// it is the unit-tested seam for all outcomes. nonisolated static func classifyReclaimFailure( error: Error, hadPriorReclaimInFlight: Bool ) -> ReclaimOutcome { - if isLocallyConsumedTombstone(error) { - return .reclaimed - } - if isAlreadyConsumed(message: error.localizedDescription) { - // Platform deterministically rejected the consume as already-spent, - // but this compatibility fallback cannot attribute the consumer. - // With no local attempt in flight that is a foreign claim; with one - // in flight, attribution is unknowable from the marker alone. - return hadPriorReclaimInFlight ? .consumedAmbiguous : .claimed + if isAlreadyConsumed(error) { + return .consumptionUnknown } // A retry after our own crash-interrupted consume can also fail // LOCALLY ("…is not tracked") — before Platform, so `isAlreadyConsumed` @@ -460,31 +406,22 @@ struct ReclaimInvitationSheet: View { message.lowercased().contains("is not tracked") } - /// Whether an error is the deterministic "asset lock outpoint already - /// consumed" rejection (consensus code 10504). The SDK surfaces a consensus + /// Whether an error is the typed signal or legacy wording for consensus + /// code 10504. The SDK surfaces a legacy consensus /// error as `"SDK error: Protocol error: "`, so /// the canonical Display of /// `IdentityAssetLockTransactionOutPointAlreadyConsumedError` — /// "Asset lock transaction … already completely used" — appears verbatim. /// Matched on that exact phrase ONLY: broader phrases like "already consumed" - /// never occur in the real Display and would only widen false-positive risk - /// (misclassifying an unrelated failure as a benign "already claimed", which - /// would wrongly flip the row to Claimed). A typed FFI result code is the - /// primary signal, with the consensus wording retained as a compatibility - /// fallback for errors originating below the typed wallet boundary. + /// never occur in the real Display and would only widen false-positive risk. + /// Neither form authenticates consumption or proves this reclaim completed; + /// the typed FFI result is primary and wording is compatibility-only. nonisolated static func isAlreadyConsumed(_ error: Error) -> Bool { - isLocallyConsumedTombstone(error) - || isAlreadyConsumed(message: error.localizedDescription) - } - - /// Whether the wallet rejected a retry from its retained local consumed - /// tombstone. Unlike consensus wording, this identifies a prior successful - /// consume by this wallet and can safely recover the row as Reclaimed. - nonisolated static func isLocallyConsumedTombstone(_ error: Error) -> Bool { - guard let walletError = error as? PlatformWalletError, - case .assetLockAlreadyConsumed = walletError - else { return false } - return true + if let walletError = error as? PlatformWalletError, + case .assetLockAlreadyConsumed = walletError { + return true + } + return isAlreadyConsumed(message: error.localizedDescription) } /// Pure classifier over the surfaced error message — the testable seam for diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift index 5bf765744b7..e18afb2dacc 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ReclaimInvitationClassifierTests.swift @@ -3,9 +3,8 @@ import SwiftDashSDK @testable import SwiftExampleApp /// Pins `ReclaimInvitationSheet.isAlreadyConsumed(message:)` — the classifier -/// that decides whether a failed reclaim is the benign "voucher already claimed" -/// case (flip the row to Claimed, show a neutral message) versus a real error -/// (surface it). +/// that decides whether a failed reclaim is a consumption-unknown case versus +/// a real unrelated error. /// /// The SDK surfaces a consensus error as /// `"SDK error: Protocol error: "`, so the match is @@ -21,13 +20,11 @@ final class ReclaimInvitationClassifierTests: XCTestCase { func test_typedAlreadyConsumed_classifiedTrue() { let error = PlatformWalletError.assetLockAlreadyConsumed("deadbeef:0") XCTAssertTrue(ReclaimInvitationSheet.isAlreadyConsumed(error)) - XCTAssertTrue(ReclaimInvitationSheet.isLocallyConsumedTombstone(error)) } func test_typedNotTracked_classifiedFalse() { let error = PlatformWalletError.assetLockNotTracked("deadbeef:0") XCTAssertFalse(ReclaimInvitationSheet.isAlreadyConsumed(error)) - XCTAssertFalse(ReclaimInvitationSheet.isLocallyConsumedTombstone(error)) } /// The real already-consumed rejection, as surfaced to Swift. @@ -56,7 +53,7 @@ final class ReclaimInvitationClassifierTests: XCTestCase { XCTAssertFalse(ReclaimInvitationSheet.isAlreadyConsumed(message: message)) } - /// An unrelated transport failure must not be swallowed as "already claimed". + /// An unrelated transport failure must not be swallowed as consumption unknown. func test_networkError_classifiedFalse() { XCTAssertFalse( ReclaimInvitationSheet.isAlreadyConsumed( @@ -85,40 +82,35 @@ final class ReclaimInvitationClassifierTests: XCTestCase { + "output 0 already completely used" ) - /// Consensus already-consumed wording + our own reclaim was in flight is - /// explicitly ambiguous: the marker only proves that a local attempt - /// started, while the consensus error cannot attribute who consumed it. - func test_classify_consensusAlreadyConsumed_priorInFlight_isAmbiguous() { + /// Consensus wording is unauthenticated, regardless of the local marker. + func test_classify_consensusAlreadyConsumed_priorInFlight_isUnknown() { XCTAssertEqual( ReclaimInvitationSheet.classifyReclaimFailure( error: Self.alreadyConsumed, hadPriorReclaimInFlight: true), - .consumedAmbiguous + .consumptionUnknown ) } - /// Consensus already-consumed wording + no prior reclaim resolves to Claimed. - func test_classify_consensusAlreadyConsumed_noPrior_isClaimed() { + func test_classify_consensusAlreadyConsumed_noPrior_isUnknown() { XCTAssertEqual( ReclaimInvitationSheet.classifyReclaimFailure( error: Self.alreadyConsumed, hadPriorReclaimInFlight: false), - .claimed + .consumptionUnknown ) } - /// Typed code 24 is emitted from the wallet's retained local consumed - /// tombstone, which is written only after this wallet successfully consumed - /// the lock. It therefore recovers Reclaimed regardless of the UI marker. - func test_classify_typedConsumedTombstone_isReclaimed_regardlessOfMarker() { + /// Code 24 no longer distinguishes a local tombstone from a remote report. + func test_classify_typedAlreadyConsumed_isUnknown_regardlessOfMarker() { let error = PlatformWalletError.assetLockAlreadyConsumed("deadbeef:0") XCTAssertEqual( ReclaimInvitationSheet.classifyReclaimFailure( error: error, hadPriorReclaimInFlight: true), - .reclaimed + .consumptionUnknown ) XCTAssertEqual( ReclaimInvitationSheet.classifyReclaimFailure( error: error, hadPriorReclaimInFlight: false), - .reclaimed + .consumptionUnknown ) } @@ -148,7 +140,7 @@ final class ReclaimInvitationClassifierTests: XCTestCase { /// A retry after our own crash-interrupted consume can fail LOCALLY /// ("…is not tracked"). With the marker set that is consistent with our /// consume having landed, but it is NOT on-chain proof — so it resolves to - /// the explicitly ambiguous `.untrackedAfterOwnAttempt`, never `.reclaimed`. + /// the explicitly ambiguous `.untrackedAfterOwnAttempt`, never success. func test_classify_lockNotTracked_priorInFlight_isUntrackedAmbiguous() { XCTAssertEqual( ReclaimInvitationSheet.classifyReclaimFailure( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index b8b6d21cfa1..d9ffd747989 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -56,6 +56,7 @@ final class InvitationPersistenceTests: XCTestCase { // `on_persist_dpns_name_states_fn` and lands the rows on // `PersistentDPNSName`, so this bit is genuinely attested. | PlatformWalletPersistenceCapabilities.dpnsNameStates + | PlatformWalletPersistenceCapabilities.trackedAssetLocks XCTAssertEqual( capabilities.version,