diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index d5f32274deb..aa26292d4b4 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -4,11 +4,13 @@ "description": "Executable current Kotlin/Swift parity truth for the correctness, persistence, recovery, shared-policy, and immediate example-app capabilities audited from the PR #3999 baseline.", "declared_persistence_capabilities": [ "asset_lock_funding_indices", + "asset_lock_operation_ids", "atomic_changesets", "invitations", "pending_contact_crypto", "provider_transactions", "shielded_viewing_keys", + "tracked_asset_locks", "unsigned_token_storage", "wallet_restore" ], @@ -40,6 +42,9 @@ "platform_wallet_drain_pending_contact_crypto": "packages/rs-platform-wallet-ffi/src/dashpay.rs", "platform_wallet_manager_create_with_persistence_capabilities": "packages/rs-platform-wallet-ffi/src/manager.rs", "platform_wallet_manager_persistence_capabilities": "packages/rs-platform-wallet-ffi/src/manager.rs", + "platform_wallet_manager_shielded_fund_from_wallet_drain": "packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs", + "platform_wallet_manager_shielded_wallet_drain_preview": "packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs", + "platform_wallet_manager_shielded_wallet_drain_status": "packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs", "platform_wallet_manager_spv_rescan_filters": "packages/rs-platform-wallet-ffi/src/spv.rs", "platform_wallet_manager_sync_fault_detected": "packages/rs-platform-wallet-ffi/src/manager.rs", "platform_wallet_resume_identity_with_existing_asset_lock_signer": "packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs", @@ -1604,6 +1609,87 @@ "covers_restart": false } ] + }, + { + "id": "shielded.core_wallet_drain", + "title": "Pooled Core-to-shielded Max with restart-safe recovery", + "area": "recovery", + "shared_apis": [ + "platform_wallet_manager_shielded_wallet_drain_preview", + "platform_wallet_manager_shielded_fund_from_wallet_drain", + "platform_wallet_manager_shielded_wallet_drain_status" + ], + "required_persistence_capabilities": [ + "asset_lock_funding_indices", + "asset_lock_operation_ids", + "atomic_changesets", + "tracked_asset_locks", + "wallet_restore" + ], + "hosts": { + "swift": { + "sdk": "supported", + "example_app": "unsupported", + "restart": "tested", + "reason": "Swift exposes preview, execution, and status with durable operation UUIDs. Restart recovery resumes the exact asset-lock outpoint: a fresh Type18 that Platform executes, or that Platform rejects because the outpoint was already consumed by an earlier attempt, durably completes the operation. Unit tests cover store reopen, failed persistence and the terminal DrainCompleted rule. An iOS wallet testnet process-death test confirmed recovery after dispatch and enabled a subsequent Max transfer. SwiftExampleApp does not yet expose this flow." + }, + "kotlin": { + "sdk": "unsupported", + "example_app": "unsupported", + "restart": "not_applicable", + "reason": "The typed input-limit error is mapped, but JNI drain wrappers and operation-ID callbacks are not implemented." + } + }, + "verification": [ + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift", + "id": "testShouldPreserveExactOutpointAndErrorForPendingOperation", + "command": "cd packages/swift-sdk && xcodebuild test -scheme SwiftDashSDK -sdk iphonesimulator -destination \"platform=iOS Simulator,name=iPhone 17\" ARCHS=arm64 -only-testing:SwiftDashSDKTests/WalletDrainTests", + "covers_restart": false + }, + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift", + "id": "testOperationIdSurvivesConsumptionAndStoreReopen", + "command": "cd packages/swift-sdk && xcodebuild test -scheme SwiftDashSDK -sdk iphonesimulator -destination \"platform=iOS Simulator,name=iPhone 17\" ARCHS=arm64 -only-testing:SwiftDashSDKTests/AssetLockOperationPersistenceTests", + "covers_restart": true + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet/src/wallet/asset_lock/build.rs", + "id": "should_wallet_drain_panic_keep_exact_outpoint_and_pending_status", + "command": "cargo test -p platform-wallet --features shielded --lib should_wallet_drain_panic_keep_exact_outpoint_and_pending_status", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs", + "id": "settle_completes_drain_on_exact_outpoint_already_consumed_report", + "command": "cargo test -p platform-wallet --features shielded --lib settle_completes_drain_on_exact_outpoint_already_consumed_report", + "covers_restart": true + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs", + "id": "should_resume_the_same_lock_for_a_reused_uuid_without_a_second_core_lock", + "command": "cargo test -p platform-wallet --features shielded --lib wallet_drain_tests", + "covers_restart": true + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs", + "id": "should_refuse_drain_on_a_buffered_persistence_backend", + "command": "cargo test -p platform-wallet --features shielded --lib should_refuse_drain_on_a_buffered_persistence_backend", + "covers_restart": false + } + ] } ] } diff --git a/packages/kotlin-sdk/PARITY_SUMMARY.md b/packages/kotlin-sdk/PARITY_SUMMARY.md index 300a34c375c..ff6bef9ca6e 100644 --- a/packages/kotlin-sdk/PARITY_SUMMARY.md +++ b/packages/kotlin-sdk/PARITY_SUMMARY.md @@ -2,23 +2,23 @@ # Kotlin/Swift executable parity summary Audit baseline: `PR #3999 @ 6dbc72a54df72d26eb9c4a014b425d2b95134e4e` -Capabilities tracked: **26** +Capabilities tracked: **27** ## Status counts | Host | Surface | Supported | Partial | Unsupported | Not applicable | | --- | --- | ---: | ---: | ---: | ---: | -| Swift | SDK | 15 | 9 | 1 | 1 | -| Swift | Example app | 4 | 12 | 1 | 9 | -| Kotlin | SDK | 13 | 12 | 0 | 1 | -| Kotlin | Example app | 5 | 12 | 0 | 9 | +| Swift | SDK | 16 | 9 | 1 | 1 | +| Swift | Example app | 4 | 12 | 2 | 9 | +| Kotlin | SDK | 13 | 12 | 1 | 1 | +| Kotlin | Example app | 5 | 12 | 1 | 9 | ## Restart coverage | Host | Tested | Required | Not applicable | | --- | ---: | ---: | ---: | -| Swift | 0 | 8 | 18 | -| Kotlin | 4 | 6 | 16 | +| Swift | 1 | 8 | 18 | +| Kotlin | 4 | 6 | 17 | ## Capability status @@ -50,6 +50,7 @@ Capabilities tracked: **26** | `errors.platform_wallet_not_found_typed` | supported / not-applicable / not_applicable | supported / not-applicable / not_applicable | | `persistence.pending_identity_key_repair` | unsupported / unsupported / not_applicable | supported / not-applicable / tested | | `errors.signing_key_unavailable_discriminator` | supported / not-applicable / not_applicable | supported / not-applicable / not_applicable | +| `shielded.core_wallet_drain` | supported / unsupported / tested | unsupported / unsupported / not_applicable | Source: [`docs/sdk/sdk-parity-manifest.json`](../../docs/sdk/sdk-parity-manifest.json). Detailed legacy view mapping remains in [`PARITY.md`](PARITY.md), but its manual totals are not authoritative. 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 5db48d4f6d9..3227c3d606b 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 @@ -236,6 +236,10 @@ sealed class DashSdkError( class AssetLockInsufficientFunds(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** Code 60: the full drain exceeds the input cap; no partial drain was submitted. */ + class AssetLockTooManyInputs(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * `ErrorShieldedNoRecordedAnchor` (native code 19). A shielded spend * could not be built against a Platform-recorded anchor because the @@ -763,6 +767,7 @@ sealed class DashSdkError( 26 -> PlatformWallet.TransactionBroadcastRejected(message, cause) // ErrorTransactionBroadcastRejected 27 -> PlatformWallet.ShutdownIncomplete(message, cause) // ErrorShutdownIncomplete 29 -> PlatformWallet.AssetLockInsufficientFunds(message, cause) // ErrorAssetLockInsufficientFunds + 60 -> PlatformWallet.AssetLockTooManyInputs(message, cause) // ErrorAssetLockTooManyInputs // The deferred-token trio sits at the contiguous block 34-36 because // 27-33 are claimed elsewhere: 27 ErrorShutdownIncomplete // (dashpay/platform#4268, merged), 29 ErrorAssetLockInsufficientFunds diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 3bb82014a4a..fd09ae4051a 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -9,6 +9,16 @@ import org.junit.Test class DashSdkErrorTest { + @Test + fun shouldPreserveAssetLockInputCapWithoutRetryingAPartialDrain() { + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 60, "101 exceeds 100") + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.AssetLockTooManyInputs) + assertEquals(native.message, mapped.message) + assertEquals(native, mapped.cause) + assertFalse(mapped.isRetryable) + } + @Test fun mapsNativeCodesToHierarchy() { // Codes mirror DashSDKErrorCode in rs-sdk-ffi/src/error.rs. diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 0b68fb196c0..a916a4eb433 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,14 +114,16 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 60** — 27–59 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 61** — 27–60 are all claimed (27, 29, 31, 34–42 and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and 48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their rows below); 49–54 proposed by active #4586 (the persister operation × kind block); 55–57 proposed by #4715 for pending identity-funded shield debits and durable recovery errors; 58 proposed by #4799 for an unavailable identity balance response; 59 proposed by #5322 for an SPV -teardown that needs a process restart; 28, 30, +teardown that needs a process restart; 60 proposed by the pooled Core-to-shielded +wallet drain for `ErrorAssetLockTooManyInputs` (Rust, Swift and Kotlin mirrors — +renumbered from 59 when #5322 took that number on `v5.1-dev`); 28, 30, 32 and 33 reserved). **28, 30, 32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners @@ -174,6 +176,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 57 | `ErrorShieldedRecoveryKeysRequired` | #4715 | Proposed — recovery needs the account and compatible keys; ciphertext damage can produce the same symptom. Rust, Swift and Kotlin preserve this typed error | | 58 | `ErrorIdentityBalanceUnavailable` | #4799 | Proposed — Platform returned no balance for a managed identity. Retrying the read is safe; this is distinct from missing wallet ownership and must not trigger registration or funding | | 59 | `ErrorSpvProcessRestartRequired` | #5322 | Proposed — SPV startup or teardown panicked; restart the host process. Not retryable, unlike 27, which SPV start, stop and storage clear now return while an SPV teardown is still running. Typed in Rust, Swift and Kotlin (the PR also types 27 on Kotlin) | +| 60 | `ErrorAssetLockTooManyInputs` | Core-to-shielded pooled Max | Proposed — the full drain needs more inputs than an asset lock may carry (100 on Dash Core relay policy and Platform consensus); no partial drain was broadcast. Rust, Swift and Kotlin retain the typed refusal. Renumbered from 59 after #5322 claimed it | **Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged 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 2e5e9068292..ba95989bb6a 100644 --- a/packages/rs-platform-wallet-ffi/src/asset_lock/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/asset_lock/manager.rs @@ -25,7 +25,8 @@ pub struct TrackedAssetLockFFI { pub amount: u64, /// Status (0=Built, 1=Broadcast, 2=InstantSendLocked, 3=ChainLocked, /// 4=Consumed, 5=RecoveredFromChain — Core finality proven by restore - /// reconstruction or live reconciliation, Platform-side consumption unknown). + /// reconstruction or live reconciliation, Platform-side consumption unknown; + /// 6=DrainCompleted — proven drain consumption and owned output recorded durably). pub status: u32, /// Whether a proof is attached. pub has_proof: bool, @@ -81,6 +82,7 @@ pub unsafe extern "C" fn asset_lock_manager_list_tracked_locks( AssetLockStatus::ChainLocked => 3, AssetLockStatus::Consumed => 4, AssetLockStatus::RecoveredFromChain => 5, + AssetLockStatus::DrainCompleted => 6, }, has_proof: lock.proof.is_some(), } diff --git a/packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs b/packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs index fc0f4e0d5fe..6aa0fda0b85 100644 --- a/packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs @@ -30,6 +30,8 @@ use platform_wallet::AssetLockStatus; /// bincode-encoded with `dpp::bincode::config::standard()`. Both byte /// slices are Rust-owned for the lifetime of the callback and live in /// the matching [`AssetLockEntryStorage`]. +/// This unversioned row's layout is frozen. Operation UUIDs travel through +/// the size-negotiated persistence extension, never by changing this stride. #[repr(C)] #[derive(Debug, Clone, Copy)] pub struct AssetLockEntryFFI { @@ -55,7 +57,8 @@ pub struct AssetLockEntryFFI { /// Locked amount in duffs (1 DASH = 1e8 duffs). pub amount_duffs: u64, /// Discriminant of [`AssetLockStatus`]: - /// 0 = Built, 1 = Broadcast, 2 = InstantSendLocked, 3 = ChainLocked. + /// 0 = Built, 1 = Broadcast, 2 = InstantSendLocked, 3 = ChainLocked, + /// 4 = Consumed, 5 = RecoveredFromChain, 6 = DrainCompleted (proven owned output). pub status: u8, /// Bincode-encoded [`AssetLockProof`] (standard config). Rust-owned, /// `null` + `0` length when the entry has no proof yet (statuses @@ -170,5 +173,53 @@ fn status_to_u8(status: &AssetLockStatus) -> u8 { AssetLockStatus::ChainLocked => 3, AssetLockStatus::Consumed => 4, AssetLockStatus::RecoveredFromChain => 5, + AssetLockStatus::DrainCompleted => 6, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_keep_the_published_asset_lock_row_layout() { + // A host compiled before drain UUID support still allocates and indexes + // this exact shape. Sidecars must not move any field or grow its stride. + #[repr(C)] + struct LegacyAssetLockEntry { + out_point: [u8; 36], + transaction_bytes: *const u8, + transaction_bytes_len: usize, + account_index: u32, + funding_type: u8, + identity_index: u32, + amount_duffs: u64, + status: u8, + proof_bytes: *const u8, + proof_bytes_len: usize, + } + assert_eq!( + std::mem::size_of::(), + std::mem::size_of::() + ); + assert_eq!( + std::mem::align_of::(), + std::mem::align_of::() + ); + macro_rules! same_offset { + ($($field:ident),+) => { $(assert_eq!(std::mem::offset_of!(AssetLockEntryFFI, $field), std::mem::offset_of!(LegacyAssetLockEntry, $field));)+ }; + } + same_offset!( + out_point, + transaction_bytes, + transaction_bytes_len, + account_index, + funding_type, + identity_index, + amount_duffs, + status, + proof_bytes, + proof_bytes_len + ); } } diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 4e13906dfe0..0bc67f532c9 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -609,6 +609,11 @@ pub enum PlatformWalletFFIResultCode { /// [`Self::ErrorShutdownIncomplete`]: restart the host process. ErrorSpvProcessRestartRequired = 59, + /// An all-input asset lock exceeds the asset-lock input cap (100: Dash + /// Core relay policy and Platform consensus). Nothing was broadcast; the + /// host must not silently substitute a partial drain. + ErrorAssetLockTooManyInputs = 60, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -1020,6 +1025,9 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::AssetLockInsufficientFunds { .. } => { PlatformWalletFFIResultCode::ErrorAssetLockInsufficientFunds } + PlatformWalletError::AssetLockTooManyInputs { .. } => { + PlatformWalletFFIResultCode::ErrorAssetLockTooManyInputs + } // A quiesce/drain barrier that did not complete within budget // (clear/reset paths). The host must fail closed: keep its // callback context alive and skip any paired persistence wipe. diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index f9bf8f2c602..6b7b6f5f258 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -80,6 +80,8 @@ pub mod shielded_send; #[cfg(feature = "shielded")] pub mod shielded_sync; pub mod shielded_types; +#[cfg(feature = "shielded")] +pub mod shielded_wallet_drain; pub mod sign_with_mnemonic_resolver; pub mod spv; pub mod token_persistence; @@ -156,6 +158,8 @@ pub use shielded_send::*; #[cfg(feature = "shielded")] pub use shielded_sync::*; pub use shielded_types::*; +#[cfg(feature = "shielded")] +pub use shielded_wallet_drain::*; pub use sign_with_mnemonic_resolver::*; pub use spv::*; pub use token_persistence::*; diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index e1ccdaad053..1ae61e57131 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,12 +8,12 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, FreeTrackedMasternodesFn, LoadIdentityBalanceBlockTimeFn, - LoadTrackedMasternodesFn, PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, - PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, - PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, - PersistWalletDashPayBackfillFn, PersistenceCallbacks, PersistenceCallbacksExtension, - PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, + FFIPersister, FreeTrackedMasternodesFn, LoadAssetLockOperationIdFn, + LoadIdentityBalanceBlockTimeFn, LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, + PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, + PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn, + PersistWalletChangesetUtxoVerdictsFn, PersistWalletDashPayBackfillFn, PersistenceCallbacks, + PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; @@ -282,6 +282,14 @@ unsafe fn persistence_extension_callbacks( on_persist_wallet_dashpay_backfill_fn, PersistWalletDashPayBackfillFn ), + persist_asset_lock_operation_id: slot!( + on_persist_asset_lock_operation_id_fn, + PersistAssetLockOperationIdFn + ), + load_asset_lock_operation_id: slot!( + on_load_asset_lock_operation_id_fn, + LoadAssetLockOperationIdFn + ), } } @@ -1355,6 +1363,49 @@ mod tests { assert!(parsed.load_identity_balance_block_time.is_none()); } + #[test] + fn should_size_gate_asset_lock_operation_id_slots_independently() { + unsafe extern "C" fn persist( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *const u8, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *mut bool, + _: *mut u8, + ) -> i32 { + 0 + } + let mut ext = PersistenceCallbacksExtension { + on_persist_asset_lock_operation_id_fn: Some(persist), + on_load_asset_lock_operation_id_fn: Some(load), + ..Default::default() + }; + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_asset_lock_operation_id.is_some()); + assert!(parsed.load_asset_lock_operation_id.is_some()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_asset_lock_operation_id_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_asset_lock_operation_id.is_some()); + assert!(parsed.load_asset_lock_operation_id.is_none()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_asset_lock_operation_id_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_asset_lock_operation_id.is_none()); + assert!(parsed.load_asset_lock_operation_id.is_none()); + } + /// A host whose `struct_size` stops right after the chainlock-height /// slot (built before the credit-verdict slot existed) keeps every /// earlier slot and simply never has the verdict slot read; a host diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index d163cb203c7..69ef9433a5b 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -41,6 +41,7 @@ use platform_wallet::wallet::platform_wallet::WalletId; use platform_wallet::wallet::shielded::ShieldedActivityStatus; use platform_wallet::wallet::{PerAccountPlatformAddressState, PerWalletPlatformAddressState}; use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::ffi::CString; use std::os::raw::c_char; use std::os::raw::c_void; @@ -155,6 +156,7 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_ASSET_LOCKS: u64 = 1 << pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_MASTERNODES: u64 = 1 << 10; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_SWEEP_REMOVAL: u64 = 1 << 11; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS: u64 = 1 << 12; +pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS: u64 = 1 << 13; /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately /// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by @@ -296,6 +298,27 @@ pub type PersistWalletDashPayBackfillFn = unsafe extern "C" fn( covered_count: usize, ) -> i32; +/// Associate an immutable 16-byte operation UUID with a 36-byte raw outpoint. +/// Runs after the legacy asset-lock upsert, inside the same atomic changeset. +/// All three byte pointers are readable for the duration of the callback. +pub type PersistAssetLockOperationIdFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_point: *const u8, + operation_id: *const u8, +) -> i32; + +/// Read the optional UUID for a restored outpoint, including consumed rows. +/// `out_operation_id` points to 16 writable bytes. An unreadable or missing +/// asset-lock row must return an error, not a successful `out_found = false`. +pub type LoadAssetLockOperationIdFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_point: *const u8, + out_found: *mut bool, + out_operation_id: *mut u8, +) -> i32; + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -442,6 +465,26 @@ pub struct PersistenceCallbacksExtension { covered_count: usize, ) -> i32, >, + /// Additive sidecar: the unversioned `AssetLockEntryFFI` stride is frozen. + /// Both slots plus the legacy persist/load callbacks are required before + /// the host can attest `ASSET_LOCK_OPERATION_IDS`. + pub on_persist_asset_lock_operation_id_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_point: *const u8, + operation_id: *const u8, + ) -> i32, + >, + pub on_load_asset_lock_operation_id_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_point: *const u8, + out_found: *mut bool, + out_operation_id: *mut u8, + ) -> i32, + >, } impl Default for PersistenceCallbacksExtension { @@ -460,6 +503,8 @@ impl Default for PersistenceCallbacksExtension { on_persist_identity_balance_block_time_fn: None, on_load_identity_balance_block_time_fn: None, on_persist_wallet_dashpay_backfill_fn: None, + on_persist_asset_lock_operation_id_fn: None, + on_load_asset_lock_operation_id_fn: None, } } } @@ -479,6 +524,8 @@ pub struct PersistenceExtensionCallbacks { pub persist_identity_balance_block_time: Option, pub load_identity_balance_block_time: Option, pub wallet_dashpay_backfill: Option, + pub persist_asset_lock_operation_id: Option, + pub load_asset_lock_operation_id: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1435,7 +1482,8 @@ pub struct FFIPersister { /// spent-marker rows while both `store()` calls still return `Ok`, /// bypassing the durable-watermark fault latch and recreating /// dashpay/platform#4069. Holding this lock for the whole round makes - /// each round atomic with respect to every other round. + /// each round atomic with respect to every other round and the complete + /// restore read (including additive sidecars). round_lock: Mutex, } @@ -1594,6 +1642,21 @@ impl FFIPersister { if self.callbacks.on_persist_asset_locks_fn.is_some() { capabilities = capabilities.union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); } + if wallet_restore + && self.callbacks.on_persist_asset_locks_fn.is_some() + && self + .tracked_masternodes_callbacks + .persist_asset_lock_operation_id + .is_some() + && self + .tracked_masternodes_callbacks + .load_asset_lock_operation_id + .is_some() + { + // The declaration additionally attests UUID preservation, consumed + // tombstones and fail-closed loads. Legacy hosts do not set this bit. + capabilities = capabilities.union(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS); + } if self .tracked_masternodes_callbacks .persist_tracked_masternodes @@ -2601,6 +2664,31 @@ impl PlatformWalletPersistence for FFIPersister { } } } + // Preserve the legacy row stride. UUIDs are an additive sidecar, + // written only after their base rows in the same atomic round. + for entry in al_cs.asset_locks.values() { + if let Some(operation_id) = entry.operation_id { + let Some(cb) = self + .tracked_masternodes_callbacks + .persist_asset_lock_operation_id + else { + outcome.record(1); + continue; + }; + let out_point = outpoint_to_bytes(&entry.out_point); + let rc = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + out_point.as_ptr(), + operation_id.as_ptr(), + ) + }; + if rc != 0 { + outcome.record(rc); + } + } + } } // Send invitation changeset — DIP-13 sent-invitation records, one @@ -3319,6 +3407,16 @@ impl PlatformWalletPersistence for FFIPersister { } fn load(&self) -> Result { + // Host loaders may read the same context that store() stages into. + // Keep base rows and every sidecar in one committed snapshot: a load + // between per-kind callbacks and end must never expose a Consumed row + // whose commit can still fail. Callbacks must not re-enter this API. + let round = self.round_lock.lock(); + if round.in_round { + return Err(PersistenceError::backend( + "FFIPersister: cannot restore from an unclosed changeset round", + )); + } // If Swift hasn't wired up `on_load_wallet_list_fn` there's // nothing to restore — treat as a fresh client. let Some(load_cb) = self.callbacks.on_load_wallet_list_fn else { @@ -3346,7 +3444,12 @@ impl PlatformWalletPersistence for FFIPersister { }; let mut out = ClientStartState::default(); - if entries_ptr.is_null() || count == 0 { + if entries_ptr.is_null() && count != 0 { + return Err(PersistenceError::backend( + "wallet restore: missing row buffer", + )); + } + if count == 0 { return Ok(out); } @@ -3356,7 +3459,23 @@ impl PlatformWalletPersistence for FFIPersister { // fires before we leave this function. let entries = unsafe { slice::from_raw_parts(entries_ptr, count) }; for entry in entries { + if out.wallets.contains_key(&entry.wallet_id) { + return Err(PersistenceError::backend( + "wallet restore: duplicate wallet ID", + )); + } let (mut wallet_state, platform_address_state) = build_wallet_start_state(entry)?; + if let Some(cb) = self + .tracked_masternodes_callbacks + .load_asset_lock_operation_id + { + load_asset_lock_operation_ids( + cb, + self.callbacks.context, + &entry.wallet_id, + &mut wallet_state.unused_asset_locks, + )?; + } if let Some(cb) = self.load_identity_balance_block_time_callback { for identities in wallet_state.identity_manager.wallet_identities.values_mut() { for managed in identities.values_mut() { @@ -6086,22 +6205,48 @@ fn build_wallet_start_state( Ok((wallet_state, platform_address_state)) } -/// Rebuild the `unused_asset_locks` map carried on -/// [`ClientWalletStartState`] from the `tracked_asset_locks` slice the -/// Swift load callback hands back. Mirrors the encoding used by -/// [`crate::asset_lock_persistence::build_asset_lock_entries`]: -/// -/// - `out_point` is 32-byte raw txid + 4-byte little-endian vout. -/// - `transaction_bytes` is consensus-encoded. -/// - `proof_bytes` is bincode-encoded (`dpp::bincode::config::standard()`). -/// `null` / 0 length means "no proof yet" (statuses Built / Broadcast). -/// -/// A malformed entry returns `Err(PersistenceError)` so the caller -/// surfaces the load failure rather than dropping a partially-rebuilt -/// state silently. Empty / null `tracked_asset_locks` yields an empty -/// map (same as the legacy hardcoded path). Terminal `Consumed` rows -/// are restored as tombstones so an exact-outpoint retry remains -/// distinguishable from a lock that was never tracked. +/// Rehydrate immutable UUID sidecars after decoding the frozen legacy rows. +/// A failed sidecar read or ambiguous association invalidates the whole load. +fn load_asset_lock_operation_ids( + callback: LoadAssetLockOperationIdFn, + context: *mut c_void, + wallet_id: &WalletId, + locks: &mut BTreeMap>, +) -> Result<(), PersistenceError> { + let mut operation_ids = BTreeSet::new(); + for lock in locks.values_mut().flat_map(|locks| locks.values_mut()) { + let out_point = outpoint_to_bytes(&lock.out_point); + let mut found = false; + let mut operation_id = [0; 16]; + let rc = unsafe { + callback( + context, + wallet_id.as_ptr(), + out_point.as_ptr(), + &mut found, + operation_id.as_mut_ptr(), + ) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + "Loading asset-lock operation ID failed", + )); + } + if found && !operation_ids.insert(operation_id) { + return Err(PersistenceError::backend( + "tracked asset locks: duplicate operation ID", + )); + } + lock.operation_id = found.then_some(operation_id); + } + Ok(()) +} + +/// Rebuild tracked locks from the host's frozen `AssetLockEntryFFI` rows. +/// Transactions use consensus encoding, proofs use DPP bincode, and outpoints +/// are raw txid bytes followed by a little-endian vout. Malformed or duplicate +/// rows fail the whole load. Consumed rows remain recovery tombstones. fn build_unused_asset_locks( entry: &WalletRestoreEntryFFI, ) -> Result< @@ -6110,9 +6255,12 @@ fn build_unused_asset_locks( > { use dashcore::hashes::Hash; - let specs: &[AssetLockEntryFFI] = if entry.tracked_asset_locks.is_null() - || entry.tracked_asset_locks_count == 0 - { + if entry.tracked_asset_locks.is_null() && entry.tracked_asset_locks_count != 0 { + return Err(PersistenceError::backend( + "tracked asset locks: missing row buffer", + )); + } + let specs: &[AssetLockEntryFFI] = if entry.tracked_asset_locks_count == 0 { &[] } else { // SAFETY: Swift guarantees the pointer + count form a valid @@ -6124,6 +6272,7 @@ fn build_unused_asset_locks( let mut map: BTreeMap> = BTreeMap::new(); + let mut out_points = BTreeSet::new(); for spec in specs { // Decode the outpoint: 32-byte raw txid + 4-byte LE vout. let txid = dashcore::Txid::from_slice(&spec.out_point[..32]).map_err(|e| { @@ -6137,6 +6286,11 @@ fn build_unused_asset_locks( .expect("4-byte slice from 36-byte array"); let vout = u32::from_le_bytes(vout_bytes); let out_point = dashcore::OutPoint { txid, vout }; + if !out_points.insert(out_point) { + return Err(PersistenceError::backend( + "tracked asset locks: duplicate outpoint", + )); + } // Decode the consensus-encoded transaction. if spec.transaction_bytes.is_null() || spec.transaction_bytes_len == 0 { @@ -6191,6 +6345,7 @@ fn build_unused_asset_locks( funding_type, identity_index: spec.identity_index, amount: spec.amount_duffs, + operation_id: None, status, proof, }; @@ -6234,6 +6389,7 @@ fn status_from_u8(b: u8) -> Result AssetLockStatus::ChainLocked, 4 => AssetLockStatus::Consumed, 5 => AssetLockStatus::RecoveredFromChain, + 6 => AssetLockStatus::DrainCompleted, other => { return Err(PersistenceError::backend(format!( "tracked asset lock: unknown status discriminant {}", @@ -9601,6 +9757,237 @@ mod tests { drop(persister); } + #[test] + fn should_roundtrip_drain_operation_ids_including_consumed_tombstones() { + use dashcore::hashes::Hash; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + + unsafe extern "C" fn load_id( + context: *mut c_void, + _: *const u8, + _: *const u8, + found: *mut bool, + output: *mut u8, + ) -> i32 { + let id = *(context as *const Option<[u8; 16]>); + *found = id.is_some(); + if let Some(id) = id { + std::ptr::copy_nonoverlapping(id.as_ptr(), output, 16); + } + 0 + } + for (operation_id, status) in [ + (None, platform_wallet::AssetLockStatus::Consumed), + (Some([0xA5; 16]), platform_wallet::AssetLockStatus::Consumed), + ( + Some([0xA5; 16]), + platform_wallet::AssetLockStatus::DrainCompleted, + ), + ] { + let entry = platform_wallet::changeset::AssetLockEntry { + out_point: dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([3; 32]), + vout: 0, + }, + transaction: dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 7, + funding_type: AssetLockFundingType::AssetLockShieldedAddressTopUp, + identity_index: 0, + amount_duffs: 1000, + status: status.clone(), + proof: None, + operation_id, + }; + let (entries, _storage) = build_asset_lock_entries(&[&entry]); + let wallet = WalletRestoreEntryFFI { + tracked_asset_locks: entries.as_ptr(), + tracked_asset_locks_count: entries.len(), + ..Default::default() + }; + let mut restored = build_unused_asset_locks(&wallet).expect("valid persisted lock"); + assert_eq!( + restored[&7][&entry.out_point].operation_id, None, + "legacy ABI has no UUID field" + ); + load_asset_lock_operation_ids( + load_id, + (&operation_id as *const Option<[u8; 16]>).cast_mut().cast(), + &[42; 32], + &mut restored, + ) + .expect("valid UUID sidecar"); + assert_eq!(restored[&7][&entry.out_point].operation_id, operation_id); + assert_eq!(restored[&7][&entry.out_point].status, status); + let duplicated = [entries[0], entries[0]]; + let bad_wallet = WalletRestoreEntryFFI { + tracked_asset_locks: duplicated.as_ptr(), + tracked_asset_locks_count: 2, + ..Default::default() + }; + assert!( + build_unused_asset_locks(&bad_wallet).is_err(), + "duplicate outpoints cannot overwrite correlation" + ); + if operation_id.is_some() { + let changeset = PlatformWalletChangeSet { + asset_locks: Some(platform_wallet::changeset::AssetLockChangeSet { + asset_locks: [(entry.out_point, entry.clone())].into(), + ..Default::default() + }), + ..Default::default() + }; + let callbacks = || PersistenceCallbacks { + on_changeset_begin_fn: Some(noop_begin), + on_changeset_end_fn: Some(noop_end), + on_persist_asset_locks_fn: Some(noop_asset_locks), + ..Default::default() + }; + let legacy = FFIPersister::new(callbacks()); + assert!( + legacy.store([42; 32], changeset.clone()).is_err(), + "a legacy host cannot silently discard a drain UUID" + ); + unsafe extern "C" fn persist_id( + context: *mut c_void, + wallet: *const u8, + _: *const u8, + id: *const u8, + ) -> i32 { + assert_eq!(slice::from_raw_parts(wallet, 32), &[42; 32]); + assert_eq!(slice::from_raw_parts(id, 16), &[0xA5; 16]); + *(context as *mut bool) = true; + 0 + } + let mut called = false; + let writer = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: std::ptr::addr_of_mut!(called).cast(), + ..callbacks() + }, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + persist_asset_lock_operation_id: Some(persist_id), + ..Default::default() + }, + ); + writer + .store([42; 32], changeset) + .expect("UUID sidecar persisted with the base row"); + assert!(called); + } + } + } + + #[test] + fn should_require_explicit_operation_id_persistence_attestation() { + unsafe extern "C" fn persist( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *const u8, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *mut bool, + _: *mut u8, + ) -> i32 { + 0 + } + let callbacks = || PersistenceCallbacks { + 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_OPERATION_IDS; + let extensions = PersistenceExtensionCallbacks { + persist_asset_lock_operation_id: Some(persist), + load_asset_lock_operation_id: Some(load), + ..Default::default() + }; + let persister = |callbacks, declaration, extensions| { + FFIPersister::new_with_persistence_capabilities_and_extensions( + callbacks, + declaration, + extensions, + ) + }; + assert!( + !persister(callbacks(), PersistenceCapabilities::NONE, extensions) + .persistence_capabilities() + .contains(required) + ); + assert!(persister(callbacks(), required, extensions) + .persistence_capabilities() + .contains(required)); + assert!(!declared_persister(callbacks(), required) + .persistence_capabilities() + .contains(required)); + for incomplete in [ + PersistenceExtensionCallbacks { + persist_asset_lock_operation_id: None, + ..extensions + }, + PersistenceExtensionCallbacks { + load_asset_lock_operation_id: None, + ..extensions + }, + ] { + assert!(!persister(callbacks(), required, incomplete) + .persistence_capabilities() + .contains(required)); + } + let mut missing_load = callbacks(); + missing_load.on_load_wallet_list_fn = None; + assert!(!persister(missing_load, required, extensions) + .persistence_capabilities() + .contains(required)); + } + + #[test] + fn should_restore_proven_drain_completion_discriminant() { + assert_eq!( + status_from_u8(6).unwrap(), + platform_wallet::AssetLockStatus::DrainCompleted + ); + assert_eq!( + status_from_u8(4).unwrap(), + platform_wallet::AssetLockStatus::Consumed + ); + assert!(status_from_u8(7).is_err()); + } + + #[test] + fn should_reject_a_missing_wallet_restore_buffer() { + unsafe extern "C" fn missing( + _: *mut c_void, + entries: *mut *const WalletRestoreEntryFFI, + count: *mut usize, + ) -> i32 { + *entries = std::ptr::null(); + *count = 1; + 0 + } + let persister = FFIPersister::new(PersistenceCallbacks { + on_load_wallet_list_fn: Some(missing), + ..Default::default() + }); + assert!( + persister.load().is_err(), + "an unreadable snapshot cannot prove an operation absent" + ); + } + #[test] fn asset_lock_reconciliation_requires_every_callback_leg() { fn complete_callbacks() -> PersistenceCallbacks { @@ -9752,8 +10139,8 @@ mod tests { // sweeps/chainlock slots merged in, so the trio keeps them), the // trio's end is where the sweeps slot begins, the sweeps slot's // end is where the chainlock-height slot begins, and the - // chainlock-height slot is currently terminal. Reordering any of - // them would silently misread every extension already in the field. + // later slots extend that prefix. Reordering any of them would + // silently misread every extension already in the field. assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, @@ -9849,6 +10236,26 @@ mod tests { PersistenceCallbacksExtension, on_persist_wallet_dashpay_backfill_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_asset_lock_operation_id_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_asset_lock_operation_id_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_asset_lock_operation_id_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_asset_lock_operation_id_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( @@ -9907,6 +10314,10 @@ mod tests { PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS, PersistenceCapabilities::DASHPAY_PAYMENTS.bits() ); + assert_eq!( + PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS, + PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS.bits() + ); assert_eq!( PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ACCOUNT_ADDRESS_POOLS, PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_FUNDING_INDICES @@ -11693,6 +12104,175 @@ mod tests { // ── Round serialization + defensive state machine (dashpay/platform#4069) ── + #[test] + fn should_restore_only_committed_asset_lock_status_after_a_failed_round() { + use dashcore::hashes::Hash; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use std::sync::{mpsc, Condvar, Mutex as StdMutex}; + use std::time::Duration; + + struct Host { + transaction: Vec, + status: AtomicUsize, + // The end callback pauses after staging Consumed and before a + // failed commit rolls it back, like Swift's shared ModelContext. + end: StdMutex<(bool, bool)>, + changed: Condvar, + } + unsafe extern "C" fn stage( + context: *mut c_void, + _: *const u8, + entries: *const AssetLockEntryFFI, + count: usize, + _: *const [u8; 36], + _: usize, + ) -> i32 { + assert_eq!(count, 1); + (&*context.cast::()) + .status + .store((*entries).status as usize, Ordering::SeqCst); + 0 + } + unsafe extern "C" fn reject_commit( + context: *mut c_void, + _: *const u8, + success: bool, + ) -> i32 { + assert!(success); + let host = &*context.cast::(); + let mut end = host.end.lock().unwrap(); + end.0 = true; + host.changed.notify_all(); + while !end.1 { + end = host.changed.wait(end).unwrap(); + } + host.status.store(1, Ordering::SeqCst); // rollback to Broadcast + -1 + } + unsafe extern "C" fn load( + context: *mut c_void, + entries: *mut *const WalletRestoreEntryFFI, + count: *mut usize, + ) -> i32 { + let host = &*context.cast::(); + let mut out_point = [3; 36]; + out_point[32..].fill(0); + let lock = Box::new(AssetLockEntryFFI { + out_point, + transaction_bytes: host.transaction.as_ptr(), + transaction_bytes_len: host.transaction.len(), + account_index: 0, + funding_type: 5, + identity_index: 0, + amount_duffs: 1000, + status: host.status.load(Ordering::SeqCst) as u8, + proof_bytes: std::ptr::null(), + proof_bytes_len: 0, + }); + *entries = Box::into_raw(Box::new(WalletRestoreEntryFFI { + wallet_id: [42; 32], + tracked_asset_locks: Box::into_raw(lock), + tracked_asset_locks_count: 1, + ..Default::default() + })); + *count = 1; + 0 + } + unsafe extern "C" fn free(_: *mut c_void, entries: *const WalletRestoreEntryFFI, _: usize) { + let wallet = Box::from_raw(entries.cast_mut()); + drop(Box::from_raw(wallet.tracked_asset_locks.cast_mut())); + } + let entry = platform_wallet::changeset::AssetLockEntry { + out_point: dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([3; 32]), + vout: 0, + }, + transaction: dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 0, + funding_type: AssetLockFundingType::AssetLockShieldedAddressTopUp, + identity_index: 0, + amount_duffs: 1000, + status: platform_wallet::AssetLockStatus::Consumed, + proof: None, + operation_id: None, + }; + let out_point = entry.out_point; + let host = Arc::new(Host { + transaction: dashcore::consensus::serialize(&entry.transaction), + status: AtomicUsize::new(1), + end: StdMutex::new((false, false)), + changed: Condvar::new(), + }); + let persister = Arc::new(FFIPersister::new(PersistenceCallbacks { + context: Arc::as_ptr(&host).cast_mut().cast(), + on_persist_asset_locks_fn: Some(stage), + on_changeset_end_fn: Some(reject_commit), + on_load_wallet_list_fn: Some(load), + on_load_wallet_list_free_fn: Some(free), + ..Default::default() + })); + let writer = { + let persister = Arc::clone(&persister); + std::thread::spawn(move || { + persister.store( + [42; 32], + PlatformWalletChangeSet { + asset_locks: Some(platform_wallet::changeset::AssetLockChangeSet { + asset_locks: [(entry.out_point, entry)].into(), + ..Default::default() + }), + ..Default::default() + }, + ) + }) + }; + { + let mut end = host.end.lock().unwrap(); + while !end.0 { + end = host.changed.wait(end).unwrap(); + } + } + let (started_tx, started_rx) = mpsc::channel(); + let (loaded_tx, loaded_rx) = mpsc::channel(); + let reader = { + let persister = Arc::clone(&persister); + std::thread::spawn(move || { + started_tx.send(()).unwrap(); + let loaded = persister.load(); + loaded_tx.send(()).unwrap(); + loaded + }) + }; + started_rx.recv().unwrap(); + let returned_before_commit = loaded_rx.recv_timeout(Duration::from_millis(100)).is_ok(); + // Always release the writer before asserting, including on regressions. + host.end.lock().unwrap().1 = true; + host.changed.notify_all(); + assert!(writer.join().unwrap().is_err()); + let loaded = reader.join().unwrap().unwrap(); + assert!( + !returned_before_commit, + "restore observed an uncommitted round" + ); + assert_eq!( + loaded.wallets[&[42; 32]].unused_asset_locks[&0][&out_point].status, + platform_wallet::AssetLockStatus::Broadcast + ); + } + + #[test] + fn should_refuse_restore_after_an_unclosed_persistence_round() { + let persister = FFIPersister::new(PersistenceCallbacks::default()); + persister.round_lock.lock().begin_round().unwrap(); + assert!(persister.load().is_err()); + } + use std::os::raw::c_void as TestCVoid; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 1b7628baedb..01f385d75b5 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -713,7 +713,7 @@ fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { /// (`dev-ios` / `release-ios`) build with `panic = "abort"` as part of their staticlib size /// tuning (see the workspace `Cargo.toml` profile comments), so on iOS a panic still aborts the /// process before this guard can see it. -fn catch_panic_to_code( +pub(crate) fn catch_panic_to_code( operation: &str, code: PlatformWalletFFIResultCode, guidance: &str, @@ -773,7 +773,7 @@ const FUNDING_PANIC_GUIDANCE: &str = "The asset lock may or may not have been br /// fails at input selection instead of double-spending), and the reservation TTL or a sync /// observing the transaction reconciles the outcome; a tracked lock is resumable through /// `platform_wallet_manager_shielded_resume_fund_from_asset_lock`. -fn catch_funding_panic( +pub(crate) fn catch_funding_panic( operation: &str, body: impl FnOnce() -> PlatformWalletFFIResult, ) -> PlatformWalletFFIResult { @@ -2241,7 +2241,7 @@ fn resolve_wallet( /// coordinator `Arc` for the given manager handle. Shielded /// spend operations need the coordinator's shared store, so this /// is the right resolver for transfer/unshield/withdraw FFIs. -fn resolve_wallet_and_coordinator( +pub(crate) fn resolve_wallet_and_coordinator( handle: Handle, wallet_id: &[u8; 32], ) -> Result< diff --git a/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs b/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs new file mode 100644 index 00000000000..52463b4c307 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs @@ -0,0 +1,383 @@ +//! Pooled Core-to-shielded Max with a stable, per-operation recovery identity. + +use dashcore::hashes::Hash; +use dpp::address_funds::OrchardAddress; +use platform_wallet::wallet::asset_lock::{ + AssetLockFunding, AssetLockOperationContext, AssetLockOperationSnapshot, + AssetLockOperationState, +}; +use platform_wallet::wallet::shielded::wallet_drain::{ + WalletDrainPreview, WalletDrainPreviewStatus, +}; +use platform_wallet::wallet::shielded::CachedOrchardProver; +use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle}; + +use crate::check_ptr; +use crate::core_wallet_types::OutPointFFI; +use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; +use crate::handle::Handle; +use crate::runtime::block_on_worker; +use crate::shielded_send::{ + catch_funding_panic, catch_panic_to_code, resolve_wallet_and_coordinator, +}; + +use std::sync::Arc; + +/// Read-only estimate. Candidate figures include in-flight reservations; the +/// execution builder is authoritative. Optional values have explicit flags. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default)] +pub struct WalletDrainPreviewFFI { + /// 0 = ready, 1 = insufficient funds, 2 = input limit may be exceeded. + pub status: u8, + pub candidate_input_count: u64, + pub candidate_balance_duffs: u64, + pub pool_fee_credits: u64, + pub has_estimated_l1_fee_duffs: bool, + pub estimated_l1_fee_duffs: u64, + pub has_estimated_recipient_credits: bool, + pub estimated_recipient_credits: u64, +} + +impl From for WalletDrainPreviewFFI { + fn from(preview: WalletDrainPreview) -> Self { + Self { + status: match preview.status { + WalletDrainPreviewStatus::Ready => 0, + WalletDrainPreviewStatus::InsufficientFunds => 1, + WalletDrainPreviewStatus::InputLimitMayBeExceeded => 2, + }, + candidate_input_count: preview.candidate_input_count as u64, + candidate_balance_duffs: preview.candidate_balance_duffs, + pool_fee_credits: preview.pool_fee_credits, + has_estimated_l1_fee_duffs: preview.estimated_l1_fee_duffs.is_some(), + estimated_l1_fee_duffs: preview.estimated_l1_fee_duffs.unwrap_or(0), + has_estimated_recipient_credits: preview.estimated_recipient_credits.is_some(), + estimated_recipient_credits: preview.estimated_recipient_credits.unwrap_or(0), + } + } +} + +/// Outcome of ONE UUID-scoped drain. Written even when execution returns an +/// error or catches a panic. An absent outpoint never licenses a fresh drain. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct WalletDrainOutcomeFFI { + /// 0 = not sent, 1 = pending/unknown, 2 = completed. + pub state: u8, + pub has_out_point: bool, + pub out_point: OutPointFFI, +} + +impl Default for WalletDrainOutcomeFFI { + fn default() -> Self { + Self { + state: 1, + has_out_point: false, + out_point: OutPointFFI { + txid: [0; 32], + vout: 0, + }, + } + } +} + +impl From for WalletDrainOutcomeFFI { + fn from(snapshot: AssetLockOperationSnapshot) -> Self { + Self { + state: match snapshot.state { + AssetLockOperationState::NotSent => 0, + AssetLockOperationState::Pending => 1, + AssetLockOperationState::Completed => 2, + }, + has_out_point: snapshot.out_point.is_some(), + out_point: snapshot.out_point.map_or( + OutPointFFI { + txid: [0; 32], + vout: 0, + }, + |out_point| OutPointFFI { + txid: out_point.txid.to_byte_array(), + vout: out_point.vout, + }, + ), + } + } +} + +fn catch_wallet_drain_read_panic( + operation: &str, + body: impl FnOnce() -> PlatformWalletFFIResult, +) -> PlatformWalletFFIResult { + catch_panic_to_code( + operation, + PlatformWalletFFIResultCode::ErrorWalletOperation, + "This read did not submit a transaction; retain any earlier pending operation.", + body, + ) +} + +/// Read the context AFTER the guarded worker finishes, including when its +/// panic became a typed error. The context belongs only to this invocation. +fn wallet_drain_result( + snapshot: impl FnOnce() -> AssetLockOperationSnapshot, + body: impl FnOnce() -> PlatformWalletFFIResult, +) -> (PlatformWalletFFIResult, WalletDrainOutcomeFFI) { + let result = catch_funding_panic("shielded wallet drain", body); + (result, snapshot().into()) +} + +/// Estimate a pooled drain without deriving addresses, signing or reserving inputs. +/// +/// # Safety +/// `wallet_id_bytes` points to 32 readable bytes; `out_preview` is writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_wallet_drain_preview( + handle: Handle, + wallet_id_bytes: *const u8, + account_index: u32, + out_preview: *mut WalletDrainPreviewFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_preview); + *out_preview = WalletDrainPreviewFFI::default(); + check_ptr!(wallet_id_bytes); + let mut wallet_id = [0; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + catch_wallet_drain_read_panic("shielded wallet drain preview", || { + let (wallet, _) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(value) => value, + Err(error) => return error, + }; + match block_on_worker(async move { + wallet.shielded_wallet_drain_preview(account_index, 0).await + }) { + Ok(preview) => { + *out_preview = preview.into(); + PlatformWalletFFIResult::ok() + } + Err(error) => error.into(), + } + }) +} + +/// Drain the permitted transparent accounts into one shielded recipient. +/// +/// The caller persists its UUID before calling. Reusing a UUID must never +/// create another lock. The result's error and `out_outcome` are complementary: +/// a typed error does not erase the outpoint or authorize another fresh drain. +/// +/// # Safety +/// Wallet ID, operation ID and recipient point to 32, 16 and 43 readable bytes, +/// respectively. `out_outcome` is writable. The caller keeps `core_signer_handle` +/// alive until this synchronous call returns, including worker/proving time. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_wallet_drain( + handle: Handle, + wallet_id_bytes: *const u8, + operation_id_bytes: *const u8, + account_index: u32, + recipient_raw_43: *const u8, + core_signer_handle: *mut MnemonicResolverHandle, + out_outcome: *mut WalletDrainOutcomeFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_outcome); + *out_outcome = WalletDrainOutcomeFFI::default(); + check_ptr!(wallet_id_bytes); + check_ptr!(operation_id_bytes); + check_ptr!(recipient_raw_43); + check_ptr!(core_signer_handle); + + let mut wallet_id = [0; 32]; + let mut operation_id = [0; 16]; + let mut recipient_bytes = [0; 43]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + std::ptr::copy_nonoverlapping(operation_id_bytes, operation_id.as_mut_ptr(), 16); + std::ptr::copy_nonoverlapping(recipient_raw_43, recipient_bytes.as_mut_ptr(), 43); + + let operation = Arc::new(AssetLockOperationContext::new(operation_id)); + let worker_operation = Arc::clone(&operation); + let mut registration = None; + let (result, outcome) = wallet_drain_result( + || operation.snapshot(), + || { + let recipient = match OrchardAddress::from_raw_bytes(&recipient_bytes) { + Ok(value) => value, + Err(error) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("invalid Orchard recipient address: {error}"), + ) + } + }; + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(value) => value, + Err(error) => return error, + }; + registration = + Some(wallet.register_wallet_drain_operation(Arc::clone(&worker_operation))); + let network = wallet.network(); + let core_signer_addr = core_signer_handle as usize; + match block_on_worker(async move { + // SAFETY: the caller pins the resolver for the complete FFI call. + let signer = unsafe { + MnemonicResolverCoreSigner::new( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + wallet + .shielded_fund_from_asset_lock( + &coordinator, + AssetLockFunding::DrainWalletBalance { + account_index, + minimum_lock_duffs: None, + operation: worker_operation, + }, + vec![(recipient, None)], + &signer, + &CachedOrchardProver::new(), + None, + 0, + None, + None, + ) + .await + }) { + Ok(()) => PlatformWalletFFIResult::ok(), + Err(error) => error.into(), + } + }, + ); + *out_outcome = outcome; + drop(registration); + result +} + +/// Read-only: the durable row, the live row and the active context. Never +/// touches the network. +/// +/// # Safety +/// IDs point to 32 and 16 readable bytes. `out_outcome` is writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_wallet_drain_status( + handle: Handle, + wallet_id_bytes: *const u8, + operation_id_bytes: *const u8, + out_outcome: *mut WalletDrainOutcomeFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_outcome); + *out_outcome = WalletDrainOutcomeFFI::default(); + check_ptr!(wallet_id_bytes); + check_ptr!(operation_id_bytes); + let mut wallet_id = [0; 32]; + let mut operation_id = [0; 16]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + std::ptr::copy_nonoverlapping(operation_id_bytes, operation_id.as_mut_ptr(), 16); + catch_wallet_drain_read_panic("shielded wallet drain status", || { + let (wallet, _) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(value) => value, + Err(error) => return error, + }; + match block_on_worker(async move { + wallet + .shielded_wallet_drain_operation_status(operation_id) + .await + }) { + Ok(snapshot) => { + *out_outcome = snapshot.into(); + PlatformWalletFFIResult::ok() + } + Err(error) => error.into(), + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_keep_the_exact_operation_outpoint_after_a_worker_panic() { + let out_point = dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([7; 32]), + vout: 3, + }; + let (mut result, outcome) = wallet_drain_result( + || AssetLockOperationSnapshot { + state: AssetLockOperationState::Pending, + out_point: Some(out_point), + }, + || panic!("worker failed after the lock was tracked"), + ); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed + ); + assert_eq!(outcome.state, 1); + assert!(outcome.has_out_point); + assert_eq!(outcome.out_point.txid, [7; 32]); + assert_eq!(outcome.out_point.vout, 3); + unsafe { crate::error::platform_wallet_ffi_result_free(&mut result) }; + } + + #[test] + fn should_clear_outcome_to_pending_when_an_abi_argument_is_missing() { + let mut outcome = WalletDrainOutcomeFFI { + state: 2, + has_out_point: true, + out_point: OutPointFFI { + txid: [7; 32], + vout: 3, + }, + }; + let mut result = unsafe { + platform_wallet_manager_shielded_fund_from_wallet_drain( + 0, + std::ptr::null(), + std::ptr::null(), + 0, + std::ptr::null(), + std::ptr::null_mut(), + &mut outcome, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert_eq!(outcome.state, 1); + assert!(!outcome.has_out_point); + assert_eq!(outcome.out_point.txid, [0; 32]); + unsafe { crate::error::platform_wallet_ffi_result_free(&mut result) }; + } + + #[test] + fn should_preserve_absent_estimates_for_an_input_limit_warning() { + let ffi = WalletDrainPreviewFFI::from(WalletDrainPreview { + status: WalletDrainPreviewStatus::InputLimitMayBeExceeded, + candidate_input_count: 501, + candidate_balance_duffs: 1_000_000, + pool_fee_credits: 100_000, + estimated_l1_fee_duffs: None, + estimated_recipient_credits: None, + }); + assert_eq!(ffi.status, 2); + assert_eq!(ffi.candidate_input_count, 501); + assert!(!ffi.has_estimated_l1_fee_duffs); + assert!(!ffi.has_estimated_recipient_credits); + } + + #[test] + fn should_preserve_typed_input_cap_refusal() { + let mut result: PlatformWalletFFIResult = + platform_wallet::PlatformWalletError::AssetLockTooManyInputs { + count: 501, + max: 500, + } + .into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorAssetLockTooManyInputs + ); + assert_eq!(result.code as i32, 60); + unsafe { crate::error::platform_wallet_ffi_result_free(&mut result) }; + } +} diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 769378695c1..06132e56eaa 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -847,3 +847,4 @@ table-rebuild migration, as V004 does. | V016 | `V016__identity_keys_null_scope_requires_existing_identity.rs` | Recreates the `identity_keys` null-scope trigger pair (see Triggers above) to also reject a NULL-scoped key naming an identity that does not exist at all, closing the gap where V008's guard caught only the wallet-owned case. | | V017 | `V017__identity_scan_state.rs` | Adds `identity_scan_states` (one row per wallet: the last gap-limit identity-scan verdict — `complete`, `probed_from`/`probed_through`, `unlocated_gap`) and `identity_scan_failed_indices` (indices probed without an answer, cascading from the verdict row via `wallet_id`). Purely additive; an upgraded database reads back "no verdict recorded" for every wallet until the next scan (dashpay/platform#4365). | | V018 | `V018__identity_hard_delete.rs` | Retires identity tombstoning. Adds `cascade_children_on_identity_delete` (brooms `identity_keys` / `contacts` / `ignored_senders` / `pending_contact_crypto` by the deleted identity id, covering the rows no live FK reaches) plus its access-path indexes `idx_contacts_owner`, `idx_ignored_senders_owner`, and `idx_pending_contact_crypto_owner`; purges every already-tombstoned identity and its dependents; drops `identities.tombstoned`. | +| V019 | `V019__asset_lock_operation_id.rs` | Adds the nullable `asset_locks.operation_id BLOB` column (CHECK: NULL or exactly 16 bytes) that correlates a wallet-drain operation with its funding outpoint; the lifecycle blob is untouched. Adds the unique partial index `asset_locks_wallet_operation_id` on `(wallet_id, operation_id) WHERE operation_id IS NOT NULL` and the `asset_locks_operation_id_immutable` BEFORE UPDATE trigger, which aborts any change to a non-NULL `operation_id`. Purely additive; existing rows read back NULL. | diff --git a/packages/rs-platform-wallet-storage/migrations/V019__asset_lock_operation_id.rs b/packages/rs-platform-wallet-storage/migrations/V019__asset_lock_operation_id.rs new file mode 100644 index 00000000000..60c2cc38cc9 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V019__asset_lock_operation_id.rs @@ -0,0 +1,12 @@ +//! Stable correlation for idempotent wallet-drain recovery. Legacy blobs stay +//! byte-identical; old rows receive NULL and retain their previous behavior. +pub fn migration() -> String { + "ALTER TABLE asset_locks ADD COLUMN operation_id BLOB \ + CHECK (operation_id IS NULL OR (typeof(operation_id) = 'blob' AND length(operation_id) = 16)); \ + CREATE UNIQUE INDEX asset_locks_wallet_operation_id \ + ON asset_locks(wallet_id, operation_id) WHERE operation_id IS NOT NULL; \ + CREATE TRIGGER asset_locks_operation_id_immutable \ + BEFORE UPDATE OF operation_id ON asset_locks \ + WHEN OLD.operation_id IS NOT NULL AND NEW.operation_id IS NOT OLD.operation_id \ + BEGIN SELECT RAISE(ABORT, 'asset lock operation ID cannot change'); END;".to_owned() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 82fda2837ca..15634f6293f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1312,6 +1312,7 @@ impl PlatformWalletPersistence for SqlitePersister { // token balances and the DashPay overlay have no load readers, so a // full restore remains lossy. Shielded viewing keys are native when // compiled in; notes, nullifiers, and sync state use ShieldedStore. + // WALLET_DRAIN folds in WALLET_RESTORE, so it stays unattested too. let capabilities = PersistenceCapabilities::ATOMIC_CHANGESETS .union(PersistenceCapabilities::INVITATIONS) .union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) @@ -1319,6 +1320,7 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::PENDING_CONTACT_CRYPTO) .union(PersistenceCapabilities::DPNS_NAME_STATES) .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) + .union(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS) .union(PersistenceCapabilities::TRACKED_MASTERNODES) .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL) .union(PersistenceCapabilities::DASHPAY_PAYMENTS); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs index f437755606a..6256705bfbb 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -10,7 +10,7 @@ //! the surrounding fields on the serde encoder — mirroring `IdentityKeyWire` //! in `identity_keys.rs`. -use rusqlite::{params, Transaction}; +use rusqlite::{params, OptionalExtension, Transaction}; use serde::{Deserialize, Serialize}; use dpp::prelude::AssetLockProof; @@ -37,10 +37,11 @@ use crate::sqlite::schema::blob::impl_persistable_blob; /// cannot service the `deserialize_any` an internally-tagged serde enum needs, /// which made a `proof: Some(..)` row write-once/read-never (issue #4133). /// -/// Fields 1–7 preserve the exact order and serde encodings of `AssetLockEntry` +/// Fields 1–7 preserve the historical `AssetLockEntry` order and serde encodings /// (including the shared `asset_lock_funding_type` adapter) so a pre-fix /// `proof: None` row decodes byte-identically under this type — no migration -/// needed for those rows. +/// needed for those rows. The later operation UUID lives in a separate nullable +/// column so adding it never changes this published bincode layout. #[derive(Serialize, Deserialize)] struct AssetLockEntryWire { out_point: OutPoint, @@ -58,6 +59,12 @@ struct AssetLockEntryWire { // PUBLIC material only: asset-lock lifecycle reaching `lifecycle_blob`. impl_persistable_blob!(AssetLockEntryWire); +/// Nullable recovery columns read independently of the frozen lifecycle blob. +struct AssetLockRecoveryRow { + operation_id: Option>, + lifecycle_blob: Vec, +} + /// Encode an [`AssetLockEntry`] to its on-disk `lifecycle_blob` form via the /// wire type. Test-only seam so integration tests can forge a lifecycle blob /// without naming the crate-internal [`AssetLockEntryWire`]. @@ -113,6 +120,7 @@ impl AssetLockEntryWire { amount_duffs: self.amount_duffs, status: self.status, proof, + operation_id: None, }) } } @@ -139,19 +147,52 @@ pub fn apply( // batches fold before reaching the store. let mut stmt = tx.prepare_cached( "INSERT INTO asset_locks \ - (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob, operation_id) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \ ON CONFLICT(wallet_id, outpoint) DO UPDATE SET \ status = excluded.status, \ account_index = excluded.account_index, \ identity_index = excluded.identity_index, \ amount_duffs = excluded.amount_duffs, \ - lifecycle_blob = excluded.lifecycle_blob \ + lifecycle_blob = excluded.lifecycle_blob, \ + operation_id = COALESCE(excluded.operation_id, asset_locks.operation_id) \ WHERE asset_locks.status != 'consumed' OR excluded.status = 'consumed'", )?; for (op, entry) in &cs.asset_locks { let op_bytes = blob::encode_outpoint(op)?; let lifecycle_blob = blob::encode(&AssetLockEntryWire::from_entry(entry)?)?; + let existing: Option = tx.query_row( + "SELECT operation_id, lifecycle_blob FROM asset_locks WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), &op_bytes[..]], + |row| Ok(AssetLockRecoveryRow { + operation_id: row.get(0)?, + lifecycle_blob: row.get(1)?, + }), + ).optional()?; + // The operation UUID is immutable once stored. Reject the whole + // snapshot before any lifecycle or sidecar write. + if let (Some(incoming), Some(stored)) = ( + entry.operation_id.as_ref(), + existing + .as_ref() + .and_then(|row| row.operation_id.as_deref()), + ) { + if incoming.as_slice() != stored { + return Err(WalletStorageError::blob_decode( + "asset lock operation ID cannot change", + )); + } + } + // Both terminal variants project to SQL `consumed`, but the blob + // retains stronger proof-gated completion and must never regress. + if let Some(stored) = &existing { + let old: AssetLockEntryWire = blob::decode(&stored.lifecycle_blob)?; + if old.status == AssetLockStatus::DrainCompleted + && entry.status != AssetLockStatus::DrainCompleted + { + continue; + } + } stmt.execute(params![ wallet_id.as_slice(), &op_bytes[..], @@ -163,6 +204,7 @@ pub fn apply( entry.amount_duffs, )?, lifecycle_blob, + entry.operation_id.as_ref().map(|id| id.as_slice()), ])?; } } @@ -236,7 +278,7 @@ fn status_str(s: &AssetLockStatus) -> &'static str { AssetLockStatus::Broadcast => "broadcast", AssetLockStatus::InstantSendLocked => "is_locked", AssetLockStatus::ChainLocked => "chain_locked", - AssetLockStatus::Consumed => "consumed", + AssetLockStatus::Consumed | AssetLockStatus::DrainCompleted => "consumed", AssetLockStatus::RecoveredFromChain => "recovered_from_chain", } } @@ -256,11 +298,19 @@ fn decode_row( account_index: i64, blob_bytes: &[u8], typed_status: &str, + operation_id: Option>, ctx: &LoadCtx, ) -> Result<(u32, OutPoint, TrackedAssetLock), WalletStorageError> { let outpoint = blob::decode_outpoint(op_bytes)?; let wire: AssetLockEntryWire = blob::decode(blob_bytes)?; let mut entry = wire.into_entry()?; + entry.operation_id = operation_id + .map(|bytes| { + bytes.try_into().map_err(|_| { + WalletStorageError::blob_decode("asset_locks.operation_id must contain 16 bytes") + }) + }) + .transpose()?; let account_index = crate::sqlite::util::safe_cast::i64_to_u32("asset_locks.account_index", account_index)?; // Typed-column vs blob cross-check: corruption that passes PRAGMA @@ -277,7 +327,7 @@ fn decode_row( } let blob_status = status_str(&entry.status); if blob_status != typed_status { - let consumed = typed_status == "consumed" || entry.status == AssetLockStatus::Consumed; + let consumed = typed_status == "consumed" || entry.status.is_consumed(); ctx.tolerate( LoadSite::AssetLockStatusDrift, WalletStorageError::AssetLockStatusMismatch { @@ -289,7 +339,7 @@ fn decode_row( // `load_unconsumed` pre-filters typed `consumed` rows, so the blob can // only withdraw a lock there. Sticky consumption also keeps the // unfiltered test/inspection reader conservative. - if consumed { + if consumed && entry.status != AssetLockStatus::DrainCompleted { entry.status = AssetLockStatus::Consumed; } } @@ -302,6 +352,7 @@ fn decode_row( amount: entry.amount_duffs, status: entry.status, proof: entry.proof, + operation_id: entry.operation_id, }; Ok((account_index, outpoint, tracked)) } @@ -317,7 +368,7 @@ pub fn load_state( ctx: &LoadCtx, ) -> Result { let mut stmt = conn.prepare( - "SELECT length(outpoint), outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status \ + "SELECT length(outpoint), outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status, operation_id \ FROM asset_locks WHERE wallet_id = ?1", )?; let mut rows = stmt.query(params![wallet_id.as_slice()])?; @@ -329,28 +380,35 @@ pub fn load_state( blob::check_size(row.get::<_, i64>(3)?)?; let blob_bytes: Vec = row.get(4)?; let status: String = row.get(5)?; - let (acct, outpoint, tracked) = - decode_row(&op_bytes, account_index, &blob_bytes, &status, ctx)?; + let operation_id: Option> = row.get(6)?; + let (acct, outpoint, tracked) = decode_row( + &op_bytes, + account_index, + &blob_bytes, + &status, + operation_id, + ctx, + )?; out.entry(acct).or_default().insert(outpoint, tracked); } Ok(out) } /// Status-filtered rehydration feed: every asset lock **except** terminal -/// `Consumed` rows, bucketed by account index. Feeding `Consumed` locks back -/// into the live set would resurrect a spent one-shot lock as actionable -/// (A04/A08), so the exclusion is at the SQL level (`status NOT IN -/// ('consumed')`, `status` indexed); history stays visible via [`load_state`]. -/// Malformed rows remain fatal; Recovery tolerates status drift and excludes -/// the row if either representation says it is consumed. +/// `Consumed` rows without an operation ID, bucketed by account index. +/// Consumed wallet-drain locks are non-actionable recovery tombstones: retaining +/// their ID prevents a restart from mistaking a completed operation for a new one. +/// Legacy consumed locks remain excluded; full history is available via +/// [`load_state`]. Malformed rows remain fatal. Recovery keeps consumption +/// sticky when the typed column and blob disagree. pub fn load_unconsumed( conn: &Connection, wallet_id: &WalletId, ctx: &LoadCtx, ) -> Result { let mut stmt = conn.prepare( - "SELECT length(outpoint), outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status \ - FROM asset_locks WHERE wallet_id = ?1 AND status NOT IN ('consumed')", + "SELECT length(outpoint), outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status, operation_id \ + FROM asset_locks WHERE wallet_id = ?1 AND (status != 'consumed' OR operation_id IS NOT NULL)", )?; let mut rows = stmt.query(params![wallet_id.as_slice()])?; let mut out: AssetLocksByAccount = BTreeMap::new(); @@ -361,9 +419,16 @@ pub fn load_unconsumed( blob::check_size(row.get::<_, i64>(3)?)?; let blob_bytes: Vec = row.get(4)?; let status: String = row.get(5)?; - let (acct, outpoint, tracked) = - decode_row(&op_bytes, account_index, &blob_bytes, &status, ctx)?; - if tracked.status == AssetLockStatus::Consumed { + let operation_id: Option> = row.get(6)?; + let (acct, outpoint, tracked) = decode_row( + &op_bytes, + account_index, + &blob_bytes, + &status, + operation_id, + ctx, + )?; + if tracked.status.is_consumed() && tracked.operation_id.is_none() { continue; } out.entry(acct).or_default().insert(outpoint, tracked); @@ -426,6 +491,7 @@ mod tests { account_index: 0, funding_type: AssetLockFundingType::IdentityRegistration, identity_index: 0, + operation_id: None, amount_duffs: 1000, status, proof, @@ -463,10 +529,50 @@ mod tests { amount_duffs: tracked.amount, status: tracked.status, proof: tracked.proof, + operation_id: tracked.operation_id, }) .expect("stored asset lock must load back") } + #[test] + fn should_preserve_proven_drain_completion_over_stale_consumed_snapshot() { + let mut conn = migrated_conn(); + let wallet = [0xAA; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet[..]], + ) + .unwrap(); + let mut entry = + entry_with_proof(sample_outpoint(90), AssetLockStatus::DrainCompleted, None); + entry.operation_id = Some([9; 16]); + assert_eq!( + roundtrip_through_db(&entry).status, + AssetLockStatus::DrainCompleted + ); + for status in [ + AssetLockStatus::DrainCompleted, + AssetLockStatus::Consumed, + AssetLockStatus::Built, + ] { + entry.status = status; + let mut cs = AssetLockChangeSet::default(); + cs.asset_locks.insert(entry.out_point, entry.clone()); + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet, &cs).unwrap(); + tx.commit().unwrap(); + } + let restored = load_state(&conn, &wallet, &LoadCtx::strict()).unwrap(); + assert_eq!( + restored[&0][&entry.out_point].status, + AssetLockStatus::DrainCompleted + ); + let projection: String = conn + .query_row("SELECT status FROM asset_locks", [], |row| row.get(0)) + .unwrap(); + assert_eq!(projection, "consumed"); + } + /// Repro for #4133: a `proof: Some(AssetLockProof::Chain(..))` row must /// survive a full `apply` → `load_state` round-trip. Before the wire-type /// fix this failed at decode with `BincodeDecode`/`AnyNotSupported`, because @@ -543,9 +649,31 @@ mod tests { fn none_proof_row_is_byte_identical_across_shapes() { let entry = entry_with_proof(sample_outpoint(0x34), AssetLockStatus::Built, None); - // Old on-disk bytes: exactly what the pre-fix `blob::encode` produced - // (`bincode::serde::encode_to_vec` over the `AssetLockEntry` itself). - let old_bytes = bincode::serde::encode_to_vec(&entry, blob::bounded_config()).unwrap(); + // Freeze the historical shape rather than following new fields on the + // live entry. UUIDs were added later and deliberately live outside this blob. + #[derive(Serialize)] + struct LegacyEntry<'a> { + out_point: OutPoint, + transaction: &'a dashcore::Transaction, + account_index: u32, + #[serde(with = "platform_wallet::changeset::serde_adapters::asset_lock_funding_type")] + funding_type: AssetLockFundingType, + identity_index: u32, + amount_duffs: u64, + status: &'a AssetLockStatus, + proof: &'a Option, + } + let legacy = LegacyEntry { + out_point: entry.out_point, + transaction: &entry.transaction, + account_index: entry.account_index, + funding_type: entry.funding_type, + identity_index: entry.identity_index, + amount_duffs: entry.amount_duffs, + status: &entry.status, + proof: &entry.proof, + }; + let old_bytes = bincode::serde::encode_to_vec(&legacy, blob::bounded_config()).unwrap(); let new_bytes = blob::encode(&AssetLockEntryWire::from_entry(&entry).unwrap()).unwrap(); assert_eq!( old_bytes, new_bytes, @@ -591,6 +719,7 @@ mod tests { account_index: 0, funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, + operation_id: None, amount_duffs: 1000, status: AssetLockStatus::Built, proof: None, @@ -643,6 +772,7 @@ mod tests { AssetLockStatus::ChainLocked, AssetLockStatus::Consumed, AssetLockStatus::RecoveredFromChain, + AssetLockStatus::DrainCompleted, ]; for v in &variants { match v { @@ -651,7 +781,8 @@ mod tests { | AssetLockStatus::InstantSendLocked | AssetLockStatus::ChainLocked | AssetLockStatus::Consumed - | AssetLockStatus::RecoveredFromChain => {} + | AssetLockStatus::RecoveredFromChain + | AssetLockStatus::DrainCompleted => {} } } variants diff --git a/packages/rs-platform-wallet-storage/tests/integration/persisted_core_transactions.rs b/packages/rs-platform-wallet-storage/tests/integration/persisted_core_transactions.rs index a3c554c3d4f..0d4983d5885 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/persisted_core_transactions.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/persisted_core_transactions.rs @@ -135,16 +135,7 @@ fn load_lifecycle(blob: &[u8], out_point: dashcore::OutPoint) -> AssetLockEntry .remove(&0) .and_then(|mut by_outpoint| by_outpoint.remove(&out_point)) .expect("the stored lock"); - AssetLockEntry { - out_point: lock.out_point, - transaction: lock.transaction, - account_index: lock.account_index, - funding_type: lock.funding_type, - identity_index: lock.identity_index, - amount_duffs: lock.amount, - status: lock.status, - proof: lock.proof, - } + AssetLockEntry::from(&lock) } #[test] diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_asset_locks_filter.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_asset_locks_filter.rs index 2243e7bf277..b3cac135312 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_asset_locks_filter.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_asset_locks_filter.rs @@ -12,6 +12,7 @@ use dashcore::{OutPoint, Transaction, Txid}; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry, PlatformWalletPersistence}; use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; +use platform_wallet_storage::sqlite::error::WalletStorageError; use platform_wallet_storage::sqlite::schema::asset_locks; fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { @@ -34,6 +35,7 @@ fn entry(op: OutPoint, status: AssetLockStatus) -> AssetLockEntry { account_index: 0, funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, + operation_id: None, amount_duffs: 1000, status, proof: None, @@ -144,3 +146,197 @@ fn a2_all_consumed_yields_empty_feed() { drop(conn); assert!(feed.is_empty(), "all-consumed wallet → empty feed"); } + +/// A completed drain must remain discoverable by UUID after restart, without +/// making legacy consumed locks actionable again. +#[test] +fn should_restore_consumed_drain_operation_tombstones() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xA6); + ensure_wallet_meta(&persister, &w); + let outpoint = op(0x30); + let operation_id = [0xC5; 16]; + for status in [AssetLockStatus::Built, AssetLockStatus::Consumed] { + let mut row = entry(outpoint, status); + row.operation_id = Some(operation_id); + persister + .store( + w, + platform_wallet::changeset::PlatformWalletChangeSet { + asset_locks: Some(AssetLockChangeSet { + asset_locks: [(outpoint, row)].into_iter().collect(), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + persister.flush(w).unwrap(); + } + drop(persister); + let reopened = reopen(&path); + let conn = reopened.lock_conn_for_test(); + let locks = + asset_locks::load_unconsumed(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .unwrap(); + let restored = &locks[&0][&outpoint]; + assert_eq!(restored.operation_id, Some(operation_id)); + assert_eq!(restored.status, AssetLockStatus::Consumed); +} + +#[test] +fn should_preserve_operation_id_when_legacy_snapshot_lacks_it() { + let (persister, _tmp, _) = fresh_persister(); + let w = wid(0xA7); + ensure_wallet_meta(&persister, &w); + let outpoint = op(0x31); + for id in [Some([7; 16]), None] { + let mut row = entry(outpoint, AssetLockStatus::Broadcast); + row.operation_id = id; + persister + .store( + w, + platform_wallet::changeset::PlatformWalletChangeSet { + asset_locks: Some(AssetLockChangeSet { + asset_locks: [(outpoint, row)].into_iter().collect(), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + } + let conn = persister.lock_conn_for_test(); + let locks = + asset_locks::load_unconsumed(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .unwrap(); + assert_eq!(locks[&0][&outpoint].operation_id, Some([7; 16])); +} + +#[test] +fn should_migrate_legacy_blob_without_reencoding_and_reject_invalid_operation_ids() { + use platform_wallet_storage::sqlite::migrations; + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + migrations::runner() + .set_target(refinery::Target::Version(18)) + .run(&mut conn) + .unwrap(); + let w = wid(0xA8); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + let outpoint = op(0x32); + let legacy = entry(outpoint, AssetLockStatus::Built); + let bytes = asset_locks::encode_entry_for_test(&legacy).unwrap(); + let outpoint_bytes = + platform_wallet_storage::sqlite::schema::blob::encode_outpoint(&outpoint).unwrap(); + conn.execute("INSERT INTO asset_locks (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob) + VALUES (?1, ?2, 'built', 0, 0, 1000, ?3)", + rusqlite::params![w.as_slice(), outpoint_bytes, bytes]).unwrap(); + migrations::run(&mut conn).unwrap(); + let (after, id): (Vec, Option>) = conn + .query_row( + "SELECT lifecycle_blob, operation_id FROM asset_locks", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(after, bytes, "published lifecycle blobs must not change"); + assert!(id.is_none()); + let locks = + asset_locks::load_unconsumed(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .unwrap(); + assert_eq!(locks[&0][&outpoint].operation_id, None); + assert!(conn + .execute("UPDATE asset_locks SET operation_id = ?1", [vec![1_u8; 15]]) + .is_err()); + assert!(conn + .execute( + "UPDATE asset_locks SET operation_id = 'abcdefghijklmnop'", + [] + ) + .is_err()); + conn.execute("UPDATE asset_locks SET operation_id = ?1", [vec![1_u8; 16]]) + .unwrap(); + assert!(conn + .execute("UPDATE asset_locks SET operation_id = ?1", [vec![2_u8; 16]]) + .is_err()); + assert!(conn + .execute("UPDATE asset_locks SET operation_id = NULL", []) + .is_err()); +} + +#[test] +fn should_reject_conflicting_operation_id_before_mutating_any_stored_columns() { + fn stored_row( + conn: &rusqlite::Connection, + wallet_id: &[u8; 32], + ) -> Vec { + conn.query_row( + "SELECT * FROM asset_locks WHERE wallet_id = ?1", + [wallet_id.as_slice()], + |row| { + (0..row.as_ref().column_count()) + .map(|index| row.get(index)) + .collect() + }, + ) + .unwrap() + } + + // Exercise both the regular upsert and the consumed-row terminal guard: + // the conflict check runs before either path touches the row. + for status in [AssetLockStatus::Broadcast, AssetLockStatus::Consumed] { + let (persister, _tmp, path) = fresh_persister(); + let wallet_id = wid(0xAA); + ensure_wallet_meta(&persister, &wallet_id); + let outpoint = op(0x34); + let mut original = entry(outpoint, status); + original.operation_id = Some([8; 16]); + persister + .store( + wallet_id, + platform_wallet::changeset::PlatformWalletChangeSet { + asset_locks: Some(AssetLockChangeSet { + asset_locks: [(outpoint, original)].into_iter().collect(), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let mut incoming = entry(outpoint, AssetLockStatus::ChainLocked); + incoming.operation_id = Some([9; 16]); + incoming.account_index = 7; + incoming.identity_index = 11; + incoming.amount_duffs = 2_000; + incoming.transaction.lock_time = 123; + let changeset = AssetLockChangeSet { + asset_locks: [(outpoint, incoming)].into_iter().collect(), + ..Default::default() + }; + let mut conn = persister.lock_conn_for_test(); + let before = stored_row(&conn, &wallet_id); + let tx = conn.transaction().unwrap(); + assert!(matches!( + asset_locks::apply(&tx, &wallet_id, &changeset), + Err(WalletStorageError::BlobDecode { + reason: "asset lock operation ID cannot change" + }) + )); + // The Rust validation must reject before SQL, rather than relying on + // the immutable-UUID trigger or transaction rollback to preserve data. + tx.commit().unwrap(); + assert_eq!(stored_row(&conn, &wallet_id), before); + drop(conn); + drop(persister); + let reopened = reopen(&path); + assert_eq!( + stored_row(&reopened.lock_conn_for_test(), &wallet_id), + before + ); + } +} diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_blob_roundtrip_coverage.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_blob_roundtrip_coverage.rs index 0aaf9f9eb51..c0235fc8e5e 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_blob_roundtrip_coverage.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_blob_roundtrip_coverage.rs @@ -52,6 +52,7 @@ fn entry(op: OutPoint, status: AssetLockStatus, proof: Option) - account_index: 0, funding_type: AssetLockFundingType::IdentityRegistration, identity_index: 0, + operation_id: None, amount_duffs: 1000, status, proof, diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_load_reconstruction.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_load_reconstruction.rs index bbb2c3b8a61..4206b1db3bc 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_load_reconstruction.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_load_reconstruction.rs @@ -1087,6 +1087,7 @@ fn tc_p4_005_load_asset_locks_bucketed() { account_index: account, funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, + operation_id: None, amount_duffs: 1000, status: AssetLockStatus::Built, proof: None, @@ -1392,6 +1393,7 @@ fn tc_p4_008c_asset_locks_corruption_is_hard_error() { account_index: 0, funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, + operation_id: None, amount_duffs: 1000, status: AssetLockStatus::Built, proof: None, diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs index 017ebf783ac..d13e50be66f 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs @@ -406,6 +406,7 @@ fn tc010_asset_lock_roundtrip() { account_index: 5, funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 9, + operation_id: None, amount_duffs: 12_345, status: AssetLockStatus::Built, proof: None, @@ -472,6 +473,7 @@ fn tc010b_recovered_from_chain_lock_roundtrip() { account_index: 0, funding_type: AssetLockFundingType::AssetLockShieldedAddressTopUp, identity_index: 0, + operation_id: None, amount_duffs: 500_000, status: AssetLockStatus::RecoveredFromChain, proof: Some(dpp::prelude::AssetLockProof::Chain(ChainAssetLockProof { @@ -543,6 +545,7 @@ fn tc010c_stale_recovery_snapshot_cannot_regress_consumed_row() { account_index: 0, funding_type: AssetLockFundingType::IdentityRegistration, identity_index: 0, + operation_id: None, amount_duffs: 1_000_000, status: status.clone(), proof: match status { diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs index b13d5f79e0c..a2f518dcf97 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs @@ -290,6 +290,7 @@ fn seed_asset_lock_status_drift( account_index: 0, funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, + operation_id: None, amount_duffs: 1_000, status: AssetLockStatus::Consumed, proof: None, diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_schema_pinning.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_schema_pinning.rs index 282a2f5abf1..b4ef31e3c31 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_schema_pinning.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_schema_pinning.rs @@ -15,13 +15,13 @@ use platform_wallet_storage::sqlite::{migrations as mig, schema::versions::Domai /// Golden `(version, name)` fingerprint of the frozen migration set. Bump /// deliberately only when adding/removing/renaming a migration file. const EXPECTED_ID_FINGERPRINT: &str = - "91f2fab573900a41066b8d237a29b83ca94b2fc730702389ab9c088271da522f"; + "5f6d87af51ff949ecf5058c9e1086dc7624f5d6955164e19be33bc460132776b"; /// Golden content-level fingerprint over every migration's rendered SQL. /// Bump it only when ADDING a migration file; a body change on an already /// applied migration is a defect, not a golden to refresh. const EXPECTED_SQL_FINGERPRINT: &str = - "0dbcfb2ab8d8362a206c0ab948051028c7eafc640861a823c4c50f13b0602be8"; + "3a3e9b2df9b1dde2664ae086a6bd53a30b57208024662b917698184761f14515"; /// The migrations merged `v4.2-dev` already ships. Refinery keys /// `refinery_schema_history` by version and validates an applied migration's diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shielded_viewing_keys.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shielded_viewing_keys.rs index 22b1d4cc408..d2a16697f68 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shielded_viewing_keys.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shielded_viewing_keys.rs @@ -83,6 +83,21 @@ fn sqlite_advertises_shielded_viewing_key_capability() { .contains(PersistenceCapabilities::SHIELDED_VIEWING_KEYS)); } +#[test] +fn sqlite_does_not_attest_wallet_drain_without_wallet_restore() { + let (persister, _tmp, _path) = fresh_persister(); + let capabilities = persister.persistence_capabilities(); + assert!(!capabilities.contains(PersistenceCapabilities::WALLET_DRAIN)); + assert!(capabilities + .missing(PersistenceCapabilities::WALLET_DRAIN) + .names() + .contains(&"wallet_restore")); + // Native asset-lock persistence remains available independently of the + // unsupported all-wallet shielded recovery contract. + assert!(capabilities.contains(PersistenceCapabilities::TRACKED_ASSET_LOCKS)); + assert!(capabilities.contains(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS)); +} + #[test] fn delete_wallet_cascades_shielded_viewing_keys() { let (persister, _tmp, path) = fresh_persister(); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs index f440152fb35..414f37074d4 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs @@ -380,6 +380,7 @@ fn asset_lock_typed_vs_blob_mismatch_rejected() { account_index: 99, // blob says 99 funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, + operation_id: None, amount_duffs: 1, status: AssetLockStatus::Built, proof: None, diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs index 6226e96f811..73d2437db0b 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs @@ -366,6 +366,7 @@ fn asset_lock_changeset() -> AssetLockChangeSet { account_index: 0, funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, + operation_id: None, amount_duffs: 1, status: AssetLockStatus::Built, proof: None, diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index f2c5a9f1795..47e5836972b 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1463,6 +1463,9 @@ pub struct AssetLockChangeSet { #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct AssetLockEntry { + /// Stable host operation identifier; legacy rows have no identifier. + #[cfg_attr(feature = "serde", serde(default))] + pub operation_id: Option<[u8; 16]>, /// The outpoint identifying this credit output (txid + vout). pub out_point: OutPoint, /// The full asset lock transaction. @@ -1489,6 +1492,30 @@ pub struct AssetLockEntry { pub proof: Option, } +impl AssetLockEntry { + /// Preserve the immutable operation association when folding or replaying + /// a possibly stale snapshot. Returns false for a conflicting UUID: the + /// caller must discard the whole snapshot so its lifecycle cannot be + /// attached to a different operation. + #[must_use = "a conflicting operation snapshot must not be applied"] + pub(crate) fn preserve_recovery_metadata( + &mut self, + previous_operation_id: Option<[u8; 16]>, + ) -> bool { + if let Some(previous_id) = previous_operation_id { + if self + .operation_id + .is_some_and(|incoming| incoming != previous_id) + { + tracing::warn!(out_point = %self.out_point, "Ignoring asset-lock snapshot with a conflicting operation ID"); + return false; + } + self.operation_id = Some(previous_id); + } + true + } +} + impl Merge for AssetLockChangeSet { fn merge(&mut self, other: Self) { // Last write wins, with ONE lifecycle exception: `Consumed` is @@ -1504,12 +1531,18 @@ impl Merge for AssetLockChangeSet { // durable stores apply the same rule (sqlite upsert guard, // swift-sdk `persistAssetLocks`), making the store order of // racing snapshots immaterial. - for (out_point, entry) in other.asset_locks { - if entry.status != AssetLockStatus::Consumed { - if let Some(existing) = self.asset_locks.get(&out_point) { - if existing.status == AssetLockStatus::Consumed { - continue; - } + for (out_point, mut entry) in other.asset_locks { + if let Some(existing) = self.asset_locks.get_mut(&out_point) { + if !entry.preserve_recovery_metadata(existing.operation_id) { + continue; + } + if existing.status == AssetLockStatus::DrainCompleted + || (existing.status.is_consumed() && !entry.status.is_consumed()) + { + // Terminality guards lifecycle state, never the operation + // association carried by a delayed snapshot. + existing.operation_id = entry.operation_id; + continue; } } // Every ACCEPTED upsert supersedes an earlier-folded tombstone @@ -1543,7 +1576,7 @@ impl Merge for AssetLockChangeSet { let consumed = self .asset_locks .get(&out_point) - .is_some_and(|entry| entry.status == AssetLockStatus::Consumed); + .is_some_and(|entry| entry.status.is_consumed()); if !consumed { self.asset_locks.remove(&out_point); self.removed.insert(out_point); @@ -2575,6 +2608,7 @@ mod tests { vout: 0, }; let entry_with = |status: AssetLockStatus| AssetLockEntry { + operation_id: None, out_point: outpoint, transaction: Transaction { version: 3, @@ -2596,6 +2630,19 @@ mod tests { cs }; + let mut certified = cs_with(AssetLockStatus::Consumed); + certified.merge(cs_with(AssetLockStatus::DrainCompleted)); + certified.merge(cs_with(AssetLockStatus::Consumed)); + certified.merge(cs_with(AssetLockStatus::Broadcast)); + let mut removal = AssetLockChangeSet::default(); + removal.removed.insert(outpoint); + certified.merge(removal); + assert_eq!( + certified.asset_locks[&outpoint].status, + AssetLockStatus::DrainCompleted + ); + assert!(!certified.removed.contains(&outpoint)); + // Stale recovery snapshot folded AFTER the consumption write. let mut folded = cs_with(AssetLockStatus::Consumed); folded.merge(cs_with(AssetLockStatus::RecoveredFromChain)); @@ -2605,6 +2652,23 @@ mod tests { "a non-Consumed snapshot must not replace the Consumed terminal" ); + // A conflicting operation cannot replace the lifecycle. + let mut original = cs_with(AssetLockStatus::Broadcast); + original + .asset_locks + .get_mut(&outpoint) + .unwrap() + .operation_id = Some([42; 16]); + let mut conflicting = cs_with(AssetLockStatus::Consumed); + conflicting + .asset_locks + .get_mut(&outpoint) + .unwrap() + .operation_id = Some([43; 16]); + let expected = original.clone(); + original.merge(conflicting); + assert_eq!(original.asset_locks, expected.asset_locks); + // The legitimate direction still lands. let mut folded = cs_with(AssetLockStatus::RecoveredFromChain); folded.merge(cs_with(AssetLockStatus::Consumed)); @@ -3259,6 +3323,7 @@ mod tests { m.insert( OutPoint::default(), AssetLockEntry { + operation_id: None, out_point: OutPoint::default(), transaction: Transaction { version: 3, diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index b752f465d88..0da29130409 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -7889,6 +7889,7 @@ mod tests { asset_locks.asset_locks.insert( out_point, AssetLockEntry { + operation_id: None, out_point, transaction: dashcore::Transaction { version: 3, diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index d930daf1ec9..6c17b550b1e 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -106,13 +106,18 @@ impl PersistenceCapabilities { /// wired. pub const DASHPAY_PAYMENTS: Self = Self(1 << 12); + /// Asset-lock operation UUIDs survive store/load, including consumed tombstones. + /// Missing or malformed persistence data fails load rather than becoming an + /// empty result. Required before a wallet drain can treat absence as not sent. + pub const ASSET_LOCK_OPERATION_IDS: Self = Self(1 << 13); + /// Index of the highest bit declared above. It lives here, beside the /// constants, so adding a bit and bumping this is one edit in one place /// — and `every_declared_bit_has_a_stable_name` walks up to it, so a new /// bit that never reaches `KNOWN` fails a test instead of gating /// behaviour invisibly. The same test asserts nothing above it is named, /// which is what catches a bit added without bumping this. - const HIGHEST_DECLARED_BIT: u32 = 12; + const HIGHEST_DECLARED_BIT: u32 = 13; /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -131,6 +136,13 @@ impl PersistenceCapabilities { pub const ASSET_LOCK_RECONCILIATION: Self = Self(Self::ATOMIC_CHANGESETS.0 | Self::TRACKED_ASSET_LOCKS.0 | Self::WALLET_RESTORE.0); + /// Durable correlation and funding-key indices required for wallet drain. + pub const WALLET_DRAIN: Self = Self( + Self::ASSET_LOCK_RECONCILIATION.0 + | Self::ASSET_LOCK_FUNDING_INDICES.0 + | Self::ASSET_LOCK_OPERATION_IDS.0, + ); + pub const fn from_bits_retain(bits: u64) -> Self { Self(bits) } @@ -205,6 +217,10 @@ impl PersistenceCapabilities { PersistenceCapabilities::DASHPAY_PAYMENTS, "dashpay_payments", ), + ( + PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS, + "asset_lock_operation_ids", + ), ]; KNOWN @@ -222,7 +238,7 @@ impl PersistenceCapabilities { /// failure the test exists to catch. Written as a module-level `const _` so /// it is evaluated in every build, test or not. const _: () = assert!( - PersistenceCapabilities::DASHPAY_PAYMENTS.bits() + PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS.bits() == 1u64 << PersistenceCapabilities::HIGHEST_DECLARED_BIT, "HIGHEST_DECLARED_BIT must name the highest declared capability bit" ); @@ -250,6 +266,11 @@ mod tests { assert_eq!(PersistenceCapabilities::TRACKED_MASTERNODES.bits(), 0x400); assert_eq!(PersistenceCapabilities::CORE_SWEEP_REMOVAL.bits(), 0x800); assert_eq!(PersistenceCapabilities::DASHPAY_PAYMENTS.bits(), 0x1000); + assert_eq!( + PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS.bits(), + 0x2000 + ); + assert_eq!(PersistenceCapabilities::WALLET_DRAIN.bits(), 0x2285); assert_eq!( PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), 0x281 diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 7f171b1ab98..d9f2e56ef15 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -600,6 +600,12 @@ pub enum PlatformWalletError { )] AssetLockInsufficientFunds { available: u64, required: u64 }, + /// A whole-balance asset lock needs more inputs than an asset lock may + /// carry (`max_asset_lock_transaction_inputs`, 100: Dash Core relay policy + /// and Platform consensus alike). Nothing was built for the wire. + #[error("Asset lock requires {count} inputs, exceeding the limit of {max}")] + AssetLockTooManyInputs { count: usize, max: usize }, + #[error("SDK error: {0}")] Sdk(#[from] dash_sdk::Error), diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 6fea92fd06b..bc939526561 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -971,6 +971,7 @@ impl PlatformWalletManager

{ AssetLockStatus::InstantSendLocked => 2, AssetLockStatus::ChainLocked => 3, AssetLockStatus::Consumed => 4, + AssetLockStatus::DrainCompleted => 6, AssetLockStatus::RecoveredFromChain => 5, }; let (instant_lock_present, chain_lock_height) = match &lock.proof { diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index c781c901e43..25b8022e280 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -11,6 +11,9 @@ //! - **Idempotent.** Applying the same changeset N times produces the //! same state as applying it once. Callers may re-run apply on //! startup or after a partial write without additional bookkeeping. +//! - **Monotonic asset-lock recovery.** A stale snapshot cannot erase an +//! operation UUID or regress a consumed tombstone. A conflicting UUID +//! cannot rebind the existing operation. //! - **No re-emission.** `apply_changeset` returns `Result<(), _>`, not //! a new changeset. Mutation bookkeeping that internally produces //! changesets (e.g. `update_balance`) discards them. @@ -46,7 +49,7 @@ use key_wallet::wallet::Wallet; use crate::changeset::PlatformWalletChangeSet; -use crate::wallet::asset_lock::tracked::TrackedAssetLock; +use crate::wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; use crate::wallet::platform_wallet::PlatformWalletInfo; /// Errors returned by [`PlatformWalletInfo::apply_changeset`] and the @@ -356,10 +359,22 @@ impl PlatformWalletInfo { // Replaying the persisted tombstone therefore preserves the // same classification after a restart. if let Some(al_cs) = asset_locks { - for (out_point, entry) in al_cs.asset_locks { + for (out_point, mut entry) in al_cs.asset_locks { + if let Some(existing) = self.tracked_asset_locks.get_mut(&out_point) { + if !entry.preserve_recovery_metadata(existing.operation_id) { + continue; + } + if existing.status == AssetLockStatus::DrainCompleted + || (existing.status.is_consumed() && !entry.status.is_consumed()) + { + existing.operation_id = entry.operation_id; + continue; + } + } self.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: entry.operation_id, out_point: entry.out_point, transaction: entry.transaction, account_index: entry.account_index, @@ -372,6 +387,15 @@ impl PlatformWalletInfo { ); } for out_point in al_cs.removed { + // Match changeset folding and durable stores: a stale removal + // cannot erase the terminal operation-recovery tombstone. + if self + .tracked_asset_locks + .get(&out_point) + .is_some_and(|entry| entry.status.is_consumed()) + { + continue; + } self.tracked_asset_locks.remove(&out_point); } } @@ -725,6 +749,7 @@ mod tests { al_cs.asset_locks.insert( out_point, AssetLockEntry { + operation_id: None, out_point, transaction: dashcore::Transaction { version: 3, @@ -770,13 +795,170 @@ mod tests { AssetLockStatus::Consumed ); - // An explicit removal tombstone still removes it. + // A stale removal cannot discard the terminal recovery tombstone. let mut al_cs = AssetLockChangeSet::default(); al_cs.removed.insert(out_point); let mut cs = PlatformWalletChangeSet::default(); cs.asset_locks = Some(al_cs); info.apply_changeset(&mut wallet, cs).expect("apply remove"); - assert!(!info.tracked_asset_locks.contains_key(&out_point)); + assert_eq!( + info.tracked_asset_locks[&out_point].status, + AssetLockStatus::Consumed + ); + } + + fn recovery_asset_lock( + status: AssetLockStatus, + operation_id: Option<[u8; 16]>, + ) -> AssetLockEntry { + AssetLockEntry { + operation_id, + out_point: OutPoint::default(), + transaction: dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 0, + funding_type: AssetLockFundingType::AssetLockShieldedAddressTopUp, + identity_index: 0, + amount_duffs: 5_000, + status, + proof: None, + } + } + + fn apply_recovery_asset_lock( + info: &mut PlatformWalletInfo, + wallet: &mut Wallet, + entry: AssetLockEntry, + ) { + let mut locks = AssetLockChangeSet::default(); + locks.asset_locks.insert(entry.out_point, entry); + info.apply_changeset( + wallet, + PlatformWalletChangeSet { + asset_locks: Some(locks), + ..Default::default() + }, + ) + .expect("apply asset-lock snapshot"); + } + + #[test] + fn should_preserve_asset_lock_recovery_metadata_when_applying_stale_snapshots() { + let mut wallet = build_test_wallet(); + let mut info = empty_info(&wallet); + let operation_id = Some([42; 16]); + apply_recovery_asset_lock( + &mut info, + &mut wallet, + recovery_asset_lock(AssetLockStatus::Broadcast, operation_id), + ); + + // A stale snapshot without the UUID keeps the association. + apply_recovery_asset_lock( + &mut info, + &mut wallet, + recovery_asset_lock(AssetLockStatus::RecoveredFromChain, None), + ); + let restored = &info.tracked_asset_locks[&OutPoint::default()]; + assert_eq!(restored.operation_id, operation_id); + assert_eq!(restored.status, AssetLockStatus::RecoveredFromChain); + + let newest = recovery_asset_lock(AssetLockStatus::ChainLocked, operation_id); + apply_recovery_asset_lock(&mut info, &mut wallet, newest.clone()); + apply_recovery_asset_lock(&mut info, &mut wallet, newest); + let restored = &info.tracked_asset_locks[&OutPoint::default()]; + assert_eq!(restored.operation_id, operation_id); + assert_eq!(restored.status, AssetLockStatus::ChainLocked); + } + + #[test] + fn should_keep_consumed_asset_locks_terminal_when_applying_stale_snapshots() { + let mut wallet = build_test_wallet(); + let mut info = empty_info(&wallet); + let operation_id = Some([42; 16]); + apply_recovery_asset_lock( + &mut info, + &mut wallet, + recovery_asset_lock(AssetLockStatus::Consumed, operation_id), + ); + apply_recovery_asset_lock( + &mut info, + &mut wallet, + recovery_asset_lock(AssetLockStatus::Broadcast, None), + ); + let mut removed = AssetLockChangeSet::default(); + removed.removed.insert(OutPoint::default()); + info.apply_changeset( + &mut wallet, + PlatformWalletChangeSet { + asset_locks: Some(removed), + ..Default::default() + }, + ) + .expect("apply stale removal"); + let restored = &info.tracked_asset_locks[&OutPoint::default()]; + assert_eq!(restored.operation_id, operation_id); + assert_eq!(restored.status, AssetLockStatus::Consumed); + } + + #[test] + fn should_preserve_proven_drain_completion_over_generic_consumption_and_removal() { + let mut wallet = build_test_wallet(); + let mut info = empty_info(&wallet); + for status in [ + AssetLockStatus::Consumed, + AssetLockStatus::DrainCompleted, + AssetLockStatus::Consumed, + AssetLockStatus::Broadcast, + ] { + apply_recovery_asset_lock( + &mut info, + &mut wallet, + recovery_asset_lock(status.clone(), Some([42; 16])), + ); + if status == AssetLockStatus::DrainCompleted { + assert_eq!( + info.tracked_asset_locks[&OutPoint::default()].status, + status + ); + } + } + let mut removed = AssetLockChangeSet::default(); + removed.removed.insert(OutPoint::default()); + info.apply_changeset( + &mut wallet, + PlatformWalletChangeSet { + asset_locks: Some(removed), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + info.tracked_asset_locks[&OutPoint::default()].status, + AssetLockStatus::DrainCompleted + ); + } + + #[test] + fn should_ignore_conflicting_asset_lock_operation_snapshots() { + let mut wallet = build_test_wallet(); + let mut info = empty_info(&wallet); + let original = recovery_asset_lock(AssetLockStatus::Broadcast, Some([42; 16])); + apply_recovery_asset_lock(&mut info, &mut wallet, original.clone()); + apply_recovery_asset_lock( + &mut info, + &mut wallet, + recovery_asset_lock(AssetLockStatus::Consumed, Some([43; 16])), + ); + assert_eq!( + AssetLockEntry::from(&info.tracked_asset_locks[&OutPoint::default()]), + original + ); } /// Token-balance changesets are accepted by `apply_changeset` for diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 951c1e21ce4..3b5ca3f6d90 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -22,13 +22,16 @@ use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePr use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; -use crate::changeset::{AccountRegistrationEntry, PlatformWalletChangeSet}; +use crate::changeset::{ + AccountAddressPoolEntry, AccountRegistrationEntry, AssetLockChangeSet, PlatformWalletChangeSet, +}; use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::PlatformWalletInfo; use crate::ASSET_LOCK_FUNDING_SOURCES; use super::manager::{AssetLockManager, DEFAULT_FEE_PER_KB}; use super::tracked::{AssetLockStatus, TrackedAssetLock}; +use super::{max_asset_lock_inputs, AssetLockOperationContext, AssetLockOperationState}; // --------------------------------------------------------------------------- // Asset lock transaction building @@ -158,7 +161,7 @@ impl AssetLockManager { ), PlatformWalletError, > { - let (transaction, path, token, accounts, pin) = self + let (transaction, path, token, accounts, pin, _funding_pools) = self .build_asset_lock_transaction_fenced( amount, funding_sources, @@ -212,6 +215,7 @@ impl AssetLockManager { Option, Vec, crate::wallet::core::InBroadcastPin, + Vec, ), PlatformWalletError, > { @@ -373,6 +377,10 @@ impl AssetLockManager { // and against `last_processed_height` advancement — the two mutations // that could otherwise interleave. See the method docs for the race // this closes. + // Capture used funding indices under the build's existing manager guard. + // The funded path must not reacquire it after reserving inputs: a + // cancellation there has no durable row from which to recover. + let funding_pools = Self::asset_lock_account_pool_entries(info); let in_broadcast_pin = info.generation.pin_in_broadcast(&result.transaction); Ok(( @@ -381,6 +389,7 @@ impl AssetLockManager { result.reservation_token, result.funding_accounts, in_broadcast_pin, + funding_pools, )) } @@ -698,7 +707,7 @@ impl AssetLockManager { Ok(()) } - /// Persist the asset-lock funding accounts' address-pool snapshots so a + /// Capture the asset-lock funding accounts' address-pool snapshots so a /// consumed `funding_index` survives an app restart. /// /// The `IdentityRegistration` / `IdentityTopUp` / `IdentityInvitation` / @@ -714,47 +723,40 @@ impl AssetLockManager { /// `restore_address_pool` on load. Funds accounts are skipped — they already /// persist their pools via the normal address-sync path. Best-effort. /// - /// The snapshot re-acquires a read lock after the build's write lock is - /// released, so callers must serialize asset-lock builds per wallet (the app - /// creates invitations one-at-a-time from the UI); two concurrent builds on - /// one wallet could otherwise persist a stale snapshot that drops the higher - /// burned index — self-healing on the next build, but a residual to respect. - async fn persist_asset_lock_account_pools( + /// Capture under the build's existing guard. The funded caller holds + /// `build_persist_serial` until this snapshot is durably recorded, so + /// another funded build cannot persist an older used-index snapshot later. + fn asset_lock_account_pool_entries( + wallet_info: &PlatformWalletInfo, + ) -> Vec { + wallet_info + .core_wallet + .all_managed_accounts() + .iter() + .filter(|managed| { + matches!( + managed.managed_account_type().to_account_type(), + AccountType::IdentityRegistration + | AccountType::IdentityTopUp { .. } + | AccountType::IdentityTopUpNotBoundToIdentity + | AccountType::IdentityInvitation + | AccountType::AssetLockAddressTopUp + | AccountType::AssetLockShieldedAddressTopUp + ) + }) + .flat_map(|managed| { + crate::changeset::account_address_pool_entries( + managed.managed_account_type().to_account_type(), + managed.managed_account_type().address_pools(), + ) + }) + .collect() + } + + fn persist_asset_lock_account_pools( &self, + entries: Vec, ) -> Result<(), crate::changeset::PersistenceError> { - use crate::changeset::{AccountAddressPoolEntry, PlatformWalletChangeSet}; - use key_wallet::account::AccountType; - - let entries: Vec = { - let wm = self.wallet_manager.read().await; - let Some(wallet_info) = wm.get_wallet_info(&self.wallet_id) else { - return Ok(()); - }; - wallet_info - .core_wallet - .all_managed_accounts() - .iter() - .filter(|managed| { - matches!( - managed.managed_account_type().to_account_type(), - AccountType::IdentityRegistration - | AccountType::IdentityTopUp { .. } - | AccountType::IdentityTopUpNotBoundToIdentity - | AccountType::IdentityInvitation - | AccountType::AssetLockAddressTopUp - | AccountType::AssetLockShieldedAddressTopUp - ) - }) - .flat_map(|managed| { - let account_type = managed.managed_account_type().to_account_type(); - crate::changeset::account_address_pool_entries( - account_type, - managed.managed_account_type().address_pools(), - ) - }) - .collect() - }; - if entries.is_empty() { return Ok(()); } @@ -914,9 +916,62 @@ impl AssetLockManager { identity_index: u32, signer: &S, ) -> Result<(DerivationPath, OutPoint), PlatformWalletError> { + self.broadcast_funded_asset_lock_with_operation( + amount, + funding_sources, + source_index, + funding_type, + identity_index, + signer, + None, + ) + .await + } + + pub(crate) async fn create_wallet_drain_asset_lock_proof( + &self, + account_index: u32, + minimum_lock_duffs: Option, + funding_type: AssetLockFundingType, + identity_index: u32, + signer: &S, + operation: &AssetLockOperationContext, + ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { + self.require_wallet_drain_persistence()?; + let (path, out_point) = self + .broadcast_funded_asset_lock_with_operation( + AssetLockBuildAmount::DrainAll { minimum_lock_duffs }, + &ASSET_LOCK_FUNDING_SOURCES, + account_index, + funding_type, + identity_index, + signer, + Some(operation), + ) + .await?; + let proof = self + .wait_for_funded_asset_lock_proof(&out_point, account_index) + .await?; + Ok((proof, path, out_point)) + } + + #[allow(clippy::too_many_arguments)] + async fn broadcast_funded_asset_lock_with_operation( + &self, + amount: AssetLockBuildAmount, + funding_sources: &[AccountTypePreference], + source_index: u32, + funding_type: AssetLockFundingType, + identity_index: u32, + signer: &S, + operation: Option<&AssetLockOperationContext>, + ) -> Result<(DerivationPath, OutPoint), PlatformWalletError> { + if operation.is_some() { + self.require_wallet_drain_persistence()?; + } // Serialize build→persist so a concurrent build cannot interleave its - // pool snapshot with ours. The snapshot is collected from live wallet - // state at persist time; without this guard, build A's snapshot + // pool snapshot with ours. The snapshot is collected under the build's + // wallet-manager guard; without this serialization, build A's snapshot // (missing B's just-marked index) can be persisted AFTER B's, // rolling the durable used-index state back — after a restart the // next invitation would re-select B's index and re-export the same @@ -954,7 +1009,7 @@ impl AssetLockManager { // exit from here on settles it: released on the aborts that never // reach the broadcaster and on a definitive rejection, left pending // otherwise. - let (tx, path, reservation_token, funding_accounts, in_broadcast_pin) = self + let (tx, path, reservation_token, funding_accounts, in_broadcast_pin, funding_pools) = self .build_asset_lock_transaction_fenced( amount, funding_sources, @@ -987,11 +1042,35 @@ impl AssetLockManager { // have been swept and re-owned) and no transaction reaches the wire. // The funding key index consumed by the build is the same residue any // discarded build leaves and is reclaimed by the gap-limit scan. - if let AssetLockBuildAmount::DrainAll { - minimum_lock_duffs: Some(minimum), - } = amount - { - if locked_amount_duffs < minimum { + // + // The same verdict covers the asset-lock input cap: Dash Core relays + // at most `max_asset_lock_transaction_inputs` (100) inputs and drops a + // larger lock silently (`assetlocktx-too-many-inputs`), and Platform + // consensus validates the same versioned bound — tighter than the + // standard-transaction limit key-wallet's builder applies. A drain + // pools every eligible input, so an oversized lock is abandoned here + // rather than stranded on the wire. + let max_inputs = max_asset_lock_inputs(self.sdk.version()); + let abandon = match amount { + AssetLockBuildAmount::DrainAll { .. } if tx.input.len() > max_inputs => { + Some(PlatformWalletError::AssetLockTooManyInputs { + count: tx.input.len(), + max: max_inputs, + }) + } + AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: Some(minimum), + } if locked_amount_duffs < minimum => { + Some(PlatformWalletError::AssetLockTransaction(format!( + "drained asset lock of {locked_amount_duffs} duffs is below the required \ + minimum of {minimum} duffs (the balance cannot clear the shield pool fee); \ + nothing was broadcast" + ))) + } + _ => None, + }; + if let Some(error) = abandon { + { drop(build_persist_guard); // Nothing reached the broadcaster, so the fence has no // transaction to protect: release it alongside the reservation @@ -1021,11 +1100,7 @@ impl AssetLockManager { ) .await; in_broadcast_pin.settle_released(); - return Err(PlatformWalletError::AssetLockTransaction(format!( - "drained asset lock of {locked_amount_duffs} duffs is below the required \ - minimum of {minimum} duffs (the balance cannot clear the shield pool fee); \ - nothing was broadcast" - ))); + return Err(error); } } @@ -1042,15 +1117,24 @@ impl AssetLockManager { // anything hits the wire. // Aborting BEFORE broadcast is harmless (no tx on the wire); the other // asset-lock accounts keep their keys on-device, so they stay best-effort. - let pool_durability = match self.persist_asset_lock_account_pools().await { - Ok(()) if funding_type == AssetLockFundingType::IdentityInvitation => { + let pool_durability = match self.persist_asset_lock_account_pools(funding_pools) { + Ok(()) + if funding_type == AssetLockFundingType::IdentityInvitation + || operation.is_some() => + { self.persister.flush() } other => other, }; if let Err(e) = pool_durability { tracing::error!(error = %e, "failed to persist asset-lock funding index"); - if funding_type == AssetLockFundingType::IdentityInvitation { + if operation.is_some() || funding_type == AssetLockFundingType::IdentityInvitation { + // Only funding pools have been written: no lock row can be + // recovered and no transaction was sent. Keep the consumed + // funding index, but discard the unpublished outpoint. + if let Some(operation) = operation { + operation.set(AssetLockOperationState::NotSent, None); + } // The pooled build reserved every selected input across its // contributing accounts under `reservation_token`. Nothing // was broadcast, so abandon like the drain-floor branch @@ -1081,6 +1165,11 @@ impl AssetLockManager { ) .await; in_broadcast_pin.settle_released(); + if operation.is_some() { + return Err(PlatformWalletError::Persistence(format!( + "drain funding indices could not be made durable before dispatch: {e}" + ))); + } return Err(PlatformWalletError::AssetLockTransaction(format!( "aborted before broadcast: could not durably record the invitation \ funding index (broadcasting anyway would risk voucher-key reuse on \ @@ -1089,26 +1178,47 @@ impl AssetLockManager { } } - // The durable snapshot now includes this build's index; broadcast and - // everything after it can safely run concurrently with the next build. + let tracked = TrackedAssetLock { + operation_id: operation.map(|context| context.operation_id), + out_point, + transaction: tx.clone(), + account_index: source_index, + funding_type, + identity_index, + amount: locked_amount_duffs, + status: AssetLockStatus::Built, + proof: None, + }; + if let Some(operation) = operation { + operation.retain_checkpoint(tracked.clone()); + // No await separates the successful build, pool checkpoint and row + // store. Only this boundary can durably expose the outpoint; retain + // it if store/flush fails because the row may already be committed. + operation.set(AssetLockOperationState::Pending, Some(out_point)); + // Durable BEFORE visible: a generic resume can broadcast any + // tracked Built row immediately. No in-memory insertion is allowed + // until both the row and the previously stored pools are flushed. + let mut changeset = AssetLockChangeSet::default(); + changeset.asset_locks.insert(out_point, (&tracked).into()); + self.persister + .store(PlatformWalletChangeSet { + asset_locks: Some(changeset), + ..Default::default() + }) + .and_then(|()| self.persister.flush()) + .map_err(|error| { + PlatformWalletError::Persistence(format!( + "drain asset lock was not made durable before dispatch: {error}" + )) + })?; + self.track_asset_lock(tracked).await; + operation.clear_checkpoint(); + } else { + let cs_built = self.track_asset_lock(tracked).await; + self.queue_asset_lock_changeset(cs_built); + } drop(build_persist_guard); - // 2. Track as Built and queue the changeset onto the persister - // so a crash after broadcast leaves a row we can recover from. - let cs_built = self - .track_asset_lock(TrackedAssetLock { - out_point, - transaction: tx.clone(), - account_index: source_index, - funding_type, - identity_index, - amount: locked_amount_duffs, - status: AssetLockStatus::Built, - proof: None, - }) - .await; - self.queue_asset_lock_changeset(cs_built); - tracing::debug!( %txid, "Asset lock tracked as Built and queued for persistence; broadcasting." @@ -1161,7 +1271,29 @@ impl AssetLockManager { // still-tracked row would be resumable while its inputs are // re-spendable. let removed_built_row = cs_untrack.removed.contains(&out_point); - self.queue_asset_lock_changeset(cs_untrack); + if operation.is_some() && removed_built_row { + if let Err(error) = self.persister.store(PlatformWalletChangeSet { + asset_locks: Some(cs_untrack), + ..Default::default() + }) { + // The durable removal is unknown. Keep the context + // and input fence pending, but do NOT re-expose the row: + // the removal may have committed, and a generic resume + // must never dispatch a row with uncertain durability. + return Err(PlatformWalletError::Persistence(format!( + "could not durably remove rejected drain asset lock: {error}" + ))); + } + // Drains run on inline-committing backends only (see + // `require_wallet_drain_persistence`), so the removal is + // already durable: a failed host notification must not + // strand its context or inputs. + if let Err(error) = self.persister.flush() { + tracing::warn!(%out_point, %error, "rejected drain removed inline; flush notification failed"); + } + } else { + self.queue_asset_lock_changeset(cs_untrack); + } if removed_built_row { // Provably nothing on the wire and the row is gone: free the // fence with the reservation so the rebuild can reselect — @@ -1175,6 +1307,11 @@ impl AssetLockManager { // settle released, not fence inputs whose transaction was // never sent and can no longer be resumed (same shape as // the contact-send rejection arm). + // The durable row is gone. Publish the known-unsent result + // before cancellable cleanup can leave a stale context. + if let Some(operation) = operation { + operation.set(AssetLockOperationState::NotSent, None); + } let mut in_broadcast_pin = in_broadcast_pin; in_broadcast_pin.settle_released_on_drop(); crate::wallet::reservations::release_reservation_after_rejected_broadcast( @@ -1347,6 +1484,9 @@ fn map_builder_error(e: AssetLockError, requested: u64) -> PlatformWalletError { required: requested, } } + AssetLockError::Builder(BuilderError::TooManyInputs { count, max }) => { + PlatformWalletError::AssetLockTooManyInputs { count, max } + } other => { PlatformWalletError::AssetLockTransaction(format!("Asset lock builder failed: {other}")) } @@ -1367,6 +1507,8 @@ mod tests { }; use key_wallet::transaction_checking::{TransactionContext, TransactionType}; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; + use key_wallet::wallet::ManagedWalletInfo; + use key_wallet::wallet::Wallet; use tokio::sync::Notify; use async_trait::async_trait; @@ -1376,7 +1518,8 @@ mod tests { use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; use crate::changeset::{ - ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + ClientStartState, ClientWalletStartState, PersistenceError, PlatformWalletChangeSet, + PlatformWalletPersistence, }; use crate::test_support::{ funded_wallet_manager, funded_wallet_manager_dual_standard, @@ -1385,6 +1528,7 @@ mod tests { }; use crate::wallet::asset_lock::manager::AssetLockManager; use crate::wallet::asset_lock::tracked::AssetLockStatus; + use crate::wallet::asset_lock::tracked::TrackedAssetLock; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::PlatformWalletInfo; use crate::wallet::platform_wallet::WalletId; @@ -1461,9 +1605,35 @@ mod tests { stored: Mutex>, flushes: std::sync::atomic::AtomicUsize, fail_flush: bool, + fail_load: std::sync::atomic::AtomicBool, + fail_next_pool_store: std::sync::atomic::AtomicBool, + fail_on_flush: Option, + restored_wallet: Option<(Wallet, ManagedWalletInfo)>, + before_remove: Mutex>>, + restore_stored_locks: std::sync::atomic::AtomicBool, + commits_inline: bool, + fail_remove_store: bool, + committed_stores: std::sync::atomic::AtomicUsize, + fail_lock_store: std::sync::atomic::AtomicBool, + failed_lock_stores: Mutex>, } impl CapturingPersistence { + fn for_wallet( + manager: &Arc>>, + wallet_id: WalletId, + ) -> Self { + let manager = manager.try_read().expect("fixture wallet is not locked"); + let (wallet, info) = manager.get_wallet_and_info(&wallet_id).unwrap(); + Self { + restored_wallet: Some((wallet.clone(), info.core_wallet.clone())), + // Drains admit inline-committing backends only; a test that + // needs the refused buffered mode overrides this explicitly. + commits_inline: true, + ..Default::default() + } + } + /// Outpoints queued for persisted-row deletion across all stored /// changesets. fn removed_outpoints(&self) -> Vec { @@ -1478,29 +1648,135 @@ mod tests { } impl PlatformWalletPersistence for CapturingPersistence { + fn store_commits_inline(&self) -> bool { + self.commits_inline + } + + fn persistence_capabilities(&self) -> crate::changeset::PersistenceCapabilities { + crate::changeset::PersistenceCapabilities::WALLET_DRAIN + } + fn store( &self, _wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { - self.stored - .lock() - .expect("capturing persistence mutex") - .push(changeset); + if !changeset.account_address_pools.is_empty() + && self + .fail_next_pool_store + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + return Err(PersistenceError::backend("simulated pool store failure")); + } + if changeset + .asset_locks + .as_ref() + .is_some_and(|locks| !locks.asset_locks.is_empty()) + && self + .fail_lock_store + .load(std::sync::atomic::Ordering::SeqCst) + { + self.failed_lock_stores.lock().unwrap().push(changeset); + return Err(PersistenceError::backend("simulated lock store rollback")); + } + if changeset + .asset_locks + .as_ref() + .is_some_and(|locks| !locks.removed.is_empty()) + { + if self.fail_remove_store { + return Err(PersistenceError::backend("simulated removal store failure")); + } + if let Some(hook) = self.before_remove.lock().unwrap().as_ref() { + hook(); + } + } + let mut stored = self.stored.lock().expect("capturing persistence mutex"); + stored.push(changeset); + if self.commits_inline { + self.committed_stores + .store(stored.len(), std::sync::atomic::Ordering::SeqCst); + } Ok(()) } fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { - self.flushes - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if self.fail_flush { + let count = self + .flushes + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + + 1; + if self.fail_flush || self.fail_on_flush == Some(count) { return Err(PersistenceError::backend("simulated flush failure")); } + self.committed_stores.store( + self.stored.lock().unwrap().len(), + std::sync::atomic::Ordering::SeqCst, + ); Ok(()) } fn load(&self) -> Result { - Ok(ClientStartState::default()) + if self.fail_load.load(std::sync::atomic::Ordering::SeqCst) { + return Err(PersistenceError::backend("simulated restore failure")); + } + let mut state = ClientStartState::default(); + if let Some((wallet, info)) = &self.restored_wallet { + state.wallets.insert( + wallet.compute_wallet_id(), + ClientWalletStartState { + dashpay_backfill: Default::default(), + wallet: wallet.clone(), + wallet_info: info.clone(), + identity_manager: Default::default(), + unused_asset_locks: Default::default(), + unconfirmed_outgoing_txs: Vec::new(), + }, + ); + } + if self + .restore_stored_locks + .load(std::sync::atomic::Ordering::SeqCst) + { + let wallet = state.wallets.values_mut().next().unwrap(); + for cs in self + .stored + .lock() + .unwrap() + .iter() + .take( + self.committed_stores + .load(std::sync::atomic::Ordering::SeqCst), + ) + .filter_map(|cs| cs.asset_locks.as_ref()) + { + for entry in cs.asset_locks.values() { + wallet + .unused_asset_locks + .entry(entry.account_index) + .or_default() + .insert( + entry.out_point, + TrackedAssetLock { + operation_id: entry.operation_id, + out_point: entry.out_point, + transaction: entry.transaction.clone(), + account_index: entry.account_index, + funding_type: entry.funding_type, + identity_index: entry.identity_index, + amount: entry.amount_duffs, + status: entry.status.clone(), + proof: entry.proof.clone(), + }, + ); + } + for removed in &cs.removed { + for locks in wallet.unused_asset_locks.values_mut() { + locks.remove(removed); + } + } + } + } + Ok(state) } } @@ -2737,7 +3013,7 @@ mod tests { wallet_id: WalletId, broadcaster: Arc, ) -> (Arc>, Arc) { - let persistence = Arc::new(CapturingPersistence::default()); + let persistence = Arc::new(CapturingPersistence::for_wallet(&wallet_manager, wallet_id)); let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); let manager = Arc::new(AssetLockManager::new( sdk, @@ -3682,4 +3958,1095 @@ mod tests { "and the row itself is gone, not just reported as removed" ); } + + #[tokio::test] + async fn should_wallet_drain_refuse_missing_durable_wallet_snapshot() { + let (wallet_manager, wallet_id, _, _) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let broadcaster = Arc::new(CountingOkBroadcaster::default()); + let manager = AssetLockManager::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + wallet_manager, + wallet_id, + Arc::new(Notify::new()), + broadcaster.clone(), + // Admitted backend (inline commit) with no restored wallet, so + // the refusal below comes from the durable read, not preflight. + WalletPersister::new( + wallet_id, + Arc::new(CapturingPersistence { + commits_inline: true, + ..Default::default() + }), + ), + ); + // A globally successful load that omits this wallet must never be + // interpreted as a definitive NotSent for an old persisted host UUID. + assert!(matches!( + manager.wallet_drain_operation_status([81; 16]).await, + Err(PlatformWalletError::Persistence(message)) + if message.contains("wallet missing from durable snapshot") + )); + let operation = Arc::new(super::AssetLockOperationContext::new([81; 16])); + let _registration = manager.register_wallet_drain_operation(operation.clone()); + assert!(matches!( + manager.begin_wallet_drain(&operation).await, + Err(PlatformWalletError::Persistence(message)) + if message.contains("wallet missing from durable snapshot") + )); + assert_eq!( + operation.snapshot().state, + super::AssetLockOperationState::Pending + ); + assert_eq!(broadcaster.calls(), 0); + assert!(manager.list_tracked_locks().await.is_empty()); + } + + #[tokio::test] + async fn should_wallet_drain_release_only_the_last_matching_prebuild_registration() { + let (wallet_manager, wallet_id, _, _) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let (manager, _) = asset_lock_manager_over( + wallet_manager, + wallet_id, + Arc::new(CountingOkBroadcaster::default()), + ); + let operation = Arc::new(super::AssetLockOperationContext::new([82; 16])); + let outer = manager.register_wallet_drain_operation(operation.clone()); + let inner = manager.register_wallet_drain_operation(operation.clone()); + manager.begin_wallet_drain(&operation).await.unwrap(); + let same_id = Arc::new(super::AssetLockOperationContext::new([82; 16])); + let unrelated = manager.register_wallet_drain_operation(same_id.clone()); + drop(inner); + assert_eq!( + manager + .wallet_drain_operation_status([82; 16]) + .await + .unwrap() + .state, + super::AssetLockOperationState::Pending, + "another Arc or a nested worker cannot clear the owner's registration" + ); + drop(outer); + assert_eq!( + manager + .wallet_drain_operation_status([82; 16]) + .await + .unwrap() + .state, + super::AssetLockOperationState::Pending, + "the queued same-UUID contender must remain visible after the owner exits" + ); + assert!(manager.begin_wallet_drain(&same_id).await.is_err()); + drop(unrelated); + assert_eq!( + operation.snapshot().state, + super::AssetLockOperationState::Pending + ); + assert_eq!( + manager + .wallet_drain_operation_status([82; 16]) + .await + .unwrap() + .state, + super::AssetLockOperationState::NotSent + ); + let _retry = manager.register_wallet_drain_operation(operation.clone()); + assert_eq!(manager.begin_wallet_drain(&operation).await.unwrap(), None); + assert_eq!(manager.broadcaster.calls(), 0); + } + + #[tokio::test] + async fn should_wallet_drain_retry_after_prebuild_load_failure() { + let (wallet_manager, wallet_id, _, _) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let (manager, persistence) = asset_lock_manager_over( + wallet_manager, + wallet_id, + Arc::new(CountingOkBroadcaster::default()), + ); + let operation = Arc::new(super::AssetLockOperationContext::new([83; 16])); + persistence + .fail_load + .store(true, std::sync::atomic::Ordering::SeqCst); + { + let _registration = manager.register_wallet_drain_operation(operation.clone()); + assert!(matches!( + manager.begin_wallet_drain(&operation).await, + Err(PlatformWalletError::Persistence(_)) + )); + } + assert_eq!( + operation.snapshot().state, + super::AssetLockOperationState::Pending + ); + persistence + .fail_load + .store(false, std::sync::atomic::Ordering::SeqCst); + assert_eq!( + manager + .wallet_drain_operation_status([83; 16]) + .await + .unwrap() + .state, + super::AssetLockOperationState::NotSent + ); + // Both a fresh context for the same UUID and a new explicit request + // can proceed once durable loading works again. + for id in [[83; 16], [84; 16]] { + let retry = Arc::new(super::AssetLockOperationContext::new(id)); + let _registration = manager.register_wallet_drain_operation(retry.clone()); + assert_eq!(manager.begin_wallet_drain(&retry).await.unwrap(), None); + } + assert_eq!(manager.broadcaster.calls(), 0); + assert!(manager.list_tracked_locks().await.is_empty()); + } + + #[tokio::test] + async fn should_wallet_drain_pool_standard_families_without_change() { + let (wallet_manager, wallet_id, _, signer) = + funded_wallet_manager_dual_standard(&[700_000, 900_000], &[800_000]).await; + let (manager, persistence) = asset_lock_manager_over( + wallet_manager, + wallet_id, + Arc::new(CountingOkBroadcaster::default()), + ); + let operation = Arc::new(super::AssetLockOperationContext::new([11; 16])); + let _registration = manager.register_wallet_drain_operation(operation.clone()); + assert_eq!( + manager + .wallet_drain_operation_status([11; 16]) + .await + .unwrap() + .state, + super::AssetLockOperationState::Pending + ); + assert_eq!(manager.begin_wallet_drain(&operation).await.unwrap(), None); + let (_, outpoint) = manager + .broadcast_funded_asset_lock_with_operation( + super::AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: Some(1), + }, + &crate::ASSET_LOCK_FUNDING_SOURCES, + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + Some(&operation), + ) + .await + .unwrap(); + let locks = manager.list_tracked_locks().await; + let lock = locks + .iter() + .find(|lock| lock.out_point == outpoint) + .unwrap(); + assert_eq!(lock.transaction.input.len(), 3); + assert_eq!( + lock.transaction.output.len(), + 1, + "only the burn output, no change" + ); + assert!(lock.transaction.output[0].script_pubkey.is_op_return()); + assert_eq!(lock.transaction.output[0].value, lock.amount); + assert!(lock.amount < 2_400_000); + assert_eq!(lock.operation_id, Some([11; 16])); + assert!( + persistence + .flushes + .load(std::sync::atomic::Ordering::SeqCst) + >= 2 + ); + assert_eq!(operation.snapshot().out_point, Some(outpoint)); + // UUID replay resolves the existing lock, and a fresh UUID cannot + // bypass pending state even if the host lost its local marker. + let replay = Arc::new(super::AssetLockOperationContext::new([11; 16])); + let _replay_registration = manager.register_wallet_drain_operation(replay.clone()); + assert_eq!( + manager.begin_wallet_drain(&replay).await.unwrap(), + Some(outpoint) + ); + let second = Arc::new(super::AssetLockOperationContext::new([12; 16])); + let _second_registration = manager.register_wallet_drain_operation(second.clone()); + assert!(manager.begin_wallet_drain(&second).await.is_err()); + assert_eq!( + second.snapshot().state, + super::AssetLockOperationState::NotSent + ); + assert_eq!(manager.broadcaster.calls(), 1); + // Generic consumption cannot manufacture a proof-gated drain result. + assert!(manager.consume_asset_lock(&outpoint).await.is_err()); + assert_eq!( + manager + .wallet_drain_operation_status([11; 16]) + .await + .unwrap() + .state, + super::AssetLockOperationState::Pending + ); + } + + #[tokio::test] + async fn should_wallet_drain_include_dashpay_receiving_funds() { + let (wallet_manager, wallet_id, _, signer, _) = + funded_wallet_manager_with_contact(&[700_000], &[800_000]).await; + let (manager, _) = asset_lock_manager_over( + wallet_manager, + wallet_id, + Arc::new(CountingOkBroadcaster::default()), + ); + let operation = super::AssetLockOperationContext::new([21; 16]); + let (_, outpoint) = manager + .broadcast_funded_asset_lock_with_operation( + super::AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: Some(1), + }, + &crate::ASSET_LOCK_FUNDING_SOURCES, + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + Some(&operation), + ) + .await + .unwrap(); + let locks = manager.list_tracked_locks().await; + let lock = locks + .iter() + .find(|lock| lock.out_point == outpoint) + .unwrap(); + assert_eq!(lock.transaction.input.len(), 2); + assert_eq!(lock.transaction.output.len(), 1); + assert!(lock.amount > 1_490_000 && lock.amount < 1_500_000); + } + + #[tokio::test] + async fn should_wallet_drain_refuse_101_inputs_before_dispatch_and_allow_100() { + for count in [101, 100] { + let (wallet_manager, wallet_id, _, signer) = + crate::test_support::funded_wallet_manager_with_outputs( + StandardAccountType::BIP44Account, + &vec![10_000; count], + ) + .await; + let (manager, _) = asset_lock_manager_over( + wallet_manager, + wallet_id, + Arc::new(CountingOkBroadcaster::default()), + ); + let operation = super::AssetLockOperationContext::new([31; 16]); + let result = manager + .broadcast_funded_asset_lock_with_operation( + super::AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: Some(1), + }, + &crate::ASSET_LOCK_FUNDING_SOURCES, + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + Some(&operation), + ) + .await; + if count == 101 { + assert!(matches!( + result, + Err(PlatformWalletError::AssetLockTooManyInputs { + count: 101, + max: 100 + }) + )); + assert_eq!(manager.broadcaster.calls(), 0); + assert!(manager.list_tracked_locks().await.is_empty()); + } else { + assert!(result.is_ok(), "{result:?}"); + assert_eq!( + manager.list_tracked_locks().await[0] + .transaction + .input + .len(), + 100 + ); + } + } + } + + /// Stops signing while the builder owns the manager lock, allowing a + /// competing writer to queue before the durability checkpoint. + #[cfg(feature = "shielded")] + struct PausedDrainSigner { + inner: WalletSigner, + resume: Notify, + } + + #[cfg(feature = "shielded")] + #[async_trait] + impl key_wallet::signer::Signer for PausedDrainSigner { + type Error = String; + + fn supported_methods(&self) -> &[key_wallet::signer::SignerMethod] { + self.inner.supported_methods() + } + + async fn sign_ecdsa( + &self, + path: &key_wallet::bip32::DerivationPath, + sighash: [u8; 32], + ) -> Result< + ( + dashcore::secp256k1::ecdsa::Signature, + dashcore::secp256k1::PublicKey, + ), + String, + > { + let signature = self.inner.sign_ecdsa(path, sighash).await?; + self.resume.notified().await; + Ok(signature) + } + + async fn public_key( + &self, + path: &key_wallet::bip32::DerivationPath, + ) -> Result { + self.inner.public_key(path).await + } + } + + #[cfg(feature = "shielded")] + #[async_trait] + impl key_wallet::signer::ExtendedPubKeySigner for PausedDrainSigner { + async fn extended_public_key( + &self, + path: &key_wallet::bip32::DerivationPath, + ) -> Result { + self.inner.extended_public_key(path).await + } + } + + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_checkpoint_drain_before_post_build_cancellation() { + let (wallet_manager, wallet_id, _, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let (manager, persistence) = asset_lock_manager_over( + wallet_manager.clone(), + wallet_id, + Arc::new(CountingOkBroadcaster::default()), + ); + let signer = PausedDrainSigner { + inner: signer, + resume: Notify::new(), + }; + let operation = Arc::new(super::AssetLockOperationContext::new([42; 16])); + let registration = manager.register_wallet_drain_operation(operation.clone()); + manager.begin_wallet_drain(&operation).await.unwrap(); + let mut future = Box::pin(manager.broadcast_funded_asset_lock_with_operation( + super::AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: Some(1), + }, + &crate::ASSET_LOCK_FUNDING_SOURCES, + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + Some(&operation), + )); + assert!(futures::poll!(&mut future).is_pending()); + let mut writer = Box::pin(wallet_manager.write()); + assert!(futures::poll!(&mut writer).is_pending()); + signer.resume.notify_one(); + // The queued writer blocks tracking, but cannot interrupt persistence. + assert!(futures::poll!(&mut future).is_pending()); + let blocker = writer.await; + let outpoint = operation + .snapshot() + .out_point + .expect("checkpointed outpoint"); + { + let stored = persistence.stored.lock().unwrap(); + assert!( + stored + .iter() + .any(|cs| cs.asset_locks.as_ref().is_some_and(|locks| locks + .asset_locks + .get(&outpoint) + .is_some_and(|lock| lock.operation_id == Some([42; 16])))), + "the first post-build suspension must have a durable recovery row" + ); + assert!( + stored + .iter() + .any(|cs| cs.account_address_pools.iter().any(|pool| pool + .addresses + .iter() + .any(|address| matches!( + address.state, + key_wallet::managed_account::address_pool::AddressState::Used + )))), + "the burned funding key must be persisted too" + ); + } + assert_eq!( + persistence + .flushes + .load(std::sync::atomic::Ordering::SeqCst), + 2 + ); + assert_eq!(manager.broadcaster.calls(), 0); + drop(future); + drop(registration); + drop(blocker); + assert_eq!( + operation.snapshot().state, + super::AssetLockOperationState::Pending + ); + assert_eq!(operation.snapshot().out_point, Some(outpoint)); + assert!(manager.list_tracked_locks().await.is_empty()); + persistence + .restore_stored_locks + .store(true, std::sync::atomic::Ordering::SeqCst); + let retry = Arc::new(super::AssetLockOperationContext::new([42; 16])); + let _registration = manager.register_wallet_drain_operation(retry.clone()); + assert_eq!( + manager.begin_wallet_drain(&retry).await.unwrap(), + Some(outpoint) + ); + assert_eq!( + manager.tracked_resume_metadata(&outpoint).await.unwrap().0, + AssetLockFundingType::AssetLockShieldedAddressTopUp + ); + let result = manager + .resume_asset_lock(&outpoint, Some(Duration::ZERO)) + .await; + assert!( + !matches!(result, Err(PlatformWalletError::AssetLockNotTracked(_))), + "{result:?}" + ); + assert_eq!( + manager.broadcaster.calls(), + 1, + "resume dispatches the original checkpointed transaction" + ); + let locks = manager.list_tracked_locks().await; + assert_eq!(locks.len(), 1); + assert_eq!(locks[0].out_point, outpoint); + assert_eq!(locks[0].status, AssetLockStatus::Broadcast); + // Retrying hydration must not roll the live Broadcast row back to Built. + assert_eq!( + manager.begin_wallet_drain(&retry).await.unwrap(), + Some(outpoint) + ); + assert_eq!( + manager.list_tracked_locks().await[0].status, + AssetLockStatus::Broadcast + ); + } + + #[tokio::test] + async fn should_wallet_drain_pool_store_failure_release_inputs_for_retry() { + assert_wallet_drain_pool_failure_can_retry(true).await; + } + + #[tokio::test] + async fn should_wallet_drain_pool_flush_failure_release_inputs_for_retry() { + assert_wallet_drain_pool_failure_can_retry(false).await; + } + + async fn assert_wallet_drain_pool_failure_can_retry(fail_store: bool) { + let (wallet_manager, wallet_id, _, signer) = + funded_wallet_manager_dual_standard(&[700_000, 900_000], &[800_000]).await; + let persistence = Arc::new(CapturingPersistence { + fail_next_pool_store: std::sync::atomic::AtomicBool::new(fail_store), + fail_on_flush: (!fail_store).then_some(1), + ..CapturingPersistence::for_wallet(&wallet_manager, wallet_id) + }); + let broadcaster = Arc::new(CountingOkBroadcaster::default()); + let manager = AssetLockManager::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + wallet_manager, + wallet_id, + Arc::new(Notify::new()), + broadcaster.clone(), + WalletPersister::new(wallet_id, persistence.clone()), + ); + let operation = Arc::new(super::AssetLockOperationContext::new([41; 16])); + let registration = manager.register_wallet_drain_operation(operation.clone()); + manager.begin_wallet_drain(&operation).await.unwrap(); + let result = manager + .broadcast_funded_asset_lock_with_operation( + super::AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: Some(1), + }, + &crate::ASSET_LOCK_FUNDING_SOURCES, + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + Some(&operation), + ) + .await; + assert!(matches!(result, Err(PlatformWalletError::Persistence(_)))); + drop(registration); + assert_eq!(broadcaster.calls(), 0); + assert!(manager.list_tracked_locks().await.is_empty()); + assert_eq!(operation.snapshot().out_point, None); + assert_eq!( + operation.snapshot().state, + super::AssetLockOperationState::NotSent + ); + assert_eq!( + manager + .wallet_drain_operation_status([41; 16]) + .await + .unwrap(), + operation.snapshot() + ); + + // Retry immediately with the same operation ID and all three original + // inputs, including the second account family. No TTL sweep is needed. + let retry = Arc::new(super::AssetLockOperationContext::new([41; 16])); + let _registration = manager.register_wallet_drain_operation(retry.clone()); + assert_eq!(manager.begin_wallet_drain(&retry).await.unwrap(), None); + manager + .broadcast_funded_asset_lock_with_operation( + super::AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: Some(1), + }, + &crate::ASSET_LOCK_FUNDING_SOURCES, + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + Some(&retry), + ) + .await + .unwrap(); + assert_eq!(broadcaster.calls(), 1); + let locks = manager.list_tracked_locks().await; + assert_eq!(locks.len(), 1); + assert_eq!(locks[0].transaction.input.len(), 3); + // The failed build consumed a key: successful retry must persist both + // used indices rather than rolling the funding pool backwards. + assert!(persistence.stored.lock().unwrap().iter().any(|cs| { + cs.account_address_pools.iter().any(|pool| { + matches!( + pool.account_type, + key_wallet::account::AccountType::AssetLockShieldedAddressTopUp + ) && pool + .addresses + .iter() + .filter(|address| { + matches!( + address.state, + key_wallet::managed_account::address_pool::AddressState::Used + ) + }) + .count() + == 2 + }) + })); + } + + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_publish_not_sent_before_cancelled_rejection_cleanup() { + let (wallet_manager, wallet_id, _, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let (manager, persistence) = asset_lock_manager_over( + wallet_manager.clone(), + wallet_id, + Arc::new(AlwaysRejectedBroadcaster), + ); + let blocked_manager = Arc::new(Mutex::new(None)); + let blocker_slot = blocked_manager.clone(); + *persistence.before_remove.lock().unwrap() = Some(Box::new(move || { + // Untracking is complete; delay only reservation cleanup. + *blocker_slot.lock().unwrap() = Some(wallet_manager.clone().try_write_owned().unwrap()); + })); + let operation = Arc::new(super::AssetLockOperationContext::new([43; 16])); + let registration = manager.register_wallet_drain_operation(operation.clone()); + manager.begin_wallet_drain(&operation).await.unwrap(); + let mut future = Box::pin(manager.broadcast_funded_asset_lock_with_operation( + super::AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: Some(1), + }, + &crate::ASSET_LOCK_FUNDING_SOURCES, + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + Some(&operation), + )); + assert!(futures::poll!(&mut future).is_pending()); + assert_eq!(persistence.removed_outpoints().len(), 1); + assert_eq!( + persistence + .flushes + .load(std::sync::atomic::Ordering::SeqCst), + 3 + ); + drop(future); + drop(registration); + blocked_manager.lock().unwrap().take(); + assert_eq!( + operation.snapshot().state, + super::AssetLockOperationState::NotSent + ); + assert!(operation.snapshot().out_point.is_none()); + assert_eq!( + manager + .wallet_drain_operation_status([43; 16]) + .await + .unwrap(), + operation.snapshot() + ); + // Neither the same UUID nor another UUID is stranded by context_pending. + for id in [[43; 16], [44; 16]] { + let retry = Arc::new(super::AssetLockOperationContext::new(id)); + let _registration = manager.register_wallet_drain_operation(retry.clone()); + assert_eq!(manager.begin_wallet_drain(&retry).await.unwrap(), None); + } + } + + #[tokio::test] + async fn should_wallet_drain_definite_rejection_clear_only_after_durable_cleanup() { + let (wallet_manager, wallet_id, _, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let (manager, persistence) = asset_lock_manager_over( + wallet_manager, + wallet_id, + Arc::new(AlwaysRejectedBroadcaster), + ); + let operation = Arc::new(super::AssetLockOperationContext::new([51; 16])); + let _registration = manager.register_wallet_drain_operation(operation.clone()); + manager.begin_wallet_drain(&operation).await.unwrap(); + let result = manager + .broadcast_funded_asset_lock_with_operation( + super::AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: Some(1), + }, + &crate::ASSET_LOCK_FUNDING_SOURCES, + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + Some(&operation), + ) + .await; + assert!(matches!( + result, + Err(PlatformWalletError::TransactionBroadcast(_)) + )); + assert!(manager.list_tracked_locks().await.is_empty()); + assert_eq!(persistence.removed_outpoints().len(), 1); + assert_eq!( + persistence + .flushes + .load(std::sync::atomic::Ordering::SeqCst), + 3 + ); + assert_eq!( + operation.snapshot().state, + super::AssetLockOperationState::NotSent + ); + assert_eq!( + manager + .wallet_drain_operation_status([51; 16]) + .await + .unwrap() + .state, + super::AssetLockOperationState::NotSent + ); + let second = Arc::new(super::AssetLockOperationContext::new([52; 16])); + let _second_registration = manager.register_wallet_drain_operation(second.clone()); + assert_eq!(manager.begin_wallet_drain(&second).await.unwrap(), None); + } + + #[tokio::test] + async fn should_release_rejected_drain_only_when_removal_committed() { + for fail_remove_store in [false, true] { + { + let commits_inline = true; + let (wallet_manager, wallet_id, _, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let persistence = Arc::new(CapturingPersistence { + commits_inline, + fail_remove_store, + fail_on_flush: Some(3), + restore_stored_locks: std::sync::atomic::AtomicBool::new(true), + ..CapturingPersistence::for_wallet(&wallet_manager, wallet_id) + }); + let manager = AssetLockManager::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + wallet_manager, + wallet_id, + Arc::new(Notify::new()), + Arc::new(AlwaysRejectedBroadcaster), + WalletPersister::new(wallet_id, persistence.clone()), + ); + let operation = Arc::new(super::AssetLockOperationContext::new([53; 16])); + let registration = manager.register_wallet_drain_operation(operation.clone()); + manager.begin_wallet_drain(&operation).await.unwrap(); + let result = manager + .broadcast_funded_asset_lock_with_operation( + super::AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: Some(1), + }, + &crate::ASSET_LOCK_FUNDING_SOURCES, + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + Some(&operation), + ) + .await; + let released = commits_inline && !fail_remove_store; + assert_eq!( + matches!(result, Err(PlatformWalletError::TransactionBroadcast(_))), + released, + "inline={commits_inline}, store failure={fail_remove_store}: {result:?}" + ); + if !released { + assert!(matches!(result, Err(PlatformWalletError::Persistence(_)))); + } + assert!(manager.list_tracked_locks().await.is_empty()); + let restored = persistence.load().unwrap(); + let persisted_count: usize = restored.wallets[&wallet_id] + .unused_asset_locks + .values() + .map(|locks| locks.len()) + .sum(); + assert_eq!(persisted_count, usize::from(!released)); + assert_eq!(operation.snapshot().out_point.is_none(), released); + drop(registration); + assert_eq!( + manager + .wallet_drain_operation_status([53; 16]) + .await + .unwrap() + .state, + if released { + super::AssetLockOperationState::NotSent + } else { + super::AssetLockOperationState::Pending + } + ); + if released { + for id in [[53; 16], [54; 16]] { + let retry = Arc::new(super::AssetLockOperationContext::new(id)); + let _registration = manager.register_wallet_drain_operation(retry.clone()); + assert_eq!(manager.begin_wallet_drain(&retry).await.unwrap(), None); + } + } else { + let other = Arc::new(super::AssetLockOperationContext::new([54; 16])); + assert!(manager.begin_wallet_drain(&other).await.is_err()); + } + // Test actual input selection too: clearing only the operation + // snapshot would still leave the non-expiring spend fence. + let rebuild = manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await; + assert_eq!(rebuild.is_ok(), released, "{rebuild:?}"); + } + } + } + + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_retry_rolled_back_drain_checkpoint_without_rebuilding() { + { + let (wallet_manager, wallet_id, _, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let persistence = Arc::new(CapturingPersistence { + commits_inline: true, + fail_lock_store: std::sync::atomic::AtomicBool::new(true), + restore_stored_locks: std::sync::atomic::AtomicBool::new(true), + ..CapturingPersistence::for_wallet(&wallet_manager, wallet_id) + }); + let broadcaster = Arc::new(CountingOkBroadcaster::default()); + let manager = AssetLockManager::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + wallet_manager, + wallet_id, + Arc::new(Notify::new()), + broadcaster.clone(), + WalletPersister::new(wallet_id, persistence.clone()), + ); + let operation = Arc::new(super::AssetLockOperationContext::new([56; 16])); + let registration = manager.register_wallet_drain_operation(operation.clone()); + manager.begin_wallet_drain(&operation).await.unwrap(); + let result = manager + .broadcast_funded_asset_lock_with_operation( + super::AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: Some(1), + }, + &crate::ASSET_LOCK_FUNDING_SOURCES, + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + Some(&operation), + ) + .await; + assert!(matches!(result, Err(PlatformWalletError::Persistence(_)))); + let point = operation.snapshot().out_point.unwrap(); + let original_tx = persistence.failed_lock_stores.lock().unwrap()[0] + .asset_locks + .as_ref() + .unwrap() + .asset_locks[&point] + .transaction + .clone(); + drop(registration); + drop(operation); + assert!(persistence.load().unwrap().wallets[&wallet_id] + .unused_asset_locks + .is_empty()); + assert!(manager.list_tracked_locks().await.is_empty()); + assert_eq!(broadcaster.calls(), 0); + assert_eq!( + manager + .wallet_drain_operation_status([56; 16]) + .await + .unwrap() + .state, + super::AssetLockOperationState::Pending + ); + let other = Arc::new(super::AssetLockOperationContext::new([57; 16])); + let other_registration = manager.register_wallet_drain_operation(other.clone()); + assert!(manager.begin_wallet_drain(&other).await.is_err()); + drop(other_registration); + // No ordinary spend or generic resume may race the unpublished row. + assert!(manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await + .is_err()); + assert!(matches!( + manager + .resume_asset_lock(&point, Some(Duration::ZERO)) + .await, + Err(PlatformWalletError::AssetLockNotTracked(_)) + )); + assert_eq!(broadcaster.calls(), 0); + + // A fresh caller/context for the same UUID finds the retained + // original checkpoint. Another rollback must retain it again. + let retry = Arc::new(super::AssetLockOperationContext::new([56; 16])); + let _registration = manager.register_wallet_drain_operation(retry.clone()); + assert!(matches!( + manager.begin_wallet_drain(&retry).await, + Err(PlatformWalletError::Persistence(_)) + )); + assert_eq!(persistence.failed_lock_stores.lock().unwrap().len(), 2); + assert!(manager.list_tracked_locks().await.is_empty()); + persistence + .fail_lock_store + .store(false, std::sync::atomic::Ordering::SeqCst); + assert_eq!( + manager.begin_wallet_drain(&retry).await.unwrap(), + Some(point) + ); + let locks = manager.list_tracked_locks().await; + assert_eq!(locks.len(), 1); + assert_eq!(locks[0].transaction, original_tx); + assert_eq!(locks[0].out_point, point); + let restored = persistence.load().unwrap(); + let durable = restored.wallets[&wallet_id] + .unused_asset_locks + .values() + .find_map(|locks| locks.get(&point)) + .unwrap(); + assert_eq!(durable.transaction, original_tx); + assert_eq!( + broadcaster.calls(), + 0, + "checkpoint retry must not broadcast" + ); + let _ = manager + .resume_asset_lock(&point, Some(Duration::ZERO)) + .await; + assert_eq!( + broadcaster.calls(), + 1, + "resume reuses the original fenced inputs" + ); + assert_eq!( + manager.list_tracked_locks().await[0].status, + AssetLockStatus::Broadcast + ); + assert_eq!( + persistence + .stored + .lock() + .unwrap() + .iter() + .filter(|cs| !cs.account_address_pools.is_empty()) + .count(), + 1, + "retry must not allocate a second funding key or rebuild" + ); + } + } + + #[tokio::test] + async fn should_wallet_drain_failed_row_flush_never_publish_resumable_lock() { + let (wallet_manager, wallet_id, _, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let persistence = Arc::new(CapturingPersistence { + fail_on_flush: Some(2), + restore_stored_locks: std::sync::atomic::AtomicBool::new(true), + ..CapturingPersistence::for_wallet(&wallet_manager, wallet_id) + }); + let broadcaster = Arc::new(CountingOkBroadcaster::default()); + let manager = AssetLockManager::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + wallet_manager, + wallet_id, + Arc::new(Notify::new()), + broadcaster.clone(), + WalletPersister::new(wallet_id, persistence.clone()), + ); + let operation = Arc::new(super::AssetLockOperationContext::new([61; 16])); + let _registration = manager.register_wallet_drain_operation(operation.clone()); + manager.begin_wallet_drain(&operation).await.unwrap(); + let result = manager + .broadcast_funded_asset_lock_with_operation( + super::AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: Some(1), + }, + &crate::ASSET_LOCK_FUNDING_SOURCES, + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + Some(&operation), + ) + .await; + assert!(matches!(result, Err(PlatformWalletError::Persistence(_)))); + assert_eq!(broadcaster.calls(), 0); + assert!(manager.list_tracked_locks().await.is_empty()); + assert_eq!( + persistence + .flushes + .load(std::sync::atomic::Ordering::SeqCst), + 2 + ); + let point = operation.snapshot().out_point.unwrap(); + assert!(persistence + .stored + .lock() + .unwrap() + .iter() + .filter_map(|cs| cs.asset_locks.as_ref()) + .any(|cs| cs + .asset_locks + .get(&point) + .is_some_and(|lock| lock.operation_id == Some([61; 16])))); + assert_eq!( + manager + .wallet_drain_operation_status([61; 16]) + .await + .unwrap() + .state, + super::AssetLockOperationState::Pending + ); + // On an inline-committing backend the row store already committed; + // the failed flush notification only kept it unpublished. Explicit + // retry re-establishes that exact row before tracking it. + assert!(persistence.load().unwrap().wallets[&wallet_id] + .unused_asset_locks + .values() + .any(|locks| locks.contains_key(&point))); + assert_eq!( + manager.begin_wallet_drain(&operation).await.unwrap(), + Some(point) + ); + assert_eq!(manager.list_tracked_locks().await[0].out_point, point); + assert!(persistence.load().unwrap().wallets[&wallet_id] + .unused_asset_locks + .values() + .any(|locks| locks.contains_key(&point))); + assert_eq!(broadcaster.calls(), 0); + } + + struct DrainPanicBroadcaster { + persistence: Arc, + } + + #[async_trait] + impl TransactionBroadcaster for DrainPanicBroadcaster { + async fn broadcast(&self, _transaction: &Transaction) -> Result { + assert!( + self.persistence + .flushes + .load(std::sync::atomic::Ordering::SeqCst) + >= 2, + "row and pool state must already be flushed at dispatch" + ); + panic!("simulated panic after dispatch may have started") + } + } + + #[tokio::test] + async fn should_wallet_drain_panic_keep_exact_outpoint_and_pending_status() { + let (wallet_manager, wallet_id, _, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let persistence = Arc::new(CapturingPersistence::for_wallet(&wallet_manager, wallet_id)); + let manager = Arc::new(AssetLockManager::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + wallet_manager, + wallet_id, + Arc::new(Notify::new()), + Arc::new(DrainPanicBroadcaster { + persistence: persistence.clone(), + }), + WalletPersister::new(wallet_id, persistence), + )); + let operation = Arc::new(super::AssetLockOperationContext::new([71; 16])); + let _registration = manager.register_wallet_drain_operation(operation.clone()); + manager.begin_wallet_drain(&operation).await.unwrap(); + let task_manager = manager.clone(); + let task_operation = operation.clone(); + let task = tokio::spawn(async move { + task_manager + .broadcast_funded_asset_lock_with_operation( + super::AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: Some(1), + }, + &crate::ASSET_LOCK_FUNDING_SOURCES, + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + Some(&task_operation), + ) + .await + }); + assert!(task.await.unwrap_err().is_panic()); + let snapshot = operation.snapshot(); + assert_eq!(snapshot.state, super::AssetLockOperationState::Pending); + assert_eq!( + Some(manager.list_tracked_locks().await[0].out_point), + snapshot.out_point + ); + assert_eq!( + manager + .wallet_drain_operation_status([71; 16]) + .await + .unwrap(), + snapshot + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs index bd3c06f8637..52f473021a9 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs @@ -20,7 +20,7 @@ use super::tracked::TrackedAssetLock; use key_wallet_manager::WalletManager; /// Default fee rate in duffs per kilobyte for asset lock transactions. -pub(super) const DEFAULT_FEE_PER_KB: u64 = 1000; +pub(crate) const DEFAULT_FEE_PER_KB: u64 = 1000; /// Manages the full asset lock lifecycle: build, broadcast, proof, and tracking. /// @@ -128,6 +128,8 @@ pub struct AssetLockManager { /// foreground, reconnect) from stacking up one retry per attempt: a /// second deferral while the first is still waiting is a no-op. Held /// only for set arithmetic, never across an await. + pub(super) drain_operations: + Arc>>>, pub(super) deferred_resumes: Arc>>, /// Finds the block a funding transaction was mined in when its wallet /// record carries no height, so the ChainLock-proof wait can resolve @@ -166,6 +168,7 @@ impl AssetLockManager { persister, build_persist_serial: Arc::new(tokio::sync::Mutex::new(())), resume_dispatch_claims: Arc::new(std::sync::Mutex::new(BTreeMap::new())), + drain_operations: Arc::new(std::sync::Mutex::new(BTreeMap::new())), deferred_resumes: Arc::new(std::sync::Mutex::new(BTreeSet::new())), mined_height_locator: Arc::new(NoMinedHeightLocator), #[cfg(test)] @@ -201,6 +204,7 @@ impl AssetLockManager { persister: self.persister.clone(), build_persist_serial: Arc::clone(&self.build_persist_serial), resume_dispatch_claims: Arc::clone(&self.resume_dispatch_claims), + drain_operations: Arc::clone(&self.drain_operations), deferred_resumes: Arc::clone(&self.deferred_resumes), mined_height_locator: Arc::clone(&self.mined_height_locator), #[cfg(test)] diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs index 9cbc65944ff..58f75ddb1a3 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs @@ -4,6 +4,11 @@ //! Platform consumption. Shared across sub-wallets via `Arc`. mod build; +mod operation; +pub use operation::{ + AssetLockOperationContext, AssetLockOperationRegistration, AssetLockOperationSnapshot, + AssetLockOperationState, +}; pub mod lock_notify_handler; pub mod manager; pub mod orchestration; @@ -12,3 +17,20 @@ pub mod tracked; pub use lock_notify_handler::LockNotifyHandler; pub use orchestration::AssetLockFunding; + +/// The asset-lock input cap this protocol version enforces +/// (`max_asset_lock_transaction_inputs`, 100 since v3). Dash Core's relay +/// policy refuses the same bound (`MAX_STANDARD_ASSET_LOCK_INPUTS`, +/// `assetlocktx-too-many-inputs`), and peers drop such a lock silently, so a +/// wallet must refuse it before anything reaches the wire. Tighter than +/// key-wallet's standard-transaction input limit. +pub(crate) fn max_asset_lock_inputs(version: &dpp::version::PlatformVersion) -> usize { + usize::from( + version + .dpp + .state_transitions + .identities + .asset_locks + .max_asset_lock_transaction_inputs, + ) +} diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs new file mode 100644 index 00000000000..09947ba0782 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs @@ -0,0 +1,540 @@ +//! Exact, restart-safe association of a host drain request with its asset lock. + +use std::collections::BTreeMap; +#[cfg(any(feature = "shielded", test))] +use std::collections::BTreeSet; +#[cfg(any(feature = "shielded", test))] +use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use dashcore::OutPoint; + +use crate::broadcaster::TransactionBroadcaster; +use crate::changeset::PersistenceCapabilities; +#[cfg(any(feature = "shielded", test))] +use crate::changeset::{AssetLockChangeSet, PlatformWalletChangeSet}; +use crate::error::PlatformWalletError; + +use super::manager::AssetLockManager; +use super::tracked::{AssetLockStatus, TrackedAssetLock}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AssetLockOperationState { + NotSent, + Pending, + Completed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AssetLockOperationSnapshot { + pub state: AssetLockOperationState, + pub out_point: Option, +} + +/// Held by the caller across a caught panic as well as by the wallet while an +/// operation is unresolved. Pending is deliberately the initial state: absence +/// of an observed return is never evidence that no transaction was dispatched. +#[derive(Debug)] +pub struct AssetLockOperationContext { + pub operation_id: [u8; 16], + #[cfg(any(feature = "shielded", test))] + started: AtomicBool, + registrations: AtomicUsize, + snapshot: Mutex, + // An unpublished Built row cannot enter the live manager: generic resume + // could dispatch it before durability. Retain it here so explicit retries + // can repeat the checkpoint after a rolled-back or uncertain write. + checkpoint: Mutex>, +} + +impl AssetLockOperationContext { + pub fn new(operation_id: [u8; 16]) -> Self { + Self { + operation_id, + #[cfg(any(feature = "shielded", test))] + started: AtomicBool::new(false), + registrations: AtomicUsize::new(0), + snapshot: Mutex::new(AssetLockOperationSnapshot { + state: AssetLockOperationState::Pending, + out_point: None, + }), + checkpoint: Mutex::new(None), + } + } + + pub fn snapshot(&self) -> AssetLockOperationSnapshot { + *self + .snapshot + .lock() + .unwrap_or_else(|error| error.into_inner()) + } + + pub(crate) fn set(&self, state: AssetLockOperationState, out_point: Option) { + *self + .snapshot + .lock() + .unwrap_or_else(|error| error.into_inner()) = + AssetLockOperationSnapshot { state, out_point }; + } + + pub(super) fn retain_checkpoint(&self, lock: TrackedAssetLock) { + *self.checkpoint.lock().unwrap_or_else(|e| e.into_inner()) = Some(lock); + } + + pub(super) fn clear_checkpoint(&self) { + self.checkpoint + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take(); + } +} + +/// Keeps a queued or running drain visible until its invocation finishes. +/// Dropping the last registration releases a pre-build operation, including +/// on cancellation or an early error. Once an outpoint may be durable, its +/// context remains registered until durable recovery establishes its outcome. +#[must_use = "hold the registration until the drain worker and outcome snapshot finish"] +#[derive(Debug)] +pub struct AssetLockOperationRegistration { + operations: Arc>>>, + operation: Arc, +} + +impl Drop for AssetLockOperationRegistration { + fn drop(&mut self) { + let mut operations = self + .operations + .lock() + .unwrap_or_else(|error| error.into_inner()); + let last = self.operation.registrations.fetch_sub(1, Ordering::SeqCst) == 1; + let snapshot = self.operation.snapshot(); + if last + && (snapshot.out_point.is_none() + || snapshot.state == AssetLockOperationState::Completed) + && operations + .get(&self.operation.operation_id) + .is_some_and(|current| Arc::ptr_eq(current, &self.operation)) + { + operations.remove(&self.operation.operation_id); + } + // Do not change the caller's snapshot: only a later successful durable + // read can resolve an uncertain pre-build return to NotSent. + } +} + +fn snapshot_for_lock(lock: &TrackedAssetLock) -> AssetLockOperationSnapshot { + AssetLockOperationSnapshot { + state: if lock.status == AssetLockStatus::DrainCompleted { + AssetLockOperationState::Completed + } else { + AssetLockOperationState::Pending + }, + out_point: Some(lock.out_point), + } +} + +impl AssetLockManager { + /// Keep queued/active callers' leases, but do not retain terminal contexts + /// for the manager's lifetime. Durable DrainCompleted remains authoritative. + fn publish_completed_operation(&self, operation_id: [u8; 16], out_point: OutPoint) { + let mut operations = self + .drain_operations + .lock() + .unwrap_or_else(|e| e.into_inner()); + if let Some(context) = operations.get(&operation_id) { + context.set(AssetLockOperationState::Completed, Some(out_point)); + context.clear_checkpoint(); + if context.registrations.load(Ordering::SeqCst) == 0 { + operations.remove(&operation_id); + } + } + } + + /// Durable drain-completion receipt, then live publication. Called once + /// Platform has executed this drain's Type18, or rejected a resumed Type18 + /// because this exact outpoint was already consumed by an earlier attempt. + /// Durable BEFORE visible: a failed store or flush leaves the live row and + /// the context Pending, and the next resume re-derives completion from + /// Platform's verdict. Idempotent on an already completed row. + #[cfg(any(feature = "shielded", test))] + pub(crate) async fn complete_wallet_drain( + &self, + out_point: OutPoint, + operation_id: [u8; 16], + ) -> Result<(), PlatformWalletError> { + self.require_wallet_drain_persistence()?; + { + let mut manager = self.wallet_manager.write().await; + let lock = manager + .get_wallet_info_mut(&self.wallet_id) + .and_then(|info| info.tracked_asset_locks.get_mut(&out_point)) + .filter(|lock| lock.operation_id == Some(operation_id)) + .ok_or(PlatformWalletError::AssetLockNotTracked(out_point))?; + if lock.status != AssetLockStatus::DrainCompleted { + let mut candidate = lock.clone(); + candidate.status = AssetLockStatus::DrainCompleted; + candidate.proof = None; + let mut changes = AssetLockChangeSet::default(); + changes.asset_locks.insert(out_point, (&candidate).into()); + self.persister + .store(PlatformWalletChangeSet { + asset_locks: Some(changes), + ..Default::default() + }) + .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; + // `require_wallet_drain_persistence` admits inline-committing + // backends only, so the row is already durable: a failed flush + // notification must not suppress its live publication. + if let Err(error) = self.persister.flush() { + tracing::warn!(%out_point, %error, "drain completion committed inline; flush notification failed"); + } + lock.status = AssetLockStatus::DrainCompleted; + lock.proof = None; + } + } + self.publish_completed_operation(operation_id, out_point); + self.resume_dispatch_claims + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&out_point); + Ok(()) + } + + #[cfg(any(feature = "shielded", test))] + fn persist_drain_lock_snapshot( + &self, + lock: &TrackedAssetLock, + ) -> Result<(), PlatformWalletError> { + let mut changes = AssetLockChangeSet::default(); + changes.asset_locks.insert(lock.out_point, lock.into()); + self.persister + .store(PlatformWalletChangeSet { + asset_locks: Some(changes), + ..Default::default() + }) + .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; + self.persister + .flush() + .map_err(|e| PlatformWalletError::Persistence(e.to_string())) + } + + /// A drain needs the `WALLET_DRAIN` capabilities **and** a backend whose + /// successful `store` is already durable. Every drain write (checkpoint, + /// rejection removal, completion) treats "store returned Ok" as "committed" + /// and publishes live state on that basis; a buffered backend could instead + /// retain a changeset whose flush failed and commit it through a later, + /// unrelated flush, leaving live and durable state disagreeing with no + /// caller left to reconcile them. The shipped FFI and SQLite persisters + /// commit inline; buffered backends are refused before any Core funds move. + pub(crate) fn require_wallet_drain_persistence(&self) -> Result<(), PlatformWalletError> { + let capabilities = self.persister.persistence_capabilities(); + let required = PersistenceCapabilities::WALLET_DRAIN; + if !capabilities.contains(required) { + return Err(PlatformWalletError::Persistence(format!( + "wallet drain requires persistence capabilities {:?}", + capabilities.missing(required).names() + ))); + } + if !self.persister.store_commits_inline() { + return Err(PlatformWalletError::Persistence( + "wallet drain requires a persistence backend that commits each store inline; \ + buffered backends are not supported" + .into(), + )); + } + Ok(()) + } + + /// Only a successful complete durable read can establish NotSent. A live + /// context wins over an absent row, including during build and failed flush. + pub async fn wallet_drain_operation_status( + &self, + operation_id: [u8; 16], + ) -> Result { + self.wallet_drain_operation_status_inner(operation_id, None) + .await + } + + async fn wallet_drain_operation_status_inner( + &self, + operation_id: [u8; 16], + ignore_context: Option<&Arc>, + ) -> Result { + self.require_wallet_drain_persistence()?; + let persisted = self.persister.load().map_err(|error| { + PlatformWalletError::Persistence(format!("cannot read drain operation: {error}")) + })?; + // A successful global load may omit a wallet whose metadata/accounts + // could not be restored. That is not evidence that its operation never + // reached the network; require the requested wallet's snapshot too. + let persisted_wallet = persisted.wallets.get(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::Persistence( + "cannot read drain operation: wallet missing from durable snapshot".into(), + ) + })?; + let durable = persisted_wallet + .unused_asset_locks + .values() + .flat_map(|locks| locks.values()) + .find(|lock| lock.operation_id == Some(operation_id)) + .map(snapshot_for_lock); + let live = { + let manager = self.wallet_manager.read().await; + let info = manager + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + info.tracked_asset_locks + .values() + .find(|lock| lock.operation_id == Some(operation_id)) + .map(snapshot_for_lock) + }; + let context = self + .drain_operations + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(&operation_id) + .filter(|context| !ignore_context.is_some_and(|ignored| Arc::ptr_eq(context, ignored))) + .map(|context| context.snapshot()); + // Terminal evidence always wins, including a generic exact-outpoint + // resume completing while the original context still says Pending. + for snapshot in [live, durable, context].into_iter().flatten() { + if snapshot.state == AssetLockOperationState::Completed { + if let Some(out_point) = snapshot.out_point { + self.publish_completed_operation(operation_id, out_point); + } + return Ok(snapshot); + } + } + Ok(live + .or(durable) + .or(context) + .unwrap_or(AssetLockOperationSnapshot { + state: AssetLockOperationState::NotSent, + out_point: None, + })) + } + + /// Register before queueing a worker, making status fail closed while + /// the operation has not reached its first asynchronous poll yet. + pub fn register_wallet_drain_operation( + &self, + operation: Arc, + ) -> AssetLockOperationRegistration { + let mut operations = self + .drain_operations + .lock() + .unwrap_or_else(|error| error.into_inner()); + let current = operations.entry(operation.operation_id).or_insert_with(|| { + #[cfg(any(feature = "shielded", test))] + operation.started.store(false, Ordering::SeqCst); + Arc::clone(&operation) + }); + // A same-UUID contender leases the existing owner's visibility too. + // Otherwise the owner could exit while the contender is still queued, + // leaving status falsely NotSent until that contender starts polling. + current.registrations.fetch_add(1, Ordering::SeqCst); + AssetLockOperationRegistration { + operations: Arc::clone(&self.drain_operations), + operation: Arc::clone(current), + } + } + + /// Retry an unpublished checkpoint or repair the gap between durability + /// and live tracking. Status remains read-only; only an explicit retry + /// can make the exact operation's missing row durable and resumable. + #[cfg(any(feature = "shielded", test))] + async fn hydrate_wallet_drain( + &self, + operation_id: [u8; 16], + out_point: OutPoint, + ) -> Result<(), PlatformWalletError> { + let _checkpoint_guard = self.build_persist_serial.lock().await; + let context = self + .drain_operations + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&operation_id) + .cloned(); + { + let manager = self.wallet_manager.read().await; + if manager + .get_wallet_info(&self.wallet_id) + .and_then(|info| info.tracked_asset_locks.get(&out_point)) + .is_some_and(|lock| lock.operation_id == Some(operation_id)) + { + if let Some(context) = &context { + context.clear_checkpoint(); + } + return Ok(()); + } + } + let persisted = self.persister.load().map_err(|error| { + PlatformWalletError::Persistence(format!("cannot restore checkpointed drain: {error}")) + })?; + let wallet = persisted.wallets.get(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::Persistence( + "cannot restore checkpointed drain: wallet missing from durable snapshot".into(), + ) + })?; + let durable = wallet + .unused_asset_locks + .values() + .find_map(|locks| locks.get(&out_point)); + let candidate = if let Some(durable) = durable { + if durable.operation_id != Some(operation_id) { + return Err(PlatformWalletError::Persistence( + "checkpointed drain conflicts with the durable operation UUID".into(), + )); + } + durable.clone() + } else { + let candidate = context + .as_ref() + .and_then(|context| { + context + .checkpoint + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + }) + .filter(|lock| { + lock.out_point == out_point && lock.operation_id == Some(operation_id) + }) + .ok_or(PlatformWalletError::AssetLockNotTracked(out_point))?; + // No replacement transaction or funding key is created. Keep the + // candidate and spend fence if this retry also fails/cancels. + self.persist_drain_lock_snapshot(&candidate)?; + candidate + }; + let mut manager = self.wallet_manager.write().await; + let info = manager + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + // A live advance wins over the loaded snapshot. In particular, never + // replace a newer proof or consumption state. + let current = info + .tracked_asset_locks + .entry(out_point) + .or_insert(candidate); + if current.operation_id != Some(operation_id) { + return Err(PlatformWalletError::Persistence( + "checkpointed drain conflicts with the live operation UUID".into(), + )); + } + if let Some(context) = &context { + context.clear_checkpoint(); + } + Ok(()) + } + + /// Called under the wallet's shield single-flight guard. Reusing a UUID + /// can resume its own lock, but can never create a second one. + #[cfg(any(feature = "shielded", test))] + pub(crate) async fn begin_wallet_drain( + &self, + operation: &Arc, + ) -> Result, PlatformWalletError> { + let first_attempt = !operation.started.swap(true, Ordering::SeqCst); + let previous = self + .wallet_drain_operation_status_inner( + operation.operation_id, + first_attempt.then_some(operation), + ) + .await?; + match previous.state { + AssetLockOperationState::Completed => { + operation.set(previous.state, previous.out_point); + return Err(PlatformWalletError::AssetLockAlreadyConsumed( + previous.out_point.ok_or_else(|| { + PlatformWalletError::Persistence( + "completed drain has no asset-lock outpoint".into(), + ) + })?, + )); + } + AssetLockOperationState::Pending => { + operation.set(previous.state, previous.out_point); + let out_point = previous.out_point.ok_or_else(|| { + PlatformWalletError::TransactionBroadcastUnconfirmed( + "drain operation remains unresolved; no new asset lock was built".into(), + ) + })?; + self.hydrate_wallet_drain(operation.operation_id, out_point) + .await?; + return Ok(Some(out_point)); + } + AssetLockOperationState::NotSent => {} + } + // A concurrent invocation may have registered the same UUID first and + // just resolved to NotSent. Its guard still owns the slot until drop; + // an unregistered contender must retry instead of building invisibly. + if !self + .drain_operations + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(&operation.operation_id) + .is_some_and(|current| Arc::ptr_eq(current, operation)) + { + return Err(PlatformWalletError::TransactionBroadcastUnconfirmed( + "previous drain invocation is still settling; retry after its outcome resolves" + .into(), + )); + } + // A second UUID is not a bypass around an unresolved drain. This + // check also covers another SDK caller or a host that lost its marker. + let persisted = self.persister.load().map_err(|error| { + PlatformWalletError::Persistence(format!("cannot read pending drains: {error}")) + })?; + let persisted_wallet = persisted.wallets.get(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::Persistence( + "cannot read pending drains: wallet missing from durable snapshot".into(), + ) + })?; + let durable_pending = persisted_wallet + .unused_asset_locks + .values() + .flat_map(|locks| locks.values()) + .any(|lock| { + lock.operation_id.is_some() && lock.status != AssetLockStatus::DrainCompleted + }); + let live_locks = self.list_tracked_locks().await; + let live_pending = live_locks.iter().any(|lock| { + lock.operation_id.is_some() && lock.status != AssetLockStatus::DrainCompleted + }); + let completed = live_locks + .iter() + .chain( + persisted + .wallets + .get(&self.wallet_id) + .into_iter() + .flat_map(|wallet| wallet.unused_asset_locks.values()) + .flat_map(|locks| locks.values()), + ) + .filter(|lock| lock.status == AssetLockStatus::DrainCompleted) + .filter_map(|lock| lock.operation_id) + .collect::>(); + let context_pending = self + .drain_operations + .lock() + .unwrap_or_else(|error| error.into_inner()) + .values() + .any(|context| { + !Arc::ptr_eq(context, operation) + && !completed.contains(&context.operation_id) + && context.snapshot().state == AssetLockOperationState::Pending + }); + if durable_pending || live_pending || context_pending { + // This UUID did not dispatch anything. Preserve the existing + // operation, whose own status/outpoint must be resolved first. + operation.set(AssetLockOperationState::NotSent, None); + return Err(PlatformWalletError::AssetLockTransaction( + "another wallet drain is pending; resume that transfer before starting a new one" + .into(), + )); + } + operation.set(AssetLockOperationState::Pending, None); + Ok(None) + } +} 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 005264ee0b3..96fe2dba014 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -29,6 +29,7 @@ //! every flow so register / top-up / address-fund can't drift apart //! on their CL fallback or retry-budget windows. +use std::sync::Arc; use std::time::Duration; use dashcore::OutPoint; @@ -43,6 +44,7 @@ use crate::error::{ as_asset_lock_proof_cl_height_too_low, is_asset_lock_already_consumed, PlatformWalletError, }; use crate::wallet::asset_lock::manager::AssetLockManager; +use crate::wallet::asset_lock::AssetLockOperationContext; // --------------------------------------------------------------------------- // Timeout policy @@ -192,6 +194,14 @@ pub enum AssetLockFunding { account_index: u32, }, + /// Drain all ordinary pooled funding sources without creating change. + /// The operation context preserves the exact outpoint on errors and panics. + DrainWalletBalance { + account_index: u32, + minimum_lock_duffs: Option, + operation: Arc, + }, + /// Build an asset lock that drains a funding account's whole balance: /// every final UTXO of `account` is consumed and the lock value is /// `Σ inputs − fee`, computed by the key-wallet builder. @@ -633,6 +643,40 @@ impl AssetLockManager { Err(e) => Err(e), } } + AssetLockFunding::DrainWalletBalance { + account_index, + minimum_lock_duffs, + operation, + } => { + if funding_type != AssetLockFundingType::AssetLockShieldedAddressTopUp { + return Err(PlatformWalletError::AssetLockTransaction( + "pooled wallet drain is available only for shielded funding".into(), + )); + } + match self + .create_wallet_drain_asset_lock_proof( + account_index, + minimum_lock_duffs, + funding_type, + destination_index, + asset_lock_signer, + &operation, + ) + .await + { + Ok((proof, path, out_point)) => { + Ok(FundingResolution::Resolved(ResolvedFunding { + proof, + path, + tracked_out_point: Some(out_point), + })) + } + Err(PlatformWalletError::FinalityTimeout(out_point)) => { + Ok(FundingResolution::IsTimeout { out_point }) + } + Err(error) => Err(error), + } + } AssetLockFunding::DrainAccountBalance { account, minimum_lock_duffs, @@ -670,7 +714,7 @@ impl AssetLockManager { .tracked_resume_metadata(&out_point) .await .ok_or(PlatformWalletError::AssetLockNotTracked(out_point))?; - if status == crate::wallet::asset_lock::tracked::AssetLockStatus::Consumed { + if status.is_consumed() { return Err(PlatformWalletError::AssetLockAlreadyConsumed(out_point)); } // Invitation vouchers are bearer instruments: the credit @@ -1102,6 +1146,7 @@ mod tests { .insert( out_point, TrackedAssetLock { + operation_id: None, out_point, transaction: transaction.clone(), account_index: 0, diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs index e7d4cdbc2c2..1c0afaf3cdb 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs @@ -2012,6 +2012,7 @@ mod chain_lock_wait_tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, out_point, transaction, account_index: 0, @@ -2117,6 +2118,7 @@ mod chain_lock_wait_tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, out_point, transaction, account_index: 0, diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs index 6736299079d..5cf0b4defc1 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs @@ -231,6 +231,7 @@ fn reconstruct_candidates( } let (status, proof) = recovered_status(record, out_point); Some(TrackedAssetLock { + operation_id: None, out_point, transaction: record.transaction.clone(), account_index, @@ -764,6 +765,7 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, out_point, transaction: tx.clone(), account_index: 0, @@ -999,6 +1001,7 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, out_point, transaction: tx.clone(), account_index: 0, diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index ec725024579..aae24b771cf 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -105,6 +105,7 @@ impl AssetLockManager { return; } let lock = TrackedAssetLock { + operation_id: None, out_point, transaction: tx, account_index, @@ -977,6 +978,14 @@ impl AssetLockManager { account_index = lock.account_index, "resume_asset_lock: lock looked up" ); + // A drain's lock is settled under the drain persistence contract + // whichever path resumes it (same-UUID resume, the generic + // exact-outpoint resume behind "Finish now", a readiness-deferred + // retry), so the admission policy applies here, before any + // dispatch or lifecycle write. Untagged legacy locks are unaffected. + if lock.operation_id.is_some() { + self.require_wallet_drain_persistence()?; + } // Only the two proof-less statuses are candidates. A lock // carrying an IS/Chain proof, a `RecoveredFromChain` entry // (reconstructed from a record the chain itself accepted), and @@ -991,7 +1000,8 @@ impl AssetLockManager { AssetLockStatus::InstantSendLocked | AssetLockStatus::ChainLocked | AssetLockStatus::RecoveredFromChain - | AssetLockStatus::Consumed => None, + | AssetLockStatus::Consumed + | AssetLockStatus::DrainCompleted => None, }; // Claim the dispatch window for the two statuses that can still // send, while the read guard that produced this snapshot is @@ -1070,7 +1080,8 @@ impl AssetLockManager { AssetLockStatus::InstantSendLocked | AssetLockStatus::ChainLocked | AssetLockStatus::RecoveredFromChain - | AssetLockStatus::Consumed => None, + | AssetLockStatus::Consumed + | AssetLockStatus::DrainCompleted => None, }; if status == AssetLockStatus::Built && local_finality.is_some() { dispatch_claim @@ -1741,7 +1752,7 @@ impl AssetLockManager { } } } - (AssetLockStatus::Consumed, _) => { + (AssetLockStatus::Consumed | AssetLockStatus::DrainCompleted, _) => { // Terminal tombstone — the asset lock was already // burned by a successful identity registration / top-up. // Retaining this state makes the typed distinction from @@ -2171,6 +2182,7 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, out_point, transaction: transaction.clone(), account_index: 0, @@ -2306,6 +2318,7 @@ mod tests { .insert( out_point, TrackedAssetLock { + operation_id: None, out_point, transaction, account_index: 0, @@ -2529,6 +2542,7 @@ mod tests { }; let out_point = OutPoint::new(tx.txid(), 0); let lock = TrackedAssetLock { + operation_id: None, out_point, transaction: tx, account_index: 0, @@ -2805,6 +2819,7 @@ mod tests { info.tracked_asset_locks.insert( self.out_point, TrackedAssetLock { + operation_id: None, out_point: self.out_point, transaction: self.transaction.clone(), account_index: 0, @@ -4315,6 +4330,7 @@ mod tests { .insert( out_point, TrackedAssetLock { + operation_id: None, out_point, transaction, account_index: 0, @@ -4828,6 +4844,7 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, out_point, transaction, account_index: 0, @@ -4951,6 +4968,7 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, out_point, transaction, account_index: 0, @@ -5453,6 +5471,7 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, out_point, transaction, account_index: 0, @@ -5557,6 +5576,7 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, out_point, transaction, account_index: 0, @@ -5835,6 +5855,7 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, out_point, transaction, account_index: 0, @@ -5979,6 +6000,7 @@ mod tests { .insert( out_point, TrackedAssetLock { + operation_id: None, out_point, transaction, account_index: 0, 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 55ca496183e..e97ae38bb77 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 @@ -121,6 +121,15 @@ impl AssetLockManager { .map(|lock| (lock.funding_type, lock.identity_index, lock.status.clone())) } + /// The wallet-drain operation a tracked lock belongs to, if any. + #[cfg(any(feature = "shielded", test))] + pub(crate) async fn tracked_operation_id(&self, out_point: &OutPoint) -> Option<[u8; 16]> { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| info.tracked_asset_locks.get(out_point)) + .and_then(|lock| lock.operation_id) + } + /// Track a new asset lock in memory, returning a changeset describing /// the inserted entry. /// @@ -268,7 +277,12 @@ impl AssetLockManager { .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; let mut cs = AssetLockChangeSet::default(); match info.tracked_asset_locks.get_mut(out_point) { - Some(entry) if entry.status != AssetLockStatus::Consumed => { + Some(entry) if entry.operation_id.is_some() => { + return Err(PlatformWalletError::Persistence( + "wallet drains require authenticated completion through drain reconciliation".into(), + )); + } + Some(entry) if !entry.status.is_consumed() => { entry.status = AssetLockStatus::Consumed; entry.proof = None; // one-shot — never relevant after consumption cs.asset_locks.insert(*out_point, (&*entry).into()); @@ -441,7 +455,7 @@ impl AssetLockManager { out_point )) })?; - if entry.status == AssetLockStatus::Consumed { + if entry.status.is_consumed() { tracing::warn!( outpoint = %out_point, attempted = ?new_status, @@ -450,6 +464,14 @@ impl AssetLockManager { ); return Err(PlatformWalletError::AssetLockAlreadyConsumed(*out_point)); } + if new_status == AssetLockStatus::DrainCompleted + || (entry.operation_id.is_some() && new_status == AssetLockStatus::Consumed) + { + return Err(PlatformWalletError::Persistence( + "wallet drains require authenticated completion through drain reconciliation" + .into(), + )); + } if !expected(&entry.status) { tracing::info!( outpoint = %out_point, @@ -503,9 +525,17 @@ impl AssetLockManager { out_point )) })?; - if entry.status == AssetLockStatus::Consumed { + if entry.status.is_consumed() { return Err(PlatformWalletError::AssetLockAlreadyConsumed(*out_point)); } + if new_status == AssetLockStatus::DrainCompleted + || (entry.operation_id.is_some() && new_status == AssetLockStatus::Consumed) + { + return Err(PlatformWalletError::Persistence( + "wallet drains require authenticated completion through drain reconciliation" + .into(), + )); + } if !expected(&entry.status) { return Ok(None); } 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 4bef519bd80..8fcf3f482f1 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs @@ -34,13 +34,12 @@ use crate::changeset::AssetLockEntry; /// /// # Schema coupling /// -/// Variants of this enum are persisted as TEXT in the -/// `platform-wallet-storage` SQLite schema (column `asset_locks.status` -/// — see `migrations/V001__initial.rs`). Any change to this enum -/// (added or renamed variant) MUST also update: -/// - `sqlite::schema::asset_locks::status_str` (writer mapping) -/// - `sqlite::schema::asset_locks::ASSET_LOCK_STATUS_LABELS` -/// (`CHECK` constraint domain) +/// The authoritative SQLite `lifecycle_blob` stores this enum; never reorder +/// existing variants. The `asset_locks.status` TEXT column is a query projection +/// (see `migrations/V001__initial.rs`). `DrainCompleted` projects to `consumed` +/// while its distinct appended blob discriminant retains the proof receipt. +/// Changes MUST update `sqlite::schema::asset_locks::status_str` and its parity +/// test; a new SQL label also requires `ASSET_LOCK_STATUS_LABELS` and a migration. /// Verified by the `asset_lock_status_labels_match_enum` unit test in /// that same module. #[derive(Debug, Clone, PartialEq)] @@ -108,12 +107,28 @@ pub enum AssetLockStatus { /// Platform is the arbiter and rejects an already-spent outpoint /// with a typed error. RecoveredFromChain, + /// A wallet drain whose Type18 Platform executed, or whose resumed Type18 + /// Platform rejected because this exact outpoint was already consumed by + /// an earlier attempt. Written only by `complete_wallet_drain`; terminal + /// and never regressed. Unlike generic `Consumed`, this is a durable + /// drain-completion receipt. + DrainCompleted, +} + +impl AssetLockStatus { + /// Terminal for generic asset-lock lifecycle and coin-selection purposes. + /// Drain success requires `DrainCompleted`, not generic `Consumed`. + pub fn is_consumed(&self) -> bool { + matches!(self, Self::Consumed | Self::DrainCompleted) + } } /// A tracked asset lock. Private keys are NOT stored here — they're /// re-derived from funding_type + identity_index via key-wallet's Wallet. #[derive(Debug, Clone)] pub struct TrackedAssetLock { + /// Stable host operation identifier, retained through consumption and restart. + pub operation_id: Option<[u8; 16]>, /// The outpoint identifying this credit output (txid + vout). pub out_point: OutPoint, pub transaction: Transaction, @@ -135,6 +150,7 @@ pub struct TrackedAssetLock { impl From<&TrackedAssetLock> for AssetLockEntry { fn from(lock: &TrackedAssetLock) -> Self { Self { + operation_id: lock.operation_id, out_point: lock.out_point, transaction: lock.transaction.clone(), account_index: lock.account_index, diff --git a/packages/rs-platform-wallet/src/wallet/core/mod.rs b/packages/rs-platform-wallet/src/wallet/core/mod.rs index e03363948f2..39d1ae9123c 100644 --- a/packages/rs-platform-wallet/src/wallet/core/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/core/mod.rs @@ -15,5 +15,7 @@ pub use generation::WalletGeneration; pub(crate) use generation::{InBroadcastFences, InBroadcastPin}; pub use spend_observer::SpendObservationHandler; pub(crate) use transaction::resolve_source_accounts; +#[cfg(feature = "shielded")] +pub(crate) use transaction::resolved_funding_accounts; pub use transaction::{SignedCoreTransaction, ASSET_LOCK_FUNDING_SOURCES, SEND_FUNDING_SOURCES}; pub use wallet::CoreWallet; 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 1738c5c3592..63cd36a4a65 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 @@ -667,6 +667,11 @@ fn enforce_asset_lock_amount_covers_address_funding_floor( minimum_lock_duffs.map_or(required, |floor| floor.max(required)), ), }), + AssetLockFunding::DrainWalletBalance { .. } => { + Err(PlatformWalletError::AssetLockTransaction( + "pooled wallet drain is available only for shielded funding".into(), + )) + } funding @ AssetLockFunding::FromExistingAssetLock { .. } => Ok(funding), } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs index 0e32a275559..30a459ce40f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::task::Poll; @@ -16,11 +17,17 @@ use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; use drive_proof_verifier::types::{ShieldedEncryptedNotes, ShieldedEncryptedNotesQuery}; fn tree_path() -> PathBuf { + // Clock resolution alone does not distinguish parallel test invocations. + static NEXT_PATH: AtomicU64 = AtomicU64::new(0); + let sequence = NEXT_PATH.fetch_add(1, Ordering::Relaxed); let nonce = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock") .as_nanos(); - std::env::temp_dir().join(format!("shielded_local_balance_{nonce}.sqlite")) + std::env::temp_dir().join(format!( + "shielded_local_balance_{}_{nonce}_{sequence}.sqlite", + std::process::id() + )) } fn coordinator(path: &std::path::Path) -> NetworkShieldedCoordinator { 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 fb7e036a786..92cbd26b73e 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,14 +19,20 @@ //! `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. **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. +//! 4. **Finalize lock state** — a verified submit completes a wallet drain +//! (`DrainCompleted`) or consumes a generic lock. A resumed drain that +//! Platform rejects because its exact outpoint was already consumed is +//! completed too: only this wallet can spend the lock, so an earlier +//! attempt landed. For a generic lock 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; +use dashcore::OutPoint; use dpp::address_funds::{OrchardAddress, PlatformAddress}; +use dpp::asset_lock::StoredAssetLockInfo; use dpp::balances::credits::CREDITS_PER_DUFF; use dpp::fee::Credits; use dpp::prelude::AssetLockProof; @@ -36,10 +42,13 @@ use dpp::state_transition::proof_result::StateTransitionProofResult; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use crate::wallet::asset_lock::tracked::TrackedAssetLock; +use crate::wallet::asset_lock::AssetLockOperationState; use std::time::Duration; -use crate::error::is_instant_lock_proof_invalid; +use crate::broadcaster::TransactionBroadcaster; +use crate::error::{is_asset_lock_already_consumed, is_instant_lock_proof_invalid}; +use crate::wallet::asset_lock::manager::AssetLockManager; use crate::wallet::asset_lock::orchestration::{ out_point_from_proof, submit_with_cl_height_retry, AssetLockFunding, FundingResolution, ResolvedFunding, @@ -65,6 +74,41 @@ pub(crate) fn shield_from_asset_lock_num_actions(dummy_outputs: usize) -> usize (1 + dummy_outputs).max(2) } +impl AssetLockManager { + /// Drain-aware form of [`Self::reconcile_asset_lock_submit_result`]. + /// + /// A drain's lock is single-use and only this wallet holds its key, so + /// Platform's exact-outpoint "already consumed" verdict on an + /// operation-tagged lock means an earlier Type18 for the same lock landed: + /// the drain is completed and `Ok(None)` is returned (nothing landed in + /// THIS call, so the caller records no history; the scan surfaces the + /// note). Every other outcome, and every lock without an operation, + /// defers to the generic reconciliation unchanged. + async fn settle_asset_lock_submit_result( + &self, + result: Result, + out_point: &OutPoint, + effective_proof: &AssetLockProof, + chain_lock_timeout: Option, + drain_operation_id: Option<[u8; 16]>, + ) -> Result, PlatformWalletError> { + if let (Err(error), Some(operation_id)) = (&result, drain_operation_id) { + if is_asset_lock_already_consumed(error, out_point) { + self.complete_wallet_drain(*out_point, operation_id).await?; + return Ok(None); + } + } + self.reconcile_asset_lock_submit_result( + result, + out_point, + effective_proof, + chain_lock_timeout, + ) + .await + .map(Some) + } +} + impl PlatformWallet { /// Fund the shielded pool from a Core L1 asset lock, with the /// asset-lock proof signed by an external @@ -141,6 +185,71 @@ impl PlatformWallet { settings: Option, cl_wait: Option, ) -> Result<(), PlatformWalletError> + where + AS: ::key_wallet::signer::ExtendedPubKeySigner + Send + Sync, + P: OrchardProver, + { + // The context is retained by the FFI caller across panic unwinding. + // Register under the same guard as every shield-class operation, so a + // reused UUID never races a fresh build or a generic resume. + let operation = match &funding { + AssetLockFunding::DrainWalletBalance { operation, .. } => Some(operation.clone()), + _ => None, + }; + let _operation_registration = operation + .as_ref() + .map(|operation| self.register_wallet_drain_operation(operation.clone())); + let _shield_guard = self.shield_guard.lock().await; + let funding = if let Some(operation) = &operation { + match self.asset_locks.begin_wallet_drain(operation).await? { + Some(out_point) => AssetLockFunding::FromExistingAssetLock { + out_point, + consume_invitation_voucher: false, + }, + None => funding, + } + } else { + funding + }; + let result = self + .shielded_fund_from_asset_lock_inner( + coordinator, + funding, + recipients, + asset_lock_signer, + prover, + surplus_output, + dummy_outputs, + settings, + cl_wait, + ) + .await; + if let Some(operation) = operation { + let snapshot = operation.snapshot(); + if result.is_ok() { + operation.set(AssetLockOperationState::Completed, snapshot.out_point); + } else if snapshot.out_point.is_none() { + // Every possibly durable or dispatched lock records its + // outpoint first. A returned pre-build error proves no send. + operation.set(AssetLockOperationState::NotSent, None); + } + } + result + } + + #[allow(clippy::too_many_arguments)] + async fn shielded_fund_from_asset_lock_inner( + &self, + coordinator: &std::sync::Arc, + funding: AssetLockFunding, + recipients: Vec<(OrchardAddress, Option)>, + asset_lock_signer: &AS, + prover: P, + surplus_output: Option, + dummy_outputs: usize, + settings: Option, + cl_wait: Option, + ) -> Result<(), PlatformWalletError> where AS: ::key_wallet::signer::ExtendedPubKeySigner + Send + Sync, P: OrchardProver, @@ -210,14 +319,38 @@ impl PlatformWallet { minimum_lock_duffs: Some(minimum_lock_duffs), } } + AssetLockFunding::DrainWalletBalance { + account_index, + operation, + .. + } => { + // Live history recording and the recoverable `sender_ovk` + // ciphertext need bound viewing keys, and Core funds must not + // be locked into a pool this wallet cannot see. Refuse before + // Core funding while shield_guard serializes this operation + // with key binding and removal. + if self + .shielded_keys + .read() + .await + .as_ref() + .is_none_or(|keys| keys.is_empty()) + { + return Err(PlatformWalletError::ShieldedBuildError( + "wallet drain requires bound shielded viewing keys before Core funding" + .into(), + )); + } + let pool_fee_credits = self.shield_from_asset_lock_pool_fee(num_actions)?; + AssetLockFunding::DrainWalletBalance { + account_index, + operation, + minimum_lock_duffs: Some(pool_fee_credits / CREDITS_PER_DUFF + 1), + } + } other => other, }; - // Single-flight: serialise shield-class operations on this - // wallet so two concurrent calls can't race the asset-lock - // tracker into a half-consumed state. - let _shield_guard = self.shield_guard.lock().await; - // Step 2: resolve funding. `AssetLockShieldedAddressTopUp` // selects the BIP44 funding family dedicated to shielded // top-ups (`accounts.asset_lock_shielded_address_topup` — @@ -374,7 +507,7 @@ impl PlatformWallet { // the live activity recorder below. Each retry re-randomizes the // bundle, so only the landed attempt's actions are the ones a // later scan will recover; `build_and_broadcast_shielded` returns - // them on success. + // them on success, with the proof's verdict on the lock. let (submit_result, effective_proof) = match submit_with_cl_height_retry(settings, |s| { build_and_broadcast_shielded( sdk.clone(), @@ -392,7 +525,7 @@ impl PlatformWallet { }) .await { - Ok(actions) => (Ok(actions), proof.clone()), + Ok(landed) => (Ok(landed), proof.clone()), Err(e) if is_instant_lock_proof_invalid(&e) => { let out_point = proof_out_point; tracing::warn!( @@ -444,15 +577,49 @@ impl PlatformWallet { .await; } - let landed_actions: Vec = self + // Platform's verdict settles the lock. For a wallet drain, an + // exact-outpoint "already consumed" rejection means an earlier attempt + // for the same lock landed: the drain completes with no landed bundle + // to record (the scan surfaces the note). + let drain_operation_id = self .asset_locks - .reconcile_asset_lock_submit_result( + .tracked_operation_id(&proof_out_point) + .await; + let Some(landed) = self + .asset_locks + .settle_asset_lock_submit_result( submit_result, &proof_out_point, &effective_proof, cl_wait, + drain_operation_id, ) - .await?; + .await? + else { + tracing::info!( + outpoint = %proof_out_point, + "wallet drain completed: Platform reports its asset lock already consumed by an earlier attempt" + ); + return Ok(()); + }; + + // A drain completes only when the verified proof attests the lock + // was consumed in full. Checked before any history is written: a + // drain left pending must not carry a Confirmed row for a bundle a + // later resume may replace. The next resume completes it from + // Platform's verdict. + if drain_operation_id.is_some() && !landed.fully_consumed { + tracing::warn!( + outpoint = %proof_out_point, + "wallet drain left pending: the landed shield's proof does not attest \ + full consumption of its asset lock" + ); + return Err(PlatformWalletError::TransactionBroadcastUnconfirmed( + "drain shield landed but its proof does not attest full consumption of \ + the asset lock; the operation stays pending" + .into(), + )); + } // Record a live `ShieldFromAssetLock` activity entry over the // landed bundle. One entry per call (= one per seed-pool batch), @@ -461,41 +628,50 @@ impl PlatformWallet { // by construction). Recorded Confirmed directly — `broadcast_and_ // _wait` already proved inclusion. Best-effort: a recording miss // (no bound keyset, no recoverable output) just omits the row. - self.record_shield_from_asset_lock_activity(coordinator, &landed_actions, shield_amount) + self.record_shield_from_asset_lock_activity(coordinator, &landed.actions, shield_amount) .await; - // Step 5: cleanup. Consume the tracked asset lock. The - // shielded note itself arrives via the next sync — there's - // no immediate balance changeset to persist (unlike - // address-funding, which writes proof-attested balances back - // into `ManagedPlatformAccount`). - if let Some(out_point) = tracked_out_point { - // Platform DID accept the shield ST — propagating an Err - // here would misreport the protocol outcome. The lock row - // stays non-Consumed and surfaces in the Resumable - // Funding list; a user Resume on it would be - // deterministically rejected by Platform with "lock - // already consumed". Log so it's visible. - if let Err(e) = self.asset_locks.consume_asset_lock(&out_point).await { - match &e { - PlatformWalletError::WalletNotFound(_) => { - tracing::warn!( - outpoint = %out_point, - error = %e, - "consume_asset_lock: wallet handle vanished after successful shielded submit" - ); - } - _ => { - tracing::error!( - outpoint = %out_point, - error = %e, - "consume_asset_lock failed unexpectedly after successful shielded submit; \ - the lock row stays non-Consumed and will surface as Resumable. \ - A user Resume on it will be rejected by Platform with 'lock already consumed'." - ); + // Step 5: finalize the tracked asset lock. The shielded note itself + // arrives via the next sync — there's no immediate balance changeset + // to persist (unlike address-funding, which writes proof-attested + // balances back into `ManagedPlatformAccount`). + match (drain_operation_id, tracked_out_point) { + // A drain writes its durable completion receipt. An error here + // keeps the operation Pending with its outpoint. + (Some(operation_id), _) => { + self.asset_locks + .complete_wallet_drain(proof_out_point, operation_id) + .await?; + } + (None, Some(out_point)) => { + // Platform DID accept the shield ST — propagating an Err + // here would misreport the protocol outcome. The lock row + // stays non-Consumed and surfaces in the Resumable + // Funding list; a user Resume on it would be + // deterministically rejected by Platform with "lock + // already consumed". Log so it's visible. + if let Err(e) = self.asset_locks.consume_asset_lock(&out_point).await { + match &e { + PlatformWalletError::WalletNotFound(_) => { + tracing::warn!( + outpoint = %out_point, + error = %e, + "consume_asset_lock: wallet handle vanished after successful shielded submit" + ); + } + _ => { + tracing::error!( + outpoint = %out_point, + error = %e, + "consume_asset_lock failed unexpectedly after successful shielded submit; \ + the lock row stays non-Consumed and will surface as Resumable. \ + A user Resume on it will be rejected by Platform with 'lock already consumed'." + ); + } } } } + (None, None) => {} } tracing::info!( @@ -581,9 +757,9 @@ impl PlatformWallet { /// that account), falling back to the lowest bound account — mirrors /// the `sender_ovk` selection above. #[cfg(feature = "shielded")] - async fn record_shield_from_asset_lock_activity( + pub(super) async fn record_shield_from_asset_lock_activity( &self, - coordinator: &std::sync::Arc, + coordinator: &crate::wallet::shielded::NetworkShieldedCoordinator, actions: &[dpp::shielded::SerializedAction], shield_amount: Credits, ) { @@ -592,43 +768,47 @@ impl PlatformWallet { }; use crate::wallet::shielded::activity_recorder::{build_pending_entry, with_status}; - let guard = self.shielded_keys.read().await; - let Some(keys_map) = guard.as_ref() else { - return; - }; - // Prefer the account whose keyset actually recognizes a visible - // output in the landed bundle (the funded note decrypts under its - // IVK / recovers under its OVK) — the row must land under THAT - // account or the recorder builds with the wrong keys, recovers no - // cmx, and silently drops the entry; it would also break the - // shared-id natural key against the eventual scan-derived row. - // Fall back to the lowest bound account only when nothing - // matches (a shield to a fully external recipient). - let matched = keys_map.iter().find(|(_, ks)| { - !crate::wallet::shielded::activity_recorder::visible_output_cmxs(actions, ks).is_empty() - }); - let Some((&account, keyset)) = matched.or_else(|| keys_map.iter().next()) else { - return; - }; - - let Some(pending) = build_pending_entry( - keyset, - crate::wallet::shielded::activity_recorder::LiveEntryParams { - kind: ShieldedActivityKind::ShieldFromAssetLock, - direction: ShieldedDirection::In, - amount: shield_amount, - // The flat pool fee is charged on the L1 side (asset-lock - // value − shield_amount); the note value is exactly - // `shield_amount`, so no shielded-pool fee is derivable - // from the bundle here. - fee: None, - counterparty: None, - memo: None, - actions, - spent_notes: &[], - }, - ) else { - return; + let (account, pending) = { + let guard = self.shielded_keys.read().await; + let Some(keys_map) = guard.as_ref() else { + return; + }; + // Prefer the account whose keyset actually recognizes a visible + // output in the landed bundle (the funded note decrypts under its + // IVK / recovers under its OVK) — the row must land under THAT + // account or the recorder builds with the wrong keys, recovers no + // cmx, and silently drops the entry; it would also break the + // shared-id natural key against the eventual scan-derived row. + // Fall back to the lowest bound account only when nothing + // matches (a shield to a fully external recipient). + let matched = keys_map.iter().find(|(_, ks)| { + !crate::wallet::shielded::activity_recorder::visible_output_cmxs(actions, ks) + .is_empty() + }); + let Some((&account, keyset)) = matched.or_else(|| keys_map.iter().next()) else { + return; + }; + + let Some(pending) = build_pending_entry( + keyset, + crate::wallet::shielded::activity_recorder::LiveEntryParams { + kind: ShieldedActivityKind::ShieldFromAssetLock, + direction: ShieldedDirection::In, + amount: shield_amount, + // The flat pool fee is charged on the L1 side (asset-lock + // value − shield_amount); the note value is exactly + // `shield_amount`, so no shielded-pool fee is derivable + // from the bundle here. + fee: None, + counterparty: None, + memo: None, + actions, + spent_notes: &[], + }, + ) else { + return; + }; + (account, pending) }; let confirmed = with_status(&pending, ShieldedActivityStatus::Confirmed, None); @@ -693,16 +873,45 @@ async fn lookup_asset_lock_value_credits( }) } +/// What a landed Type 18 submit returns to the orchestrator. +struct LandedShield { + /// The **landed** bundle's serialized Orchard actions, so the + /// orchestrator can record a live `ShieldFromAssetLock` activity entry + /// over the exact bundle that committed (each retry re-randomizes the + /// bundle, so only the landed attempt's cmxs are the ones a later scan + /// will recover). + actions: Vec, + /// Whether the verified proof result attests the asset lock was + /// consumed in full (see [`proof_attests_full_consumption`]). + fully_consumed: bool, +} + +/// Whether a landed Type 18's verified proof result attests that its asset +/// lock was consumed **in full**. +/// +/// Drive consumes a `ShieldFromAssetLock` lock in full, and the verifier +/// returns the lock's stored state at the committed block, so a drain +/// expects `FullyConsumed`. A `PartiallyConsumed` (or unexpected) result +/// must not write a drain's terminal completion: the operation stays +/// pending with its outpoint instead. +fn proof_attests_full_consumption(result: &StateTransitionProofResult) -> bool { + matches!( + result, + StateTransitionProofResult::VerifiedAssetLockConsumed(StoredAssetLockInfo::FullyConsumed) + | StateTransitionProofResult::VerifiedAssetLockConsumedWithAddressInfos( + StoredAssetLockInfo::FullyConsumed, + _ + ) + ) +} + /// Build the Type 18 transition and broadcast-and-wait. /// /// Extracted so `submit_with_cl_height_retry`'s closure stays compact /// and the IS→CL fallback path can re-call it with the upgraded proof. /// -/// On success returns the **landed** bundle's serialized Orchard actions -/// so the orchestrator can record a live `ShieldFromAssetLock` activity -/// entry over the exact bundle that committed (each retry re-randomizes -/// the bundle, so only the landed attempt's cmxs are the ones a later -/// scan will recover). +/// On success returns the landed bundle's actions together with what the +/// verified proof result attests about the asset lock's consumption. #[allow(clippy::too_many_arguments)] async fn build_and_broadcast_shielded( sdk: std::sync::Arc, @@ -716,7 +925,7 @@ async fn build_and_broadcast_shielded( surplus_output: Option, dummy_outputs: usize, settings: Option, -) -> Result, dash_sdk::Error> +) -> Result where AS: ::key_wallet::signer::Signer, P: OrchardProver, @@ -750,11 +959,16 @@ where // signal. A shield-from-asset-lock proof authenticates the consumed // outpoint (and surplus-address state) at the committed block but // cannot bind this exact Orchard request, so the outcome is an - // affected-state snapshot; a consensus rejection still surfaces as - // an error on this wait. - st.broadcast_and_wait_for_affected_state::(&sdk, settings) + // affected-state snapshot. Only this wallet can spend the lock, so a + // consumed outpoint means one of its own attempts landed; a + // consensus rejection still surfaces as an error on this wait. + let result = st + .broadcast_and_wait_for_affected_state::(&sdk, settings) .await?; - Ok(actions) + Ok(LandedShield { + actions, + fully_consumed: proof_attests_full_consumption(&result), + }) } /// Pre-flight check for the recipient list. @@ -827,6 +1041,7 @@ mod tests { 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::asset_lock::{AssetLockOperationContext, AssetLockOperationRegistration}; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; @@ -836,6 +1051,46 @@ mod tests { // curve-valid `OrchardAddress` (which requires the Orchard // crate's spend-key plumbing) inside this crate. + #[test] + fn drain_completion_requires_a_fully_consumed_lock_in_the_proof_result() { + use dpp::asset_lock::reduced_asset_lock_value::AssetLockValue; + use dpp::platform_value::Bytes32; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + let partial = AssetLockValue::new( + 1_000_000, + vec![0xaa, 0xbb], + 500_000, + vec![Bytes32::new([0x42; 32])], + PlatformVersion::latest(), + ) + .expect("fixture"); + + assert!(proof_attests_full_consumption( + &StateTransitionProofResult::VerifiedAssetLockConsumed( + StoredAssetLockInfo::FullyConsumed + ) + )); + assert!(proof_attests_full_consumption( + &StateTransitionProofResult::VerifiedAssetLockConsumedWithAddressInfos( + StoredAssetLockInfo::FullyConsumed, + BTreeMap::new() + ) + )); + assert!(!proof_attests_full_consumption( + &StateTransitionProofResult::VerifiedAssetLockConsumed( + StoredAssetLockInfo::PartiallyConsumed(partial) + ) + )); + assert!(!proof_attests_full_consumption( + &StateTransitionProofResult::VerifiedAssetLockConsumed(StoredAssetLockInfo::NotPresent) + )); + assert!(!proof_attests_full_consumption( + &StateTransitionProofResult::VerifiedShieldedNullifiers(Vec::new()) + )); + } + #[test] fn validate_rejects_empty_recipients() { let v: Vec<(u8, Option)> = Vec::new(); @@ -884,6 +1139,8 @@ mod tests { fail_next_flush: Mutex>, store_commits_inline: AtomicBool, omit_reconciliation_capabilities: AtomicBool, + /// Attest the full wallet-drain contract instead of bare reconciliation. + wallet_drain: AtomicBool, } impl PlatformWalletPersistence for RecordingPersistence { @@ -894,6 +1151,8 @@ mod tests { fn persistence_capabilities(&self) -> PersistenceCapabilities { if self.omit_reconciliation_capabilities.load(Ordering::SeqCst) { PersistenceCapabilities::NONE + } else if self.wallet_drain.load(Ordering::SeqCst) { + PersistenceCapabilities::WALLET_DRAIN } else { PersistenceCapabilities::ASSET_LOCK_RECONCILIATION } @@ -993,6 +1252,7 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, out_point, transaction, account_index: 0, @@ -1025,6 +1285,304 @@ mod tests { ))) } + const DRAIN_OPERATION_ID: [u8; 16] = [0x33; 16]; + + /// The consumption-report context with its lock tagged as a wallet drain + /// and a registered Pending operation context, as after a dispatched + /// drain whose Type18 outcome is still unknown. + async fn drain_report_context() -> ( + ConsumptionReportContext, + Arc, + AssetLockOperationRegistration, + ) { + let ctx = consumption_report_context().await; + ctx.persistence.wallet_drain.store(true, Ordering::SeqCst); + // Drains admit inline-committing backends only. + ctx.persistence + .store_commits_inline + .store(true, Ordering::SeqCst); + ctx.wallet_manager + .write() + .await + .get_wallet_info_mut(&ctx.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get_mut(&ctx.out_point) + .expect("tracked lock") + .operation_id = Some(DRAIN_OPERATION_ID); + let operation = Arc::new(AssetLockOperationContext::new(DRAIN_OPERATION_ID)); + operation.set(AssetLockOperationState::Pending, Some(ctx.out_point)); + let registration = ctx + .manager + .register_wallet_drain_operation(operation.clone()); + (ctx, operation, registration) + } + + async fn live_lock(ctx: &ConsumptionReportContext) -> TrackedAssetLock { + ctx.wallet_manager + .read() + .await + .get_wallet_info(&ctx.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&ctx.out_point) + .expect("tracked lock") + .clone() + } + + /// Every asset-lock status written for the context's outpoint, in order. + fn persisted_statuses(ctx: &ConsumptionReportContext) -> Vec { + 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()) + .collect() + } + + #[tokio::test] + async fn settle_completes_drain_on_exact_outpoint_already_consumed_report() { + let (ctx, operation, _registration) = drain_report_context().await; + let landed = ctx + .manager + .settle_asset_lock_submit_result::<()>( + Err(already_consumed_error(ctx.out_point)), + &ctx.out_point, + &ctx.proof, + None, + Some(DRAIN_OPERATION_ID), + ) + .await + .expect("an already-consumed drain lock completes the drain"); + assert!(landed.is_none(), "nothing landed in this call"); + let lock = live_lock(&ctx).await; + assert_eq!(lock.status, AssetLockStatus::DrainCompleted); + assert!(lock.proof.is_none()); + // Completion is the only write: no consumption-unknown detour. + assert_eq!( + persisted_statuses(&ctx), + vec![AssetLockStatus::DrainCompleted] + ); + assert_eq!( + operation.snapshot().state, + AssetLockOperationState::Completed + ); + assert_eq!(operation.snapshot().out_point, Some(ctx.out_point)); + } + + #[tokio::test] + async fn settle_defers_to_generic_reconciliation_without_operation_id() { + let ctx = consumption_report_context().await; + let error = ctx + .manager + .settle_asset_lock_submit_result::<()>( + Err(already_consumed_error(ctx.out_point)), + &ctx.out_point, + &ctx.proof, + None, + None, + ) + .await + .expect_err("a generic lock keeps the typed host signal"); + assert!(matches!( + error, + PlatformWalletError::AssetLockAlreadyConsumed(actual) if actual == ctx.out_point + )); + let lock = live_lock(&ctx).await; + assert_eq!(lock.status, AssetLockStatus::RecoveredFromChain); + assert!(matches!(lock.proof, Some(AssetLockProof::Chain(_)))); + } + + #[tokio::test] + async fn settle_leaves_drain_pending_on_foreign_outpoint_or_unrelated_error() { + let (ctx, operation, _registration) = drain_report_context().await; + let foreign = OutPoint::new(ctx.out_point.txid, ctx.out_point.vout + 1); + for error in [ + already_consumed_error(foreign), + dash_sdk::Error::Generic("unrelated".into()), + ] { + let result = ctx + .manager + .settle_asset_lock_submit_result::<()>( + Err(error), + &ctx.out_point, + &ctx.proof, + None, + Some(DRAIN_OPERATION_ID), + ) + .await; + assert!(matches!(result, Err(PlatformWalletError::Sdk(_)))); + let lock = live_lock(&ctx).await; + assert_eq!(lock.status, AssetLockStatus::ChainLocked); + assert_eq!(lock.proof, Some(ctx.proof.clone())); + assert!(persisted_statuses(&ctx).is_empty()); + assert_eq!(operation.snapshot().state, AssetLockOperationState::Pending); + } + } + + #[tokio::test] + async fn settle_passes_a_landed_result_through_for_a_drain() { + let (ctx, operation, _registration) = drain_report_context().await; + let landed = ctx + .manager + .settle_asset_lock_submit_result( + Ok::<_, dash_sdk::Error>(42u64), + &ctx.out_point, + &ctx.proof, + None, + Some(DRAIN_OPERATION_ID), + ) + .await + .expect("a landed submit passes through"); + assert_eq!(landed, Some(42)); + // Completion is the caller's step 5, not the settle's. + assert_eq!(live_lock(&ctx).await.status, AssetLockStatus::ChainLocked); + assert!(persisted_statuses(&ctx).is_empty()); + assert_eq!(operation.snapshot().state, AssetLockOperationState::Pending); + } + + async fn settle_already_consumed( + ctx: &ConsumptionReportContext, + ) -> Result, PlatformWalletError> { + ctx.manager + .settle_asset_lock_submit_result::<()>( + Err(already_consumed_error(ctx.out_point)), + &ctx.out_point, + &ctx.proof, + None, + Some(DRAIN_OPERATION_ID), + ) + .await + } + + #[tokio::test] + async fn settle_keeps_drain_pending_when_completion_persistence_fails() { + let (ctx, operation, _registration) = drain_report_context().await; + let settle = settle_already_consumed; + + ctx.persistence + .fail_next_store + .store(true, Ordering::SeqCst); + assert!(matches!( + settle(&ctx).await, + Err(PlatformWalletError::Persistence(_)) + )); + assert_eq!(live_lock(&ctx).await.status, AssetLockStatus::ChainLocked); + assert!(persisted_statuses(&ctx).is_empty()); + assert_eq!(operation.snapshot().state, AssetLockOperationState::Pending); + + // A buffered backend is refused outright, before any write: its + // retained changeset could otherwise commit through a later flush. + ctx.persistence + .store_commits_inline + .store(false, Ordering::SeqCst); + assert!(matches!( + settle(&ctx).await, + Err(PlatformWalletError::Persistence(message)) if message.contains("inline") + )); + assert_eq!(live_lock(&ctx).await.status, AssetLockStatus::ChainLocked); + assert!(persisted_statuses(&ctx).is_empty()); + assert_eq!(operation.snapshot().state, AssetLockOperationState::Pending); + + // An inline-committing backend already holds the row: a failed flush + // notification must not suppress the live publication. + ctx.persistence + .store_commits_inline + .store(true, Ordering::SeqCst); + *ctx.persistence + .fail_next_flush + .lock() + .expect("recording persistence mutex") = Some(PersistenceErrorKind::Transient); + assert!(settle(&ctx) + .await + .expect("inline commit completes") + .is_none()); + assert_eq!( + live_lock(&ctx).await.status, + AssetLockStatus::DrainCompleted + ); + assert_eq!( + operation.snapshot().state, + AssetLockOperationState::Completed + ); + } + + #[tokio::test] + async fn complete_wallet_drain_is_idempotent_and_refuses_a_foreign_operation_id() { + let (ctx, _operation, _registration) = drain_report_context().await; + assert!(matches!( + ctx.manager + .complete_wallet_drain(ctx.out_point, [0x44; 16]) + .await, + Err(PlatformWalletError::AssetLockNotTracked(_)) + )); + assert_eq!(live_lock(&ctx).await.status, AssetLockStatus::ChainLocked); + ctx.manager + .complete_wallet_drain(ctx.out_point, DRAIN_OPERATION_ID) + .await + .expect("first completion"); + assert_eq!( + persisted_statuses(&ctx), + vec![AssetLockStatus::DrainCompleted] + ); + ctx.manager + .complete_wallet_drain(ctx.out_point, DRAIN_OPERATION_ID) + .await + .expect("second completion is a no-op"); + assert_eq!( + persisted_statuses(&ctx), + vec![AssetLockStatus::DrainCompleted], + "an already completed row is not rewritten" + ); + + let (ctx, _operation, _registration) = drain_report_context().await; + ctx.persistence.wallet_drain.store(false, Ordering::SeqCst); + assert!(matches!( + ctx.manager + .complete_wallet_drain(ctx.out_point, DRAIN_OPERATION_ID) + .await, + Err(PlatformWalletError::Persistence(_)) + )); + assert_eq!(live_lock(&ctx).await.status, AssetLockStatus::ChainLocked); + assert!(persisted_statuses(&ctx).is_empty()); + } + + #[tokio::test] + async fn drain_completed_never_regresses() { + let (ctx, _operation, _registration) = drain_report_context().await; + ctx.manager + .complete_wallet_drain(ctx.out_point, DRAIN_OPERATION_ID) + .await + .expect("completion"); + assert!(matches!( + ctx.manager + .advance_asset_lock_status( + &ctx.out_point, + AssetLockStatus::ChainLocked, + Some(ctx.proof.clone()), + ) + .await, + Err(PlatformWalletError::AssetLockAlreadyConsumed(_)) + )); + assert!(matches!( + ctx.manager.consume_asset_lock(&ctx.out_point).await, + Err(PlatformWalletError::Persistence(_)) + )); + assert!(matches!( + ctx.manager + .resume_asset_lock(&ctx.out_point, Some(Duration::from_secs(1))) + .await, + Err(PlatformWalletError::AssetLockAlreadyConsumed(_)) + )); + assert_eq!( + live_lock(&ctx).await.status, + AssetLockStatus::DrainCompleted + ); + } + #[tokio::test] async fn successful_submit_result_passes_through_without_mutation() { let ctx = consumption_report_context().await; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index df2b4fa1ddc..3b986f0b770 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -31,6 +31,8 @@ //! `BTreeMap` (with the spend authority); //! `PlatformWallet` doesn't need a wrapper struct around it. +pub mod wallet_drain; +pub use wallet_drain::{WalletDrainPreview, WalletDrainPreviewStatus}; pub mod activity; pub mod activity_recorder; pub mod balance; @@ -48,6 +50,8 @@ pub mod store; pub mod sync; #[cfg(test)] mod viewing_key_bind_tests; +#[cfg(test)] +mod wallet_drain_tests; pub use activity::{ compute_activity_id, derive_activity_from_scan_data, sort_activity_for_display, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs new file mode 100644 index 00000000000..8c4f4f38a65 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs @@ -0,0 +1,378 @@ +//! Read-only quote for pooled Core → Shielded drains. + +use std::collections::HashSet; +use std::sync::Arc; + +use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; +use dashcore::blockdata::transaction::special_transaction::TransactionPayload; +use dashcore::hashes::Hash; +use dashcore::{PubkeyHash, ScriptBuf, TxOut}; +use dpp::balances::credits::CREDITS_PER_DUFF; +use key_wallet::wallet::managed_wallet_info::coin_selection::{SelectionError, SelectionStrategy}; +use key_wallet::wallet::managed_wallet_info::fee::FeeRate; +use key_wallet::wallet::managed_wallet_info::transaction_builder::{ + BuilderError, TransactionBuilder, +}; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + +use crate::error::PlatformWalletError; +use crate::wallet::asset_lock::manager::DEFAULT_FEE_PER_KB; +use crate::wallet::asset_lock::{ + max_asset_lock_inputs, AssetLockOperationContext, AssetLockOperationRegistration, + AssetLockOperationSnapshot, +}; +use crate::wallet::core::resolved_funding_accounts; +use crate::wallet::PlatformWallet; +use crate::ASSET_LOCK_FUNDING_SOURCES; + +use super::fund_from_asset_lock::shield_from_asset_lock_num_actions; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WalletDrainPreviewStatus { + Ready, + InsufficientFunds, + InputLimitMayBeExceeded, +} + +/// Optimistic preview: the account reservation sets are private to key-wallet, +/// so other operations' reserved inputs may be counted. It never reserves, +/// signs, consumes a change/funding address, or promises a minimum amount. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WalletDrainPreview { + pub status: WalletDrainPreviewStatus, + pub candidate_input_count: usize, + pub candidate_balance_duffs: u64, + pub pool_fee_credits: u64, + pub estimated_l1_fee_duffs: Option, + pub estimated_recipient_credits: Option, +} + +impl PlatformWallet { + /// Make a queued drain visible to status readers before spawning its worker. + pub fn register_wallet_drain_operation( + &self, + operation: Arc, + ) -> AssetLockOperationRegistration { + self.asset_locks.register_wallet_drain_operation(operation) + } + + /// Durable row, live row and active context only. Never touches the + /// network and never waits on the shield guard. + pub async fn shielded_wallet_drain_operation_status( + &self, + operation_id: [u8; 16], + ) -> Result { + self.asset_locks + .wallet_drain_operation_status(operation_id) + .await + } + + pub async fn shielded_wallet_drain_preview( + &self, + account_index: u32, + dummy_outputs: usize, + ) -> Result { + let pool_fee_credits = self + .shield_from_asset_lock_pool_fee(shield_from_asset_lock_num_actions(dummy_outputs))?; + let (candidates, height) = { + let manager = self.wallet_manager.read().await; + let (wallet, info) = + manager + .get_wallet_and_info(&self.wallet_id()) + .ok_or_else(|| { + PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id())) + })?; + let height = info.core_wallet.last_processed_height(); + let accounts = &info.core_wallet.accounts; + let funding = resolved_funding_accounts( + accounts, + wallet, + &ASSET_LOCK_FUNDING_SOURCES, + account_index, + false, + )?; + let mut seen = HashSet::new(); + let candidates = funding + .iter() + .filter_map(|account| accounts.funds_account(account)) + .flat_map(|account| account.utxos.values()) + .filter(|utxo| { + (utxo.is_confirmed || utxo.is_instantlocked) && utxo.is_spendable(height) + }) + .filter(|utxo| seen.insert(utxo.outpoint)) + .cloned() + .collect::>(); + (candidates, height) + }; + let candidate_balance_duffs = candidates.iter().try_fold(0u64, |total, input| { + total.checked_add(input.value()).ok_or_else(|| { + PlatformWalletError::AssetLockTransaction( + "wallet drain candidate balance overflows duffs".into(), + ) + }) + })?; + let mut preview = WalletDrainPreview { + status: WalletDrainPreviewStatus::InsufficientFunds, + candidate_input_count: candidates.len(), + candidate_balance_duffs, + pool_fee_credits, + estimated_l1_fee_duffs: None, + estimated_recipient_credits: None, + }; + // Dash Core relays at most `max_asset_lock_transaction_inputs` (100) + // asset-lock inputs and Platform consensus validates the same bound — + // tighter than the builder's standard-transaction limit — so report + // it before sizing the payload. + if candidates.len() > max_asset_lock_inputs(self.sdk.version()) { + preview.status = WalletDrainPreviewStatus::InputLimitMayBeExceeded; + return Ok(preview); + } + // Only a P2PKH placeholder is needed for the payload size. No wallet + // key or address is touched. With no add_funding calls this local + // builder carries no live reservation sets. + let built = TransactionBuilder::new() + .set_fee_rate(FeeRate::new(DEFAULT_FEE_PER_KB)) + .set_current_height(height) + .set_selection_strategy(SelectionStrategy::All) + .require_final_inputs() + .set_special_payload(TransactionPayload::AssetLockPayloadType( + AssetLockPayload::new(vec![TxOut { + value: 0, + script_pubkey: ScriptBuf::new_p2pkh(&PubkeyHash::all_zeros()), + }]), + )) + .add_inputs(candidates) + .build_unsigned_reserved(); + match built { + Ok((_, fee, _)) => { + preview.estimated_l1_fee_duffs = Some(fee); + let recipient = candidate_balance_duffs + .checked_sub(fee) + .and_then(|duffs| duffs.checked_mul(CREDITS_PER_DUFF)) + .and_then(|credits| credits.checked_sub(pool_fee_credits)) + .filter(|credits| *credits > 0); + preview.estimated_recipient_credits = recipient; + if recipient.is_some() { + preview.status = WalletDrainPreviewStatus::Ready; + } + } + Err(BuilderError::TooManyInputs { .. }) => { + preview.status = WalletDrainPreviewStatus::InputLimitMayBeExceeded; + } + Err( + BuilderError::InsufficientFunds { .. } + | BuilderError::NoInputs + | BuilderError::CoinSelection( + SelectionError::NoUtxosAvailable | SelectionError::InsufficientFunds { .. }, + ), + ) => {} + Err(error) => { + return Err(PlatformWalletError::AssetLockTransaction(format!( + "wallet drain preview failed: {error}" + ))) + } + } + Ok(preview) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::broadcaster::SpvBroadcaster; + use crate::events::PlatformEventManager; + use crate::spv::SpvRuntime; + use crate::test_support::{ + funded_wallet_manager_with_contact, funded_wallet_manager_with_outputs, + }; + use crate::wallet::persister::NoPlatformPersistence; + use key_wallet::account::StandardAccountType; + use std::sync::Arc; + use tokio::sync::Notify; + + async fn wallet_with_outputs(outputs: &[u64]) -> PlatformWallet { + let (manager, id, generation, _) = + funded_wallet_manager_with_outputs(StandardAccountType::BIP44Account, outputs).await; + let spv = Arc::new(SpvRuntime::new( + manager.clone(), + Arc::new(PlatformEventManager::new(Vec::new())), + )); + PlatformWallet::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + id, + manager, + generation, + Arc::new(Notify::new()), + Arc::new(NoPlatformPersistence), + Arc::new(SpvBroadcaster::new(spv)), + Arc::new(std::sync::atomic::AtomicBool::new(false)), + crate::changeset::DurableCursors::default(), + ) + } + + #[tokio::test] + async fn should_wallet_drain_preview_report_fees_and_leave_wallet_unchanged() { + let wallet = wallet_with_outputs(&[100_000, 200_000]).await; + let before = { + let manager = wallet.wallet_manager.read().await; + format!( + "{:?}", + manager + .get_wallet_info(&wallet.wallet_id()) + .unwrap() + .core_wallet + ) + }; + let quote = wallet.shielded_wallet_drain_preview(0, 0).await.unwrap(); + assert_eq!(quote.status, WalletDrainPreviewStatus::Ready); + assert_eq!(quote.candidate_balance_duffs, 300_000); + assert_eq!(quote.candidate_input_count, 2); + assert_eq!( + quote.estimated_recipient_credits, + Some( + (300_000 - quote.estimated_l1_fee_duffs.unwrap()) * CREDITS_PER_DUFF + - quote.pool_fee_credits + ) + ); + assert_eq!( + wallet.shielded_wallet_drain_preview(0, 0).await.unwrap(), + quote + ); + let after = { + let manager = wallet.wallet_manager.read().await; + format!( + "{:?}", + manager + .get_wallet_info(&wallet.wallet_id()) + .unwrap() + .core_wallet + ) + }; + assert_eq!( + before, after, + "preview must not consume addresses, reserve coins or mutate state" + ); + } + + #[tokio::test] + async fn should_wallet_drain_preview_distinguish_limit_and_insufficient_funds() { + let tiny = wallet_with_outputs(&[1]).await; + assert_eq!( + tiny.shielded_wallet_drain_preview(0, 0) + .await + .unwrap() + .status, + WalletDrainPreviewStatus::InsufficientFunds + ); + let large = wallet_with_outputs(&vec![10_000; 101]).await; + let quote = large.shielded_wallet_drain_preview(0, 0).await.unwrap(); + assert_eq!( + quote.status, + WalletDrainPreviewStatus::InputLimitMayBeExceeded + ); + assert_eq!(quote.candidate_input_count, 101); + assert_eq!(quote.estimated_l1_fee_duffs, None); + assert_eq!(quote.estimated_recipient_credits, None); + } + + #[tokio::test] + async fn should_wallet_drain_preview_exclude_nonfinal_and_locked_inputs() { + let wallet = wallet_with_outputs(&[100_000, 200_000, 300_000]).await; + { + let mut manager = wallet.wallet_manager.write().await; + let info = manager.get_wallet_info_mut(&wallet.wallet_id()).unwrap(); + let account = info + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + for utxo in account.utxos.values_mut() { + if utxo.value() == 200_000 { + utxo.is_confirmed = false; + utxo.is_instantlocked = false; + } + if utxo.value() == 300_000 { + utxo.is_locked = true; + } + } + } + let quote = wallet.shielded_wallet_drain_preview(0, 0).await.unwrap(); + assert_eq!(quote.candidate_input_count, 1); + assert_eq!(quote.candidate_balance_duffs, 100_000); + } + + #[tokio::test] + async fn should_wallet_drain_preview_include_dashpay_and_newly_final_funds() { + let (manager, id, generation, _, _) = + funded_wallet_manager_with_contact(&[100_000], &[200_000]).await; + let spv = Arc::new(SpvRuntime::new( + manager.clone(), + Arc::new(PlatformEventManager::new(Vec::new())), + )); + let wallet = PlatformWallet::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + id, + manager.clone(), + generation, + Arc::new(Notify::new()), + Arc::new(NoPlatformPersistence), + Arc::new(SpvBroadcaster::new(spv)), + Arc::new(std::sync::atomic::AtomicBool::new(false)), + crate::changeset::DurableCursors::default(), + ); + assert_eq!( + wallet + .shielded_wallet_drain_preview(0, 0) + .await + .unwrap() + .candidate_balance_duffs, + 300_000 + ); + { + let mut manager = manager.write().await; + let info = manager.get_wallet_info_mut(&id).unwrap(); + for account in info + .core_wallet + .accounts + .dashpay_receival_accounts + .values_mut() + { + for utxo in account.utxos.values_mut() { + utxo.is_confirmed = false; + utxo.is_instantlocked = false; + } + } + } + assert_eq!( + wallet + .shielded_wallet_drain_preview(0, 0) + .await + .unwrap() + .candidate_balance_duffs, + 100_000 + ); + { + let mut manager = manager.write().await; + let info = manager.get_wallet_info_mut(&id).unwrap(); + for account in info + .core_wallet + .accounts + .dashpay_receival_accounts + .values_mut() + { + for utxo in account.utxos.values_mut() { + utxo.is_instantlocked = true; + } + } + } + assert_eq!( + wallet + .shielded_wallet_drain_preview(0, 0) + .await + .unwrap() + .candidate_balance_duffs, + 300_000 + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs new file mode 100644 index 00000000000..a24133f5e28 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs @@ -0,0 +1,794 @@ +//! Wallet-level drain lifecycle tests: registration, resume, completion and +//! status over a recording persistence backend. The Type18 network path is +//! covered at the manager level in `fund_from_asset_lock.rs`. + +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; +use dashcore::blockdata::transaction::special_transaction::TransactionPayload; +use dashcore::{Network, OutPoint, ScriptBuf, Transaction, TxOut}; +use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dpp::prelude::AssetLockProof; +use dpp::version::PlatformVersion; +use key_wallet::account::StandardAccountType; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; +use key_wallet::wallet::ManagedWalletInfo; +use key_wallet::Wallet; +use tokio::sync::Notify; + +use crate::broadcaster::SpvBroadcaster; +use crate::changeset::{ + AssetLockEntry, ClientStartState, ClientWalletStartState, PersistenceCapabilities, + PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use crate::error::PlatformWalletError; +use crate::events::PlatformEventManager; +use crate::spv::SpvRuntime; +use crate::test_support::funded_wallet_manager; +use crate::wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; +use crate::wallet::asset_lock::{ + AssetLockFunding, AssetLockOperationContext, AssetLockOperationState, +}; +use crate::wallet::PlatformWallet; + +use super::keys::AccountViewingKeys; +use super::{FileBackedShieldedStore, NetworkShieldedCoordinator, OrchardKeySet}; + +const OPERATION_ID: [u8; 16] = [0x33; 16]; + +/// A pending drain lock (Core finality proven, Platform side unresolved) +/// tagged with [`OPERATION_ID`], plus the viewing keys of its recipient. +fn drain_lock_fixture() -> (TrackedAssetLock, BTreeMap) { + let keys = OrchardKeySet::from_seed(&[0x42; 32], Network::Testnet, 0) + .unwrap() + .viewing_keys(); + let secret = dashcore::secp256k1::SecretKey::from_secret_bytes([0x11; 32]).unwrap(); + let public = dashcore::PublicKey::new(dashcore::secp256k1::PublicKey::from_secret_key(&secret)); + let transaction = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType( + AssetLockPayload { + version: 1, + credit_outputs: vec![TxOut { + value: 10_000, + script_pubkey: ScriptBuf::new_p2pkh(&public.pubkey_hash()), + }], + }, + )), + }; + let out_point = OutPoint::new(transaction.txid(), 0); + let proof = AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 42, + out_point, + }); + ( + TrackedAssetLock { + operation_id: Some(OPERATION_ID), + out_point, + transaction, + account_index: 0, + funding_type: AssetLockFundingType::AssetLockShieldedAddressTopUp, + identity_index: 0, + amount: 10_000, + status: AssetLockStatus::RecoveredFromChain, + proof: Some(proof), + }, + BTreeMap::from([(0, keys)]), + ) +} + +/// Buffered backend with inline-commit and failure switches. `committed` +/// is what a restart restores; `staged` is what a flush commits. +struct RecoveryPersistence { + wallet: Wallet, + info: ManagedWalletInfo, + committed: Mutex>, + staged: Mutex>, + fail_store: AtomicBool, + fail_flush: AtomicBool, + commits_inline: AtomicBool, + fail_load: AtomicBool, +} + +impl PlatformWalletPersistence for RecoveryPersistence { + fn store_commits_inline(&self) -> bool { + self.commits_inline.load(Ordering::SeqCst) + } + fn persistence_capabilities(&self) -> PersistenceCapabilities { + PersistenceCapabilities::WALLET_DRAIN + } + fn store( + &self, + _wallet_id: [u8; 32], + cs: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + if self.fail_store.load(Ordering::SeqCst) { + return Err(PersistenceError::backend("store failed")); + } + if let Some(cs) = cs.asset_locks { + self.staged.lock().unwrap().extend(cs.asset_locks); + } + if self.store_commits_inline() { + self.commit_staged(); + } + Ok(()) + } + fn flush(&self, _wallet_id: [u8; 32]) -> Result<(), PersistenceError> { + if self.fail_flush.load(Ordering::SeqCst) { + return Err(PersistenceError::backend("flush failed")); + } + self.commit_staged(); + Ok(()) + } + fn load(&self) -> Result { + if self.fail_load.load(Ordering::SeqCst) { + return Err(PersistenceError::backend("load failed")); + } + let locks = self + .committed + .lock() + .unwrap() + .iter() + .map(|(out_point, row)| { + ( + *out_point, + TrackedAssetLock { + operation_id: row.operation_id, + out_point: *out_point, + transaction: row.transaction.clone(), + account_index: row.account_index, + funding_type: row.funding_type, + identity_index: row.identity_index, + amount: row.amount_duffs, + status: row.status.clone(), + proof: row.proof.clone(), + }, + ) + }) + .collect(); + Ok(ClientStartState { + wallets: BTreeMap::from([( + self.wallet.compute_wallet_id(), + ClientWalletStartState { + dashpay_backfill: Default::default(), + wallet: self.wallet.clone(), + wallet_info: self.info.clone(), + identity_manager: Default::default(), + unused_asset_locks: BTreeMap::from([(0, locks)]), + unconfirmed_outgoing_txs: vec![], + }, + )]), + ..Default::default() + }) + } +} + +impl RecoveryPersistence { + fn commit_staged(&self) { + self.committed + .lock() + .unwrap() + .extend(self.staged.lock().unwrap().clone()); + } + + fn committed_status(&self, out_point: &OutPoint) -> AssetLockStatus { + self.committed.lock().unwrap()[out_point].status.clone() + } +} + +struct TestDirectory(std::path::PathBuf); +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +async fn wallet_fixture( + lock: &TrackedAssetLock, + keys: BTreeMap, +) -> ( + PlatformWallet, + Arc, + NetworkShieldedCoordinator, + TestDirectory, +) { + let (manager, id, generation, _) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let (stored_wallet, info) = { + let manager = manager.read().await; + let (wallet, info) = manager.get_wallet_and_info(&id).unwrap(); + (wallet.clone(), info.core_wallet.clone()) + }; + let persistence = Arc::new(RecoveryPersistence { + wallet: stored_wallet, + info, + committed: Mutex::new(BTreeMap::from([(lock.out_point, lock.into())])), + staged: Mutex::new(BTreeMap::new()), + fail_store: AtomicBool::new(false), + fail_flush: AtomicBool::new(false), + // Drains admit inline-committing backends only; the buffered mode + // exists for `should_refuse_drain_on_a_buffered_persistence_backend`. + commits_inline: AtomicBool::new(true), + fail_load: AtomicBool::new(false), + }); + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_version(PlatformVersion::latest()) + .build() + .unwrap(), + ); + let spv = Arc::new(SpvRuntime::new( + manager.clone(), + Arc::new(PlatformEventManager::new(vec![])), + )); + let wallet = PlatformWallet::new( + sdk.clone(), + id, + manager.clone(), + generation, + Arc::new(Notify::new()), + persistence.clone(), + Arc::new(SpvBroadcaster::new(spv)), + Arc::new(std::sync::atomic::AtomicBool::new(false)), + crate::changeset::DurableCursors::default(), + ); + manager + .write() + .await + .get_wallet_info_mut(&id) + .unwrap() + .tracked_asset_locks + .insert(lock.out_point, lock.clone()); + *wallet.shielded_keys.write().await = Some(keys); + let dir = + TestDirectory(std::env::temp_dir().join(format!("wallet_drain_{}", rand::random::()))); + std::fs::create_dir_all(&dir.0).unwrap(); + let path = dir.0.join("shielded.sqlite"); + let store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + let coordinator = NetworkShieldedCoordinator::new(sdk, Network::Testnet, path, store); + (wallet, persistence, coordinator, dir) +} + +/// A fresh wallet handle whose live tracked locks come from `persistence`'s +/// committed rows, as after a process restart. +async fn restart_wallet( + old: PlatformWallet, + persistence: Arc, +) -> PlatformWallet { + let restored = persistence + .load() + .unwrap() + .wallets + .remove(&old.wallet_id()) + .unwrap(); + old.wallet_manager + .write() + .await + .get_wallet_info_mut(&old.wallet_id()) + .unwrap() + .tracked_asset_locks = restored + .unused_asset_locks + .into_values() + .flatten() + .collect(); + let spv = Arc::new(SpvRuntime::new( + old.wallet_manager.clone(), + Arc::new(PlatformEventManager::new(vec![])), + )); + let wallet = PlatformWallet::new( + old.sdk.clone(), + old.wallet_id(), + old.wallet_manager.clone(), + old.generation.clone(), + Arc::new(Notify::new()), + persistence, + Arc::new(SpvBroadcaster::new(spv)), + Arc::new(std::sync::atomic::AtomicBool::new(false)), + crate::changeset::DurableCursors::default(), + ); + *wallet.shielded_keys.write().await = old.shielded_keys.read().await.clone(); + wallet +} + +async fn live_status(wallet: &PlatformWallet, out_point: &OutPoint) -> AssetLockStatus { + wallet + .asset_locks + .list_tracked_locks() + .await + .into_iter() + .find(|lock| lock.out_point == *out_point) + .map(|lock| lock.status) + .unwrap() +} + +#[tokio::test] +async fn should_release_registration_after_returned_prebuild_load_failure() { + let (mut lock, keys) = drain_lock_fixture(); + lock.operation_id = None; + let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; + let coordinator = Arc::new(coordinator); + let (_, _, _, signer) = funded_wallet_manager(StandardAccountType::BIP44Account).await; + let prover = super::prover::CachedOrchardProver::new(); + let operation = Arc::new(AssetLockOperationContext::new([0x78; 16])); + for failed_load in [true, false] { + persistence.fail_load.store(failed_load, Ordering::SeqCst); + let result = wallet + .shielded_fund_from_asset_lock( + &coordinator, + AssetLockFunding::DrainWalletBalance { + account_index: 0, + minimum_lock_duffs: None, + operation: operation.clone(), + }, + vec![], + &signer, + &prover, + None, + 0, + None, + None, + ) + .await; + if failed_load { + assert!(matches!(result, Err(PlatformWalletError::Persistence(_)))); + assert_eq!(operation.snapshot().state, AssetLockOperationState::Pending); + } else { + // Reaching ordinary recipient validation proves the abandoned + // registration no longer blocks this same-context UUID retry. + assert!(matches!( + result, + Err(PlatformWalletError::AddressOperation(_)) + )); + } + } + assert_eq!( + wallet + .shielded_wallet_drain_operation_status(operation.operation_id) + .await + .unwrap() + .state, + AssetLockOperationState::NotSent + ); + assert!(operation.snapshot().out_point.is_none()); +} + +#[tokio::test] +async fn should_release_cancelled_drain_waiting_for_the_shield_guard() { + let (mut lock, keys) = drain_lock_fixture(); + lock.operation_id = None; + let (wallet, _, coordinator, _dir) = wallet_fixture(&lock, keys).await; + let coordinator = Arc::new(coordinator); + let (_, _, _, signer) = funded_wallet_manager(StandardAccountType::BIP44Account).await; + let prover = super::prover::CachedOrchardProver::new(); + let operation = Arc::new(AssetLockOperationContext::new([0x77; 16])); + let blocker = wallet.shield_guard.lock().await; + let mut future = Box::pin(wallet.shielded_fund_from_asset_lock( + &coordinator, + AssetLockFunding::DrainWalletBalance { + account_index: 0, + minimum_lock_duffs: None, + operation: operation.clone(), + }, + vec![], + &signer, + &prover, + None, + 0, + None, + None, + )); + assert!(futures::poll!(&mut future).is_pending()); + assert_eq!( + wallet + .shielded_wallet_drain_operation_status(operation.operation_id) + .await + .unwrap() + .state, + AssetLockOperationState::Pending + ); + drop(future); + drop(blocker); + assert_eq!(operation.snapshot().state, AssetLockOperationState::Pending); + assert_eq!( + wallet + .shielded_wallet_drain_operation_status(operation.operation_id) + .await + .unwrap() + .state, + AssetLockOperationState::NotSent + ); + let _registration = wallet.register_wallet_drain_operation(operation.clone()); + assert_eq!( + wallet + .asset_locks + .begin_wallet_drain(&operation) + .await + .unwrap(), + None + ); + assert!(operation.snapshot().out_point.is_none()); +} + +#[tokio::test] +async fn should_refuse_drain_without_viewing_keys_before_core_funding() { + for empty_binding in [false, true] { + let (mut lock, keys) = drain_lock_fixture(); + lock.operation_id = None; + let recipient = dpp::address_funds::OrchardAddress::from(keys[&0].default_address); + let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys.clone()).await; + *wallet.shielded_keys.write().await = empty_binding.then(BTreeMap::new); + let coordinator = Arc::new(coordinator); + let (_, _, _, signer) = funded_wallet_manager(StandardAccountType::BIP44Account).await; + let prover = super::prover::CachedOrchardProver::new(); + let operation = Arc::new(AssetLockOperationContext::new([0x97; 16])); + let error = wallet + .shielded_fund_from_asset_lock( + &coordinator, + AssetLockFunding::DrainWalletBalance { + account_index: 0, + minimum_lock_duffs: None, + operation: operation.clone(), + }, + vec![(recipient, None)], + &signer, + &prover, + None, + 0, + None, + None, + ) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("requires bound shielded viewing keys"), + "{error}" + ); + assert_eq!(operation.snapshot().state, AssetLockOperationState::NotSent); + assert!(operation.snapshot().out_point.is_none()); + assert_eq!(persistence.committed.lock().unwrap().len(), 1); + assert!(persistence.staged.lock().unwrap().is_empty()); + assert_eq!(wallet.asset_locks.list_tracked_locks().await.len(), 1); + // Binding keys leaves the same UUID available for a fresh attempt. + *wallet.shielded_keys.write().await = Some(keys); + let _registration = wallet.register_wallet_drain_operation(operation.clone()); + assert_eq!( + wallet + .asset_locks + .begin_wallet_drain(&operation) + .await + .unwrap(), + None + ); + } +} + +#[tokio::test] +async fn should_keep_a_consumed_legacy_drain_row_pending_and_refuse_generic_consumption() { + let (mut lock, keys) = drain_lock_fixture(); + lock.status = AssetLockStatus::Consumed; + let (wallet, persistence, _, _dir) = wallet_fixture(&lock, keys).await; + // Generic consumption never manufactures a drain completion, and nothing + // short of `complete_wallet_drain` can write the receipt. + assert!(wallet + .asset_locks + .consume_asset_lock(&lock.out_point) + .await + .is_err()); + assert!(wallet + .asset_locks + .advance_asset_lock_status(&lock.out_point, AssetLockStatus::DrainCompleted, None) + .await + .is_err()); + assert_eq!( + persistence.committed_status(&lock.out_point), + AssetLockStatus::Consumed + ); + let wallet = restart_wallet(wallet, persistence).await; + let status = wallet + .shielded_wallet_drain_operation_status(OPERATION_ID) + .await + .unwrap(); + assert_eq!(status.state, AssetLockOperationState::Pending); + assert_eq!(status.out_point, Some(lock.out_point)); +} + +#[tokio::test] +async fn should_evict_completed_context_after_last_lease_and_keep_pending_context() { + for complete_while_registered in [false, true] { + let (lock, keys) = drain_lock_fixture(); + let (wallet, _, _, _dir) = wallet_fixture(&lock, keys).await; + let operation = Arc::new(AssetLockOperationContext::new(OPERATION_ID)); + operation.set(AssetLockOperationState::Pending, Some(lock.out_point)); + let weak = Arc::downgrade(&operation); + let owner = wallet.register_wallet_drain_operation(operation.clone()); + let contender = wallet.register_wallet_drain_operation(Arc::new( + AssetLockOperationContext::new(OPERATION_ID), + )); + drop(operation); + let leases = if complete_while_registered { + Some((owner, contender)) + } else { + drop((owner, contender)); + assert!( + weak.upgrade().is_some(), + "pending checkpoint must remain retained" + ); + None + }; + wallet + .asset_locks + .complete_wallet_drain(lock.out_point, OPERATION_ID) + .await + .unwrap(); + if let Some((owner, contender)) = leases { + assert_eq!( + weak.upgrade().unwrap().snapshot().state, + AssetLockOperationState::Completed + ); + drop(owner); + assert!(weak.upgrade().is_some(), "queued caller still owns a lease"); + drop(contender); + } + assert!( + weak.upgrade().is_none(), + "completed context must be evicted" + ); + // A later status read of the durable receipt also releases a newly + // registered context once that caller's final lease leaves. + let restored = Arc::new(AssetLockOperationContext::new(OPERATION_ID)); + let weak = Arc::downgrade(&restored); + let lease = wallet.register_wallet_drain_operation(restored.clone()); + drop(restored); + assert_eq!( + wallet + .shielded_wallet_drain_operation_status(OPERATION_ID) + .await + .unwrap() + .state, + AssetLockOperationState::Completed + ); + drop(lease); + assert!(weak.upgrade().is_none()); + } +} + +#[tokio::test] +async fn should_complete_drain_when_an_inline_commit_flush_notification_fails() { + let (lock, keys) = drain_lock_fixture(); + let (wallet, persistence, _, _dir) = wallet_fixture(&lock, keys).await; + persistence.commits_inline.store(true, Ordering::SeqCst); + persistence.fail_flush.store(true, Ordering::SeqCst); + wallet + .asset_locks + .complete_wallet_drain(lock.out_point, OPERATION_ID) + .await + .unwrap(); + assert_eq!( + persistence.committed_status(&lock.out_point), + AssetLockStatus::DrainCompleted + ); + assert_eq!( + live_status(&wallet, &lock.out_point).await, + AssetLockStatus::DrainCompleted + ); + let status = wallet + .shielded_wallet_drain_operation_status(OPERATION_ID) + .await + .unwrap(); + assert_eq!(status.state, AssetLockOperationState::Completed); + assert_eq!(status.out_point, Some(lock.out_point)); +} + +#[tokio::test] +async fn should_keep_drain_pending_when_completion_persistence_fails() { + let (lock, keys) = drain_lock_fixture(); + let (wallet, persistence, _, _dir) = wallet_fixture(&lock, keys).await; + { + persistence.fail_store.store(true, Ordering::SeqCst); + assert!(matches!( + wallet + .asset_locks + .complete_wallet_drain(lock.out_point, OPERATION_ID) + .await, + Err(PlatformWalletError::Persistence(_)) + )); + assert_ne!( + persistence.committed_status(&lock.out_point), + AssetLockStatus::DrainCompleted + ); + assert_ne!( + live_status(&wallet, &lock.out_point).await, + AssetLockStatus::DrainCompleted + ); + let status = wallet + .shielded_wallet_drain_operation_status(OPERATION_ID) + .await + .unwrap(); + assert_eq!(status.state, AssetLockOperationState::Pending); + assert_eq!(status.out_point, Some(lock.out_point)); + } + persistence.fail_store.store(false, Ordering::SeqCst); + wallet + .asset_locks + .complete_wallet_drain(lock.out_point, OPERATION_ID) + .await + .unwrap(); + let wallet = restart_wallet(wallet, persistence).await; + assert_eq!( + wallet + .shielded_wallet_drain_operation_status(OPERATION_ID) + .await + .unwrap() + .state, + AssetLockOperationState::Completed + ); +} + +/// A buffered backend could commit a retained changeset through a later, +/// unrelated flush, so every drain entry point refuses it before any Core +/// funds move or any row is written; the stored state is left untouched. +#[tokio::test] +async fn should_refuse_drain_on_a_buffered_persistence_backend() { + let (lock, keys) = drain_lock_fixture(); + let (wallet, persistence, _, _dir) = wallet_fixture(&lock, keys).await; + persistence.commits_inline.store(false, Ordering::SeqCst); + + fn refused(result: Result) { + match result { + Err(PlatformWalletError::Persistence(message)) => { + assert!(message.contains("commits each store inline"), "{message}"); + } + other => panic!("buffered backend must be refused, got {other:?}"), + } + } + refused( + wallet + .shielded_wallet_drain_operation_status(OPERATION_ID) + .await + .map(|snapshot| snapshot.state), + ); + refused( + wallet + .asset_locks + .complete_wallet_drain(lock.out_point, OPERATION_ID) + .await, + ); + let fresh = Arc::new(AssetLockOperationContext::new([0x77; 16])); + let _registration = wallet + .asset_locks + .register_wallet_drain_operation(fresh.clone()); + refused(wallet.asset_locks.begin_wallet_drain(&fresh).await); + assert!(fresh.snapshot().out_point.is_none()); + // The generic exact-outpoint resume of a drain's lock applies the same + // admission policy before anything is built or submitted. + let (_, _, _, signer) = funded_wallet_manager(StandardAccountType::BIP44Account).await; + refused( + wallet + .asset_locks + .resolve_funding_with_is_timeout_fallback( + AssetLockFunding::FromExistingAssetLock { + out_point: lock.out_point, + consume_invitation_voucher: false, + }, + lock.funding_type, + 0, + &signer, + ) + .await + .map(|_| ()), + ); + refused( + wallet + .asset_locks + .resume_asset_lock(&lock.out_point, None) + .await + .map(|_| ()), + ); + + assert_ne!( + persistence.committed_status(&lock.out_point), + AssetLockStatus::DrainCompleted + ); + assert!(persistence.staged.lock().unwrap().is_empty()); + assert_eq!(wallet.asset_locks.list_tracked_locks().await.len(), 1); + + // The same backend in inline-commit mode is admitted unchanged. + persistence.commits_inline.store(true, Ordering::SeqCst); + assert_eq!( + wallet + .shielded_wallet_drain_operation_status(OPERATION_ID) + .await + .unwrap() + .state, + AssetLockOperationState::Pending + ); +} + +#[tokio::test] +async fn should_resume_the_same_lock_for_a_reused_uuid_without_a_second_core_lock() { + let (lock, keys) = drain_lock_fixture(); + let (wallet, persistence, _, _dir) = wallet_fixture(&lock, keys).await; + let operation = Arc::new(AssetLockOperationContext::new(OPERATION_ID)); + let registration = wallet.register_wallet_drain_operation(operation.clone()); + assert_eq!( + wallet + .asset_locks + .begin_wallet_drain(&operation) + .await + .unwrap(), + Some(lock.out_point) + ); + assert_eq!( + persistence.committed.lock().unwrap().len(), + 1, + "a reused UUID must never create a second Core lock" + ); + assert_eq!(operation.snapshot().state, AssetLockOperationState::Pending); + assert_eq!(operation.snapshot().out_point, Some(lock.out_point)); + + wallet + .asset_locks + .complete_wallet_drain(lock.out_point, OPERATION_ID) + .await + .unwrap(); + assert_eq!( + operation.snapshot().state, + AssetLockOperationState::Completed + ); + + // A retry under the completed UUID is refused with the typed error and + // reports completion; a new UUID is free to start. + let retry = Arc::new(AssetLockOperationContext::new(OPERATION_ID)); + let _retry_registration = wallet.register_wallet_drain_operation(retry.clone()); + assert!(matches!( + wallet.asset_locks.begin_wallet_drain(&retry).await, + Err(PlatformWalletError::AssetLockAlreadyConsumed(out_point)) if out_point == lock.out_point + )); + assert_eq!(retry.snapshot().state, AssetLockOperationState::Completed); + drop(registration); + let fresh = Arc::new(AssetLockOperationContext::new([0x44; 16])); + let _fresh_registration = wallet.register_wallet_drain_operation(fresh.clone()); + assert_eq!( + wallet.asset_locks.begin_wallet_drain(&fresh).await.unwrap(), + None + ); +} + +#[tokio::test] +async fn status_reads_durable_state_without_network_or_guards() { + let (lock, keys) = drain_lock_fixture(); + let (wallet, _, coordinator, _dir) = wallet_fixture(&lock, keys).await; + let guard = wallet.shield_guard.lock().await; + let scan = coordinator.store().write().await; + let mut future = Box::pin(wallet.shielded_wallet_drain_operation_status(OPERATION_ID)); + let result = futures::poll!(&mut future); + drop(future); + assert!(matches!( + result, + std::task::Poll::Ready(Ok(snapshot)) + if snapshot.state == AssetLockOperationState::Pending + && snapshot.out_point == Some(lock.out_point) + )); + drop(scan); + drop(guard); + wallet + .asset_locks + .complete_wallet_drain(lock.out_point, OPERATION_ID) + .await + .unwrap(); + assert_eq!( + wallet + .shielded_wallet_drain_operation_status(OPERATION_ID) + .await + .unwrap() + .state, + AssetLockOperationState::Completed + ); +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index ea878415c36..ba455874d35 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -100,7 +100,7 @@ public enum DashModelContainer { /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV3.self) + Schema(versionedSchema: DashSchemaV4.self) } /// Create a persistent model container for storing data. @@ -172,7 +172,7 @@ public enum DashModelContainer { bridgeLegacyStore: Bool = true ) throws -> ModelContainer { SDKLogger.event("store_open_started", category: .persistence, - fields: ["target_version": .publicText("3.0.0")]) + fields: ["target_version": .publicText("4.0.0")]) do { let container: ModelContainer if bridgeLegacyStore { @@ -185,7 +185,7 @@ public enum DashModelContainer { configurations: [configuration]) } SDKLogger.event("store_open_succeeded", category: .persistence, - fields: ["target_version": .publicText("3.0.0")]) + fields: ["target_version": .publicText("4.0.0")]) return container } catch { logMigrationFailure(error) @@ -201,7 +201,7 @@ public enum DashModelContainer { let domain = systemDomains.contains(nsError.domain) ? nsError.domain : String(reflecting: type(of: error)) SDKLogger.event("store_open_failed", category: .persistence, severity: .error, fields: ["error_domain": .publicText(domain), "error_code": .integer(Int64(nsError.code)), - "target_version": .publicText("3.0.0")]) + "target_version": .publicText("4.0.0")]) } /// Select by the complete stored model identity, after journal recovery. @@ -218,7 +218,7 @@ public enum DashModelContainer { guard ObjectIdentifier(defaultPlan) == ObjectIdentifier(DashMigrationPlan.self) else { return defaultPlan } guard FileManager.default.fileExists(atPath: url.path) else { SDKLogger.event("store_migration_route", category: .persistence, fields: [ - "route": .publicText("new-store"), "target_version": .publicText("3.0.0")]) + "route": .publicText("new-store"), "target_version": .publicText("4.0.0")]) return defaultPlan } let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) @@ -236,7 +236,7 @@ public enum DashModelContainer { func logRoute(_ route: String) { SDKLogger.event("store_migration_route", category: .persistence, fields: [ "source_version": .publicText(safeVersions), "source_checksum": .publicText(safeChecksum), - "route": .publicText(route), "target_version": .publicText("3.0.0")]) + "route": .publicText(route), "target_version": .publicText("4.0.0")]) } func matches(_ type: any VersionedSchema.Type) throws -> Bool { let expected = try identity(type) @@ -266,7 +266,9 @@ public enum DashModelContainer { // Same plan either way. Opening a current store must neither wait // on nor fail with a schema probe that could only refine this line; // `source_checksum` above already identifies the exact graph. - logRoute("labelled-current-v3") + logRoute("published-v3-to-v4") + } else if versions == ["4.0.0"] { + logRoute("labelled-current-v4") } else { logRoute("ordinary-current-plan") } @@ -287,12 +289,13 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV2.self, DashSchemaV3.self] + [DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] } public static var stages: [MigrationStage] { [ - .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self) + .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) ] } } @@ -300,9 +303,10 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// Separate compatibility route: V1 has fields missing from historical V2. /// Never insert V2 between this baseline and the current schema. enum DashAcceptedV1MigrationPlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self] } + static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self] } static var stages: [MigrationStage] { - [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self)] + [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self)] } } @@ -372,9 +376,14 @@ public enum DashSchemaV2: VersionedSchema { } } -/// Current working schema. A later shape change must preserve this graph as -/// the fixed legacy-bridge target before introducing another live version. +/// Published V3 and fixed legacy-bridge target. Its complete model graph is frozen. public enum DashSchemaV3: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(3, 0, 0) } + public static var models: [any PersistentModel.Type] { DashSchemaSnapshotV3.models } +} + +/// Current schema: optional asset-lock operation IDs correlate drain recovery. +public enum DashSchemaV4: VersionedSchema { + public static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) } public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift index 19b5f55d181..0ac5f527808 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift @@ -115,7 +115,8 @@ public final class PersistentAssetLock { /// Discriminant of [`AssetLockStatus`]: /// 0 = Built, 1 = Broadcast, 2 = InstantSendLocked, 3 = ChainLocked, - /// 4 = Consumed, 5 = RecoveredFromChain. Stored as `Int` so + /// 4 = Consumed, 5 = RecoveredFromChain, 6 = DrainCompleted (proven owned output). + /// Stored as `Int` so /// `#Predicate` can match raw values directly (the progress bar /// compares against 0/1/2/3 and the resumable-locks filter against /// 4 to hide already-spent rows). @@ -200,6 +201,11 @@ public final class PersistentAssetLock { /// before introducing a subsequent version for further shape changes. public var recipientIsExternal: Bool? + /// Stable UUID bytes for a wallet-drain operation. Legacy rows are nil. + /// Retained on consumed locks so restart recovery can distinguish a + /// completed transfer from an operation that never reached broadcast. + public var operationId: Data? + /// Record timestamps. public var createdAt: Date public var updatedAt: Date @@ -213,7 +219,8 @@ public final class PersistentAssetLock { accountIndexRaw: Int32 = 0, amountDuffs: Int64, statusRaw: Int, - proofBytes: Data? = nil + proofBytes: Data? = nil, + operationId: Data? = nil ) { self.outPointHex = outPointHex self.walletId = walletId @@ -224,6 +231,7 @@ public final class PersistentAssetLock { self.amountDuffs = amountDuffs self.statusRaw = statusRaw self.proofBytes = proofBytes + self.operationId = operationId self.createdAt = Date() self.updatedAt = Date() } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift index c7dd57e8f2c..549257ea450 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift @@ -64,6 +64,8 @@ public final class ManagedAssetLockManager: @unchecked Sendable { /// explicit resume may consume one; Platform rejects an /// already-spent outpoint with a typed error. case recoveredFromChain = 5 + /// The wallet drain was proven and its owned output recorded durably. + case drainCompleted = 6 } /// A tracked asset lock. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 433250e87b8..24a4edede32 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -107,6 +107,8 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// Rust only honours the declaration when the payments callback is /// actually wired. public static let dashpayPayments: UInt64 = 1 << 12 + /// Durable operation UUIDs, including consumed asset-lock tombstones. + public static let assetLockOperationIds: UInt64 = 1 << 13 public let version: UInt32 public let bits: UInt64 @@ -2383,7 +2385,13 @@ public class PlatformWalletManager: ObservableObject { guard let persistenceHandler = persistenceHandler else { return } for wallet in wallets { let walletId = wallet.walletId - let locks = persistenceHandler.loadCachedAssetLocks(walletId: walletId) + let locks: [PlatformWalletPersistenceHandler.AssetLockEntrySnapshot] + do { + locks = try persistenceHandler.loadCachedAssetLocks(walletId: walletId) + } catch { + self.lastError = error + continue + } let pending = locks.filter { $0.statusRaw < 2 } if pending.isEmpty { continue } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerWalletDrain.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerWalletDrain.swift new file mode 100644 index 00000000000..6bfeffb66cf --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerWalletDrain.swift @@ -0,0 +1,291 @@ +import Foundation +import DashSDKFFI + +private struct WalletDrainCallIdentity: Hashable { + let manager: Handle + let walletId: Data + let operationId: UUID +} + +/// A read-only Core-to-shielded Max estimate. Candidate balances include +/// reservations held by other operations, so this is never a guaranteed minimum. +public struct WalletDrainPreview: Sendable, Equatable { + public enum Status: UInt8, Sendable { + case ready = 0 + case insufficientFunds = 1 + /// The optimistic candidate count exceeds the one-transaction limit. + /// Execution may still fit after excluding another operation's reservations. + case inputLimitMayBeExceeded = 2 + } + + public let candidateInputCount: UInt64 + public let candidateBalanceDuffs: UInt64 + public let poolFeeCredits: UInt64 + public let estimatedL1FeeDuffs: UInt64? + public let estimatedRecipientCredits: UInt64? + public let status: Status + + public init( + candidateInputCount: UInt64, + candidateBalanceDuffs: UInt64, + poolFeeCredits: UInt64, + estimatedL1FeeDuffs: UInt64?, + estimatedRecipientCredits: UInt64?, + status: Status + ) { + self.candidateInputCount = candidateInputCount + self.candidateBalanceDuffs = candidateBalanceDuffs + self.poolFeeCredits = poolFeeCredits + self.estimatedL1FeeDuffs = estimatedL1FeeDuffs + self.estimatedRecipientCredits = estimatedRecipientCredits + self.status = status + } + + init(ffi: WalletDrainPreviewFFI) throws { + guard let status = Status(rawValue: ffi.status) else { + throw PlatformWalletError.deserialization("Unknown wallet drain preview status: \(ffi.status)") + } + self.init( + candidateInputCount: ffi.candidate_input_count, + candidateBalanceDuffs: ffi.candidate_balance_duffs, + poolFeeCredits: ffi.pool_fee_credits, + estimatedL1FeeDuffs: ffi.has_estimated_l1_fee_duffs ? ffi.estimated_l1_fee_duffs : nil, + estimatedRecipientCredits: ffi.has_estimated_recipient_credits ? ffi.estimated_recipient_credits : nil, + status: status + ) + } +} + +/// Exact asset-lock identity, using the same raw txid byte order as the resume API. +public struct WalletDrainOutpoint: Sendable, Equatable { + public let txidWire: Data + public let vout: UInt32 + + public init(txidWire: Data, vout: UInt32) { + self.txidWire = txidWire + self.vout = vout + } +} + +/// State and error belong to one persisted operation UUID. A pending outcome +/// must be reconciled by that UUID or resumed by its outpoint; never start a +/// replacement drain because a request timed out or the outpoint is absent. +public struct WalletDrainOutcome: Sendable { + public enum State: UInt8, Sendable { + case notSent = 0 + case pending = 1 + case completed = 2 + } + + public let state: State + public let outpoint: WalletDrainOutpoint? + public let error: PlatformWalletError? + + public init(state: State, outpoint: WalletDrainOutpoint?, error: PlatformWalletError? = nil) { + self.state = state + self.outpoint = outpoint + self.error = error + } + + init(ffi: WalletDrainOutcomeFFI, error: PlatformWalletError? = nil) { + var rawOutpoint = ffi.out_point + let outpoint = ffi.has_out_point + ? WalletDrainOutpoint( + txidWire: withUnsafeBytes(of: &rawOutpoint.txid) { Data($0) }, + vout: rawOutpoint.vout) + : nil + guard let state = State(rawValue: ffi.state) else { + self.init( + state: .pending, + outpoint: outpoint, + error: error ?? .deserialization("Unknown wallet drain outcome state: \(ffi.state)") + ) + return + } + self.init(state: state, outpoint: outpoint, error: error) + } +} + +extension PlatformWalletManager { + // MainActor-isolated, covering the interval before detached execution has + // entered the Rust operation registry. Count duplicate UUID callers so one + // returning call cannot clear another caller's protection. + private static var walletDrainCalls: [WalletDrainCallIdentity: Int] = [:] + + /// Preview a whole-wallet transparent drain without reserving inputs, + /// deriving addresses, signing, or broadcasting. Display amounts as estimates. + public func shieldedWalletDrainPreview( + walletId: Data, + fundingAccountIndex: UInt32 = 0 + ) async throws -> WalletDrainPreview { + try walletDrainReadPreflight(walletId: walletId) + return try await withWalletDrainNativeOperation("shieldedWalletDrainPreview") { handle in + try Self.withWalletDrainID(walletId) { walletPtr in + var preview = WalletDrainPreviewFFI() + try platform_wallet_manager_shielded_wallet_drain_preview( + handle, walletPtr, fundingAccountIndex, &preview + ).check() + return try WalletDrainPreview(ffi: preview) + } + } + } + + /// Drain the permitted transparent accounts into a single Orchard recipient. + /// Persist `operationId` before calling. A repeated UUID identifies the same + /// operation and must never be replaced with a new UUID after an unknown result. + /// The SDK determines the amount and durably associates the lock before dispatch. + public func shieldedFundFromWalletDrain( + walletId: Data, + operationId: UUID, + fundingAccountIndex: UInt32 = 0, + recipientRaw43: Data + ) async -> WalletDrainOutcome { + do { + try walletDrainReadPreflight(walletId: walletId) + guard recipientRaw43.count == 43 else { + throw PlatformWalletError.invalidParameter( + "recipientRaw43 must be exactly 43 bytes (was \(recipientRaw43.count))") + } + try admitNativeOp("shieldedFundFromWalletDrain") + } catch { + // The ID may belong to an earlier call. Local validation cannot + // establish that the persisted operation was never dispatched. + return WalletDrainOutcome( + state: .pending, outpoint: nil, error: Self.walletDrainError(error)) + } + + defer { finishNativeOp() } + let handle = self.handle + let callIdentity = WalletDrainCallIdentity( + manager: handle, walletId: walletId, operationId: operationId) + Self.walletDrainCalls[callIdentity, default: 0] += 1 + defer { + if let count = Self.walletDrainCalls[callIdentity], count > 1 { + Self.walletDrainCalls[callIdentity] = count - 1 + } else { + Self.walletDrainCalls.removeValue(forKey: callIdentity) + } + } + let coreSigner = MnemonicResolver() + return await Task.detached(priority: .userInitiated) { + do { + return try Self.withWalletDrainIDs(walletId, operationId: operationId) { walletPtr, operationPtr in + try recipientRaw43.withUnsafeBytes { raw in + guard let recipientPtr = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + throw PlatformWalletError.invalidParameter("recipient baseAddress is nil") + } + var outcome = WalletDrainOutcomeFFI() + // Fail closed even if a future FFI returns before initializing it. + outcome.state = WalletDrainOutcome.State.pending.rawValue + let ffiResult = withExtendedLifetime(coreSigner) { + platform_wallet_manager_shielded_fund_from_wallet_drain( + handle, walletPtr, operationPtr, fundingAccountIndex, + recipientPtr, coreSigner.handle, &outcome + ) + } + let result = PlatformWalletResult(ffiResult) + return WalletDrainOutcome( + ffi: outcome, + error: result.isSuccess ? nil : PlatformWalletError(result: result) + ) + } + } + } catch { + // These guards precede this call, but cannot disprove an + // earlier dispatch under the same operation UUID. + return WalletDrainOutcome( + state: .pending, outpoint: nil, error: Self.walletDrainError(error)) + } + }.value + } + + /// Read one operation by its UUID, including after restart. A durable-state + /// read failure throws; unavailable completion proofs leave it pending. + /// Neither is evidence that the operation was not sent. + /// Reconciliation may query authenticated network state and never broadcasts. + public func shieldedWalletDrainStatus( + walletId: Data, + operationId: UUID + ) async throws -> WalletDrainOutcome { + try walletDrainReadPreflight(walletId: walletId) + let handle = self.handle + let callIdentity = WalletDrainCallIdentity( + manager: handle, walletId: walletId, operationId: operationId) + let callActive = Self.walletDrainCalls[callIdentity] != nil + let native = try await withWalletDrainNativeOperation("shieldedWalletDrainStatus") { handle in + try Self.withWalletDrainIDs(walletId, operationId: operationId) { walletPtr, operationPtr in + var outcome = WalletDrainOutcomeFFI() + outcome.state = WalletDrainOutcome.State.pending.rawValue + try platform_wallet_manager_shielded_wallet_drain_status( + handle, walletPtr, operationPtr, &outcome + ).check() + return WalletDrainOutcome(ffi: outcome) + } + } + return callActive ? Self.walletDrainStatusDuringActiveCall(native: native) : native + } + + /// Rust owns the operation lifecycle, so the native snapshot is the answer + /// as soon as a detached execution has entered the Rust registry: an exact + /// outpoint, a durable completion of a repeated UUID and a read failure all + /// pass through. Only the interval before that, where Rust still reports + /// `notSent`, is reported as pending without an outpoint, so an in-flight + /// dispatch is never mistaken for "never sent". + nonisolated static func walletDrainStatusDuringActiveCall(native: WalletDrainOutcome) -> WalletDrainOutcome { + native.state == .notSent ? WalletDrainOutcome(state: .pending, outpoint: nil) : native + } + + /// Retain admission through detached native execution and result conversion. + /// Cancellation of the awaiting task does not release the captured handle early. + func withWalletDrainNativeOperation( + _ name: String, body: @escaping @Sendable (Handle) throws -> T + ) async throws -> T { + try admitNativeOp(name) + defer { finishNativeOp() } + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { + try body(handle) + }.value + } + + private func walletDrainReadPreflight(walletId: Data) throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle("PlatformWalletManager not configured") + } + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter("walletId must be exactly 32 bytes (was \(walletId.count))") + } + } + + nonisolated private static func walletDrainError(_ error: Error) -> PlatformWalletError { + error as? PlatformWalletError ?? .walletOperation(error.localizedDescription) + } + + nonisolated private static func withWalletDrainID( + _ walletId: Data, + _ body: (UnsafePointer) throws -> R + ) throws -> R { + try walletId.withUnsafeBytes { raw in + guard let walletPtr = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + } + return try body(walletPtr) + } + } + + nonisolated private static func withWalletDrainIDs( + _ walletId: Data, + operationId: UUID, + _ body: (UnsafePointer, UnsafePointer) throws -> R + ) throws -> R { + try withWalletDrainID(walletId) { walletPtr in + var uuid = operationId.uuid + return try withUnsafeBytes(of: &uuid) { raw in + guard let operationPtr = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + throw PlatformWalletError.invalidParameter("operationId baseAddress is nil") + } + return try body(walletPtr, operationPtr) + } + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index ed8f8e54e9f..17563d2b217 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -18,6 +18,13 @@ protocol ModelFetching: Sendable { ) throws -> [T] } +private enum AssetLockPersistenceError: Error { + case malformedRecord + case duplicateOperationId + case missingRecord + case requiresChangeset +} + struct LiveModelFetcher: ModelFetching { func fetch( _ descriptor: FetchDescriptor, @@ -537,7 +544,26 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let descriptor = FetchDescriptor( predicate: #Predicate { $0.outPointHex == outPointHex } ) - if let existing = try? backgroundContext.fetch(descriptor).first { + let existingRecord: PersistentAssetLock? + do { + existingRecord = try modelFetcher.fetch(descriptor, in: backgroundContext).first + if let operationId = entry.operationId { + guard operationId.count == 16 else { throw AssetLockPersistenceError.malformedRecord } + let sameOperation = FetchDescriptor(predicate: #Predicate { + $0.walletId == walletId && $0.operationId == operationId + }) + if try modelFetcher.fetch(sameOperation, in: backgroundContext).contains(where: { + $0.outPointHex != outPointHex + }) { throw AssetLockPersistenceError.duplicateOperationId } + if let previousId = existingRecord?.operationId, previousId != operationId { + throw AssetLockPersistenceError.duplicateOperationId + } + } + } catch { + allPersisted = false + continue + } + if let existing = existingRecord { // Consumed (4) is the terminal lifecycle state — never // let a non-Consumed snapshot regress it. Writers race: // the wallet-event adapter's batched drain can deliver a @@ -548,7 +574,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // rs-platform-wallet-storage sqlite upsert; all other // transitions stay last-write-wins because non-terminal // statuses legitimately move both ways. - if existing.statusRaw == 4 && entry.statusRaw != 4 { + if (existing.statusRaw == 6 && entry.statusRaw != 6) + || (existing.statusRaw == 4 && entry.statusRaw != 4 && entry.statusRaw != 6) { continue } existing.walletId = walletId @@ -559,6 +586,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { existing.amountDuffs = entry.amountDuffs existing.statusRaw = entry.statusRaw existing.proofBytes = entry.proofBytes + existing.operationId = entry.operationId ?? existing.operationId existing.updatedAt = Date() } else { let record = PersistentAssetLock( @@ -570,7 +598,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { accountIndexRaw: entry.accountIndexRaw, amountDuffs: entry.amountDuffs, statusRaw: entry.statusRaw, - proofBytes: entry.proofBytes + proofBytes: entry.proofBytes, + operationId: entry.operationId ) backgroundContext.insert(record) } @@ -609,7 +638,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let descriptor = FetchDescriptor( predicate: #Predicate { $0.outPointHex == hex } ) - if let existing = try? backgroundContext.fetch(descriptor).first { + let existingRecord: PersistentAssetLock? + do { + existingRecord = try modelFetcher.fetch(descriptor, in: backgroundContext).first + } catch { + allPersisted = false + continue + } + if let existing = existingRecord { // Same terminal rule as the upsert guard above: a // Consumed (4) row is deliberately retained for // historical lookup, and neither removal producer can @@ -623,7 +659,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // carries an upsert and a removal for the same // outpoint, so the upserts-then-removals order above is // layout, not load-bearing sequencing. - if existing.statusRaw == 4 { + if existing.statusRaw == 4 || existing.statusRaw == 6 { continue } backgroundContext.delete(existing) @@ -633,6 +669,52 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + /// UUID sidecar for the frozen legacy FFI row. The base upsert and this + /// association are saved together by endChangeset; a UUID can never change. + func persistAssetLockOperationId(walletId: Data, outPoint: Data, operationId: Data) throws { + try onQueue { + guard inChangeset else { throw AssetLockPersistenceError.requiresChangeset } + guard outPoint.count == 36, operationId.count == 16 else { + throw AssetLockPersistenceError.malformedRecord + } + let hex = PersistentAssetLock.encodeOutPoint(rawBytes: outPoint) + let descriptor = FetchDescriptor(predicate: #Predicate { + $0.walletId == walletId && $0.outPointHex == hex + }) + guard let record = try modelFetcher.fetch(descriptor, in: backgroundContext).first else { + throw AssetLockPersistenceError.missingRecord + } + if let previous = record.operationId, previous != operationId { + throw AssetLockPersistenceError.duplicateOperationId + } + let sameOperation = FetchDescriptor(predicate: #Predicate { + $0.walletId == walletId && $0.operationId == operationId + }) + guard try !modelFetcher.fetch(sameOperation, in: backgroundContext).contains(where: { + $0.outPointHex != hex + }) else { throw AssetLockPersistenceError.duplicateOperationId } + record.operationId = operationId + } + } + + /// A missing base row is a failed read, not evidence of a legacy nil UUID. + func loadAssetLockOperationId(walletId: Data, outPoint: Data) throws -> Data? { + try onQueue { + guard outPoint.count == 36 else { throw AssetLockPersistenceError.malformedRecord } + let hex = PersistentAssetLock.encodeOutPoint(rawBytes: outPoint) + let descriptor = FetchDescriptor(predicate: #Predicate { + $0.walletId == walletId && $0.outPointHex == hex + }) + guard let record = try modelFetcher.fetch(descriptor, in: backgroundContext).first else { + throw AssetLockPersistenceError.missingRecord + } + if let operationId = record.operationId, operationId.count != 16 { + throw AssetLockPersistenceError.malformedRecord + } + return record.operationId + } + } + /// Persist created/updated invitations (Sent-invitations bridge). Mirrors /// `persistAssetLocks`, simpler — POD entries, no owned buffers. Runs /// entirely on `onQueue`, body **inline** (never re-enter `onQueue` — a @@ -941,22 +1023,34 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// the wallet load path to rebuild `unused_asset_locks` on the /// Rust side so an in-flight registration that was interrupted by /// an app kill can resume from the latest status without - /// rebroadcasting the asset-lock transaction. - public func loadCachedAssetLocks(walletId: Data) -> [AssetLockEntrySnapshot] { - onQueue { loadCachedAssetLocksOnQueue(walletId: walletId) } + /// rebroadcasting the asset-lock transaction. Throws on unreadable or malformed + /// rows: an empty result must never hide a previously dispatched operation. + public func loadCachedAssetLocks(walletId: Data) throws -> [AssetLockEntrySnapshot] { + try onQueue { try loadCachedAssetLocksOnQueue(walletId: walletId) } } /// On-queue implementation reused by the load-wallet-list path /// without re-entering `onQueue`. - func loadCachedAssetLocksOnQueue(walletId: Data) -> [AssetLockEntrySnapshot] { + func loadCachedAssetLocksOnQueue(walletId: Data) throws -> [AssetLockEntrySnapshot] { let descriptor = FetchDescriptor( predicate: PersistentAssetLock.predicate(walletId: walletId) ) - guard let records = try? backgroundContext.fetch(descriptor) else { - return [] - } - return records.map { record in - AssetLockEntrySnapshot( + let records = try modelFetcher.fetch(descriptor, in: backgroundContext) + var operationIds = Set() + return try records.map { record in + guard decodeOutPointHex(record.outPointHex) != nil, + !record.transactionBytes.isEmpty, + (0...5).contains(record.fundingTypeRaw), + (0...6).contains(record.statusRaw) else { + throw AssetLockPersistenceError.malformedRecord + } + if let operationId = record.operationId { + guard operationId.count == 16 else { throw AssetLockPersistenceError.malformedRecord } + guard operationIds.insert(operationId).inserted else { + throw AssetLockPersistenceError.duplicateOperationId + } + } + return AssetLockEntrySnapshot( outPointHex: record.outPointHex, transactionBytes: record.transactionBytes, fundingTypeRaw: record.fundingTypeRaw, @@ -964,7 +1058,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { accountIndexRaw: record.accountIndexRaw, amountDuffs: record.amountDuffs, statusRaw: record.statusRaw, - proofBytes: record.proofBytes + proofBytes: record.proofBytes, + operationId: record.operationId ) } } @@ -983,6 +1078,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { public let amountDuffs: Int64 public let statusRaw: Int public let proofBytes: Data? + public var operationId: Data? = nil } /// Owned snapshot of an `InvitationEntryFFI` row. All-POD — the callback @@ -3160,6 +3256,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.trackedMasternodes | PlatformWalletPersistenceCapabilities.coreSweepRemoval | PlatformWalletPersistenceCapabilities.dashpayPayments + | PlatformWalletPersistenceCapabilities.assetLockOperationIds ) } @@ -3197,6 +3294,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { persistWalletChangesetUtxoVerdictsCallback extensionCallbacks.on_persist_identity_balance_block_time_fn = persistIdentityBalanceBlockTimeCallback extensionCallbacks.on_load_identity_balance_block_time_fn = loadIdentityBalanceBlockTimeCallback + extensionCallbacks.on_persist_asset_lock_operation_id_fn = persistAssetLockOperationIdCallback + extensionCallbacks.on_load_asset_lock_operation_id_fn = loadAssetLockOperationIdCallback return extensionCallbacks } @@ -7420,7 +7519,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // that was killed mid-flight can resume from the latest // status without rebroadcasting. Empty / null when the // wallet has no persisted locks. - let assetLockRows = loadCachedAssetLocksOnQueue(walletId: w.walletId) + let assetLockRows: [AssetLockEntrySnapshot] + do { + assetLockRows = try loadCachedAssetLocksOnQueue(walletId: w.walletId) + } catch { + // An unreadable operation ID must never look like "not sent". + allocation.release() + return (nil, 0, true) + } let (assetLockBuf, assetLockCount) = buildAssetLockRestoreBuffer( rows: assetLockRows, allocation: allocation @@ -10453,8 +10559,10 @@ private func persistAssetLocksCallback( removedCount: UInt ) -> Int32 { guard let context = context, - let walletIdPtr = walletIdPtr else { - return 0 + let walletIdPtr = walletIdPtr, + upsertsCount == 0 || upsertsPtr != nil, + removedCount == 0 || removedPtr != nil else { + return 1 } let handler = Unmanaged .fromOpaque(context) @@ -11751,6 +11859,37 @@ extension PlatformWalletPersistenceHandler { } } +private func persistAssetLockOperationIdCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, + outPoint: UnsafePointer?, operationId: UnsafePointer? +) -> Int32 { + guard let context, let walletId, let outPoint, let operationId else { return -1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + try handler.persistAssetLockOperationId( + walletId: Data(bytes: walletId, count: 32), outPoint: Data(bytes: outPoint, count: 36), + operationId: Data(bytes: operationId, count: 16)) + return 0 + } catch { return -1 } +} + +private func loadAssetLockOperationIdCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, outPoint: UnsafePointer?, + outFound: UnsafeMutablePointer?, outOperationId: UnsafeMutablePointer? +) -> Int32 { + guard let context, let walletId, let outPoint, let outFound, let outOperationId else { return -1 } + outFound.pointee = false + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + if let operationId = try handler.loadAssetLockOperationId( + walletId: Data(bytes: walletId, count: 32), outPoint: Data(bytes: outPoint, count: 36)) { + operationId.copyBytes(to: outOperationId, count: 16) + outFound.pointee = true + } + return 0 + } catch { return -1 } +} + private func persistIdentityBalanceBlockTimeCallback( context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, identityId: UnsafePointer?, blockTime: UnsafePointer? diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index b947a83272b..e7e746105e6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -262,6 +262,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { case errorIdentityBalanceUnavailable = 58 /// SPV startup or teardown panicked. Not retryable: restart the app. case errorSpvProcessRestartRequired = 59 + /// A whole-wallet asset lock exceeds the input cap; nothing was broadcast. + case errorAssetLockTooManyInputs = 60 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -391,6 +393,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorIdentityBalanceUnavailable case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SPV_PROCESS_RESTART_REQUIRED: self = .errorSpvProcessRestartRequired + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_TOO_MANY_INPUTS: + self = .errorAssetLockTooManyInputs case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -575,6 +579,8 @@ public enum PlatformWalletError: LocalizedError { /// The `available` / `required` duff figures are in the message. Kotlin /// parity: `DashSdkError.PlatformWallet.AssetLockInsufficientFunds`. case assetLockInsufficientFunds(String) + /// A drain cannot fit every eligible input in one transaction. No partial drain occurred. + case assetLockTooManyInputs(String) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition /// (identity-create or a spend — unshield / transfer / withdrawal) was @@ -799,6 +805,7 @@ public enum PlatformWalletError: LocalizedError { .coreInsufficientFunds(let m), .assetLockNotTracked(let m), .assetLockAlreadyConsumed(let m), .assetLockFundingMismatch(let m), .assetLockInsufficientFunds(let m), + .assetLockTooManyInputs(let m), .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .shieldedInsufficientBalance(let m), @@ -916,6 +923,7 @@ public enum PlatformWalletError: LocalizedError { case .errorAssetLockAlreadyConsumed: self = .assetLockAlreadyConsumed(detail) case .errorAssetLockFundingMismatch: self = .assetLockFundingMismatch(detail) case .errorAssetLockInsufficientFunds: self = .assetLockInsufficientFunds(detail) + case .errorAssetLockTooManyInputs: self = .assetLockTooManyInputs(detail) case .errorWalletAlreadyExists: self = .walletAlreadyExists(detail) case .errorShieldedBroadcastFailed: self = .shieldedBroadcastFailed(detail) case .errorShieldedBroadcastUnconfirmed: self = .shieldedBroadcastUnconfirmed(detail) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/PersistentAssetLockDisplay.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/PersistentAssetLockDisplay.swift index c6a8c78e591..967eb9b84c4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/PersistentAssetLockDisplay.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/PersistentAssetLockDisplay.swift @@ -83,6 +83,7 @@ extension PersistentAssetLock { // Core finality proven, Platform-side consumption unknown. // Rendered as "Unknown(5)" before this case existed. case 5: return "RecoveredFromChain" + case 6: return "DrainCompleted" default: return "Unknown(\(statusRaw))" } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift new file mode 100644 index 00000000000..f1dec9f0c87 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift @@ -0,0 +1,140 @@ +import Foundation +import SwiftData +import XCTest +@testable import SwiftDashSDK + +@MainActor +final class AssetLockOperationPersistenceTests: XCTestCase { + private let walletId = Data(repeating: 42, count: 32) + private let operationId = Data((0..<16).map(UInt8.init)) + private let outPointHex = String(repeating: "31", count: 32) + ":0" + + private func snapshot(status: Int, operationId: Data?) -> PlatformWalletPersistenceHandler.AssetLockEntrySnapshot { + .init(outPointHex: outPointHex, transactionBytes: Data([3, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + fundingTypeRaw: 5, identityIndexRaw: 0, accountIndexRaw: 0, + amountDuffs: 1000, statusRaw: status, proofBytes: nil, operationId: operationId) + } + + private var rawOutPoint: Data { Data(repeating: 0x31, count: 32) + Data(repeating: 0, count: 4) } + + private func persist(_ handler: PlatformWalletPersistenceHandler, status: Int, operationId: Data?) throws { + handler.beginChangeset(walletId: walletId) + XCTAssertTrue(handler.persistAssetLocks(walletId: walletId, + upserts: [snapshot(status: status, operationId: nil)], removed: [])) + if let operationId { + try handler.persistAssetLockOperationId(walletId: walletId, outPoint: rawOutPoint, operationId: operationId) + } + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + } + + func testOperationIdSurvivesConsumptionAndStoreReopen() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("wallet.store") + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try persist(handler, status: 0, operationId: operationId) + try persist(handler, status: 4, operationId: operationId) + // A stale legacy snapshot cannot erase the operation correlation. + try persist(handler, status: 4, operationId: nil) + } + let reopened = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: reopened, network: .testnet) + let rows = try handler.loadCachedAssetLocks(walletId: walletId) + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows.first?.operationId, operationId) + XCTAssertEqual(rows.first?.statusRaw, 4) + XCTAssertEqual(try handler.loadAssetLockOperationId(walletId: walletId, outPoint: rawOutPoint), operationId) + } + + func testOperationSidecarFailsClosedAndCannotChangeItsAssociation() throws { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertThrowsError(try handler.loadAssetLockOperationId(walletId: walletId, outPoint: rawOutPoint)) + XCTAssertThrowsError(try handler.persistAssetLockOperationId( + walletId: walletId, outPoint: rawOutPoint, operationId: operationId)) + try persist(handler, status: 0, operationId: operationId) + handler.beginChangeset(walletId: walletId) + XCTAssertThrowsError(try handler.persistAssetLockOperationId( + walletId: walletId, outPoint: rawOutPoint, operationId: Data(repeating: 9, count: 16))) + XCTAssertFalse(handler.endChangeset(walletId: walletId, success: false)) + XCTAssertEqual(try handler.loadAssetLockOperationId(walletId: walletId, outPoint: rawOutPoint), operationId) + } + + func testPublishedV3MigratesOperationIdToNil() throws { + _ = DashModelContainer.schema + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("wallet.store") + try autoreleasepool { + let schema = Schema(versionedSchema: DashSchemaV3.self) + let container = try ModelContainer(for: schema, configurations: [ + ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) + ]) + let context = ModelContext(container) + context.insert(DashSchemaSnapshotV3.PersistentAssetLock( + outPointHex: outPointHex, walletId: walletId, transactionBytes: Data([1]), + fundingTypeRaw: 5, identityIndexRaw: 0, accountIndexRaw: 0, + amountDuffs: 1000, statusRaw: 1)) + try context.save() + } + let migrated = try DashModelContainer.create(url: url) + let rows = try ModelContext(migrated).fetch(FetchDescriptor()) + XCTAssertEqual(rows.count, 1) + XCTAssertNil(rows.first?.operationId) + XCTAssertEqual(rows.first?.amountDuffs, 1000) + XCTAssertEqual(DashModelContainer.schema.version, Schema.Version(4, 0, 0)) + } + + func testMalformedOperationIdFailsLoadInsteadOfBecomingAbsent() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + context.insert(PersistentAssetLock(outPointHex: outPointHex, walletId: walletId, + transactionBytes: Data([1]), fundingTypeRaw: 5, identityIndexRaw: 0, + amountDuffs: 1000, statusRaw: 0, operationId: Data(repeating: 1, count: 15))) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertThrowsError(try handler.loadCachedAssetLocks(walletId: walletId)) + } + + func testShouldPreserveProvenDrainCompletionAfterReopenAndStaleSnapshot() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("wallet.store") + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try persist(handler, status: 4, operationId: operationId) + try persist(handler, status: 6, operationId: nil) + try persist(handler, status: 4, operationId: nil) + try persist(handler, status: 0, operationId: nil) + } + let handler = PlatformWalletPersistenceHandler( + modelContainer: try DashModelContainer.create(url: url), network: .testnet) + XCTAssertEqual(try handler.loadCachedAssetLocks(walletId: walletId).first?.statusRaw, 6) + XCTAssertEqual(try handler.loadAssetLockOperationId(walletId: walletId, outPoint: rawOutPoint), operationId) + } + + func testFetchFailureRejectsPersistenceAndRecoveryRead() throws { + struct FailingAssetLockFetcher: ModelFetching { + enum Failure: Error { case read } + func fetch(_ descriptor: FetchDescriptor, in context: ModelContext) throws -> [T] { + if T.self == PersistentAssetLock.self { throw Failure.read } + return try context.fetch(descriptor) + } + } + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, + network: .testnet, modelFetcher: FailingAssetLockFetcher()) + XCTAssertThrowsError(try handler.loadCachedAssetLocks(walletId: walletId)) + handler.beginChangeset(walletId: walletId) + XCTAssertFalse(handler.persistAssetLocks(walletId: walletId, + upserts: [snapshot(status: 0, operationId: operationId)], removed: [])) + XCTAssertFalse(handler.endChangeset(walletId: walletId, success: false)) + XCTAssertTrue(try ModelContext(container).fetch(FetchDescriptor()).isEmpty) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift index e54ed241b93..7aaafce9db8 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift @@ -326,8 +326,8 @@ final class AssetLockSpendVisibilityTests: XCTestCase { ) XCTAssertEqual( - injector.observedReads, ["PersistentTxo"], - "the faulted read must be the reconcile's stale-TXO fetch" + injector.observedReads, ["PersistentAssetLock", "PersistentTxo"], + "after reading the existing lock, the failed read is the stale-TXO reconcile" ) XCTAssertFalse( staged, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index 982d9ab0afd..d3c07c9f73f 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -11,15 +11,16 @@ private final class BridgeFutureMarker { var value: String = "future" init() {} } -private enum BridgeFutureV4: VersionedSchema { - static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) } - static var models: [any PersistentModel.Type] { DashSchemaV3.models + [BridgeFutureMarker.self] } +private enum BridgeFutureV5: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } + static var models: [any PersistentModel.Type] { DashSchemaV4.models + [BridgeFutureMarker.self] } } private enum BridgeFuturePlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, BridgeFutureV4.self] } + static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, BridgeFutureV5.self] } static var stages: [MigrationStage] { [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), - .custom(fromVersion: DashSchemaV3.self, toVersion: BridgeFutureV4.self, + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), + .custom(fromVersion: DashSchemaV4.self, toVersion: BridgeFutureV5.self, willMigrate: { context in for wallet in try context.fetch(FetchDescriptor()) { wallet.name = "explicit future transformation" @@ -770,7 +771,7 @@ final class DashLegacySchemaMigrationTests: XCTestCase { func testSkippingV3UsesFixedBridgeThenRegisteredCustomFutureStage() throws { try withStore { url in - let schema = Schema(versionedSchema: BridgeFutureV4.self) + let schema = Schema(versionedSchema: BridgeFutureV5.self) var checkedV3 = false let configuration = ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) XCTAssertThrowsError(try DashLegacySchemaBridge.open( @@ -887,7 +888,8 @@ final class DashLegacySchemaMigrationTests: XCTestCase { let container = try ModelContainer(for: schema, configurations: [ ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) ]) - try verifyRows(container.mainContext) + XCTAssertEqual(try container.mainContext.fetchCount( + FetchDescriptor()), 1) } XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["2.0.0"]) let container = try open(url, hooks: .init(visit: { _, _ in diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 4beee9638fa..ca12a56c435 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -78,8 +78,8 @@ final class DashModelMigrationTests: XCTestCase { XCTAssertTrue(log.contains("route=\"historical-v2-to-v3\"")) XCTAssertTrue(log.contains("source_version=\"2.0.0\"")) XCTAssertTrue(log.contains("source_checksum=\"\(sourceChecksum)\"")) - XCTAssertTrue(log.contains("target_version=\"3.0.0\"")) - XCTAssertTrue(log.contains("route=\"labelled-current-v3\"")) + XCTAssertTrue(log.contains("target_version=\"4.0.0\"")) + XCTAssertTrue(log.contains("route=\"labelled-current-v4\"")) XCTAssertEqual(log.components(separatedBy: "event=store_open_succeeded").count - 1, 2) XCTAssertFalse(log.contains("event=store_open_failed")) XCTAssertFalse(log.contains(directory.path)) @@ -95,7 +95,7 @@ final class DashModelMigrationTests: XCTestCase { let updatedLog = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) XCTAssertTrue(updatedLog.contains("route=\"new-store\"")) let failure = try XCTUnwrap(updatedLog.split(separator: "\n").first { $0.contains("event=store_open_failed") }) - XCTAssertTrue(failure.contains("target_version=\"3.0.0\"")) + XCTAssertTrue(failure.contains("target_version=\"4.0.0\"")) XCTAssertTrue(failure.contains("error_code=")) XCTAssertFalse(updatedLog.contains(directory.path)) XCTAssertFalse(updatedLog.contains("private-invalid-store-content")) @@ -106,7 +106,7 @@ final class DashModelMigrationTests: XCTestCase { /// either way. Labels whose route is undecidable without that probe /// (accepted V1 versus the bridge, historical V2 versus a beta layout) /// keep failing closed, leaving the store untouched. - func testCurrentV3RouteNeverDependsOnTheSchemaIdentityProbe() throws { + func testCurrentV4RouteNeverDependsOnTheSchemaIdentityProbe() throws { struct ProbeUnavailable: Error {} let failingProbe: (any VersionedSchema.Type) throws -> DashLegacySchemaBridge.Identity = { _ in throw ProbeUnavailable() @@ -117,7 +117,7 @@ final class DashModelMigrationTests: XCTestCase { defer { try? FileManager.default.removeItem(at: directory) } let current = directory.appendingPathComponent("current.store") try autoreleasepool { _ = try DashModelContainer.create(url: current) } - XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["3.0.0"]) + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["4.0.0"]) let plan = try DashModelContainer.migrationPlan( at: current, defaultPlan: DashMigrationPlan.self, identity: failingProbe) XCTAssertTrue(ObjectIdentifier(plan) == ObjectIdentifier(DashMigrationPlan.self)) @@ -513,7 +513,7 @@ final class DashModelMigrationTests: XCTestCase { try v1Container?.mainContext.save() v1Container = nil - let v2Schema = Schema(versionedSchema: DashSchemaV3.self) + let v2Schema = Schema(versionedSchema: DashSchemaV4.self) let v2Configuration = ModelConfiguration( "DashKeyLimitsMigrationTest", schema: v2Schema, @@ -590,7 +590,7 @@ final class DashModelMigrationTests: XCTestCase { try v1Container?.mainContext.save() v1Container = nil - let v2Schema = Schema(versionedSchema: DashSchemaV3.self) + let v2Schema = Schema(versionedSchema: DashSchemaV4.self) let v2Configuration = ModelConfiguration( "DashContractBoundsKindMigrationTest", schema: v2Schema, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index 4fd3c52c85c..7b51fce43d9 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -67,6 +67,7 @@ final class InvitationPersistenceTests: XCTestCase { // `PersistentDashpayPayment` rows, so the sweep's Failed flip // may ride this store's rounds — genuinely attested. | PlatformWalletPersistenceCapabilities.dashpayPayments + | PlatformWalletPersistenceCapabilities.assetLockOperationIds XCTAssertEqual( capabilities.version, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletShutdownTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletShutdownTests.swift index 78147dc379c..daeee6b9298 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletShutdownTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletShutdownTests.swift @@ -97,6 +97,46 @@ final class PlatformWalletShutdownTests: XCTestCase { XCTAssertEqual(third.totalMilliseconds, m1.totalMilliseconds) } + func testShouldHoldDrainAdmissionThroughDetachedResultConversion() async throws { + let recorder = TeardownRecorder() + let manager = PlatformWalletManager.makeForTesting(handle: 84, calls: Self.makeCalls(recorder: recorder)) + let entered = expectation(description: "Drain result conversion entered") + let stopEntered = expectation(description: "Early shutdown stop entered") + let gate = DispatchSemaphore(value: 0) + let call = Task { + try await manager.withWalletDrainNativeOperation("test drain") { handle in + XCTAssertEqual(handle, 84) + entered.fulfill() + XCTAssertEqual(gate.wait(timeout: .now() + 5), .success) + var ffi = WalletDrainPreviewFFI() + ffi.status = 0 + return try WalletDrainPreview(ffi: ffi) + } + } + await fulfillment(of: [entered], timeout: 1) + let shutdown = Task { + await manager.shutdown() + } + // Observe public handle revocation, which happens synchronously before + // shutdown waits for the admitted operation. No guessed sleep interval. + let subscription = manager.$isConfigured.dropFirst().sink { configured in + if !configured { stopEntered.fulfill() } + } + defer { gate.signal(); subscription.cancel() } + await fulfillment(of: [stopEntered], timeout: 1) + XCTAssertEqual(recorder.count(named: "destroy"), 0) + call.cancel() + XCTAssertEqual(recorder.count(named: "destroy"), 0) + do { + _ = try await manager.withWalletDrainNativeOperation("late drain") { _ in 0 } + XCTFail("Shutdown must reject new drain operations") + } catch {} + gate.signal() + _ = try await call.value + _ = await shutdown.value + XCTAssertEqual(recorder.count(named: "destroy"), 1) + } + // MARK: - Take-once handle func testShutdownConsumesHandleExactlyOnce() async { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift new file mode 100644 index 00000000000..2e6476b444d --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift @@ -0,0 +1,102 @@ +import XCTest +import DashSDKFFI +@testable import SwiftDashSDK + +final class WalletDrainTests: XCTestCase { + func testShouldKeepUnavailablePreviewAmountsAbsent() throws { + var ffi = WalletDrainPreviewFFI() + ffi.status = 2 + ffi.candidate_input_count = 501 + ffi.candidate_balance_duffs = .max + ffi.estimated_l1_fee_duffs = .max + ffi.estimated_recipient_credits = .max + let preview = try WalletDrainPreview(ffi: ffi) + XCTAssertEqual(preview.status, .inputLimitMayBeExceeded) + XCTAssertEqual(preview.candidateInputCount, 501) + XCTAssertEqual(preview.candidateBalanceDuffs, .max) + XCTAssertNil(preview.estimatedL1FeeDuffs) + XCTAssertNil(preview.estimatedRecipientCredits) + } + + func testShouldPreserveExactOutpointAndErrorForPendingOperation() { + var ffi = WalletDrainOutcomeFFI() + ffi.state = 1 + ffi.has_out_point = true + _ = withUnsafeMutableBytes(of: &ffi.out_point.txid) { + $0.initializeMemory(as: UInt8.self, repeating: 7) + } + ffi.out_point.vout = 3 + let outcome = WalletDrainOutcome( + ffi: ffi, error: .transactionBroadcastUnconfirmed("transport closed")) + XCTAssertEqual(outcome.state, .pending) + XCTAssertEqual(outcome.outpoint?.txidWire, Data(repeating: 7, count: 32)) + XCTAssertEqual(outcome.outpoint?.vout, 3) + guard case .transactionBroadcastUnconfirmed = outcome.error else { + return XCTFail("the typed ambiguity must survive alongside its outpoint") + } + } + + func testShouldTreatAnUnknownOutcomeStateAsPending() { + var ffi = WalletDrainOutcomeFFI() + ffi.state = 255 + let outcome = WalletDrainOutcome(ffi: ffi) + XCTAssertEqual(outcome.state, .pending) + XCTAssertNil(outcome.outpoint) + guard case .deserialization = outcome.error else { + return XCTFail("an unknown state must carry a decoding error") + } + } + + func testShouldPreserveTypedInputCapRefusal() { + let error = PlatformWalletError(code: .errorAssetLockTooManyInputs, message: "101 exceeds 100") + guard case .assetLockTooManyInputs(let detail) = error else { + return XCTFail("input-cap refusal lost its typed case") + } + XCTAssertEqual(detail, "101 exceeds 100") + XCTAssertEqual(PlatformWalletResultCode.errorAssetLockTooManyInputs.rawValue, 60) + } + + @MainActor + func testShouldRejectAnUnconfiguredManagerBeforeExecuting() async { + let manager = PlatformWalletManager() + let outcome = await manager.shieldedFundFromWalletDrain( + walletId: Data(repeating: 1, count: 32), + operationId: UUID(), + recipientRaw43: Data(repeating: 2, count: 43) + ) + XCTAssertEqual(outcome.state, .pending, "local validation cannot disprove an earlier dispatch of this UUID") + XCTAssertNil(outcome.outpoint) + guard case .invalidHandle = outcome.error else { + return XCTFail("an unconfigured manager cannot start a drain") + } + do { + _ = try await manager.shieldedWalletDrainStatus( + walletId: Data(repeating: 1, count: 32), operationId: UUID()) + XCTFail("an unavailable status read must throw, not return notSent") + } catch { + guard case PlatformWalletError.invalidHandle = error else { + return XCTFail("unexpected error: \(error)") + } + } + } + + func testShouldKeepNativeStatusAuthoritativeWhileACallIsActive() { + // Rust already registered or finished the operation: pass through. + let outpoint = WalletDrainOutpoint(txidWire: Data(repeating: 7, count: 32), vout: 3) + let pending = WalletDrainOutcome(state: .pending, outpoint: outpoint) + XCTAssertEqual( + PlatformWalletManager.walletDrainStatusDuringActiveCall(native: pending).outpoint, + outpoint + ) + let completed = WalletDrainOutcome(state: .completed, outpoint: outpoint) + XCTAssertEqual( + PlatformWalletManager.walletDrainStatusDuringActiveCall(native: completed).state, + .completed + ) + // Not yet in the Rust registry: an in-flight dispatch is pending, never "never sent". + let notSent = WalletDrainOutcome(state: .notSent, outpoint: nil) + let masked = PlatformWalletManager.walletDrainStatusDuringActiveCall(native: notSent) + XCTAssertEqual(masked.state, .pending) + XCTAssertNil(masked.outpoint) + } +}