From 115a081d5c84e6a160f1ee238f2c9c712903c4b1 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 12 Oct 2026 22:32:17 +0200 Subject: [PATCH 01/24] fix(wallet): drain Core balance with authenticated shielded recovery Pool eligible transparent inputs without a change output and expose preview, execution, and durable operation status through Swift and FFI. Persist operation identity before dispatch and retain every exact signed funding attempt. After restart, verify the signed request and reconcile its complete output set against authenticated shielded sync before durably consuming the asset lock. Resume replays exact bytes first. Preserve legacy ABI layouts and published storage schemas. Validated with 42 targeted core tests, 438 FFI tests (1 skipped), SQLite recovery tests, Swift runtime tests, release iOS device/simulator builds, and a funded iOS testnet process-death test followed by a successful new Max transfer. --- docs/sdk/sdk-parity-manifest.json | 80 ++ packages/kotlin-sdk/PARITY_SUMMARY.md | 15 +- .../dashsdk/errors/DashSdkError.kt | 5 + .../dashsdk/errors/DashSdkErrorTest.kt | 10 + .../ERROR_CODE_REGISTRY.md | 5 +- .../src/asset_lock_persistence.rs | 49 + packages/rs-platform-wallet-ffi/src/error.rs | 7 + packages/rs-platform-wallet-ffi/src/lib.rs | 4 + .../rs-platform-wallet-ffi/src/manager.rs | 143 ++- .../rs-platform-wallet-ffi/src/persistence.rs | 917 +++++++++++++++++- .../src/shielded_send.rs | 6 +- .../src/shielded_wallet_drain.rs | 379 ++++++++ .../V019__asset_lock_operation_id.rs | 12 + .../V020__shielded_funding_attempts.rs | 5 + .../src/sqlite/persister.rs | 2 + .../src/sqlite/schema/asset_locks.rs | 191 +++- .../tests/sqlite_asset_locks_filter.rs | 198 ++++ .../tests/sqlite_blob_roundtrip_coverage.rs | 2 + .../tests/sqlite_load_reconstruction.rs | 4 + .../tests/sqlite_persist_roundtrip.rs | 6 + .../tests/sqlite_recovery_mode.rs | 2 + .../tests/sqlite_schema_pinning.rs | 4 +- .../tests/sqlite_structural_hardening.rs | 2 + .../tests/sqlite_version_bump.rs | 2 + .../src/changeset/changeset.rs | 56 +- .../src/changeset/core_bridge.rs | 2 + .../src/changeset/persistence_capabilities.rs | 38 +- packages/rs-platform-wallet/src/error.rs | 4 + .../rs-platform-wallet/src/wallet/apply.rs | 4 + .../src/wallet/asset_lock/build.rs | 631 +++++++++++- .../src/wallet/asset_lock/manager.rs | 6 +- .../src/wallet/asset_lock/mod.rs | 4 + .../src/wallet/asset_lock/operation.rs | 348 +++++++ .../src/wallet/asset_lock/orchestration.rs | 46 + .../src/wallet/asset_lock/sync/proof.rs | 4 + .../wallet/asset_lock/sync/reconstruction.rs | 6 + .../src/wallet/asset_lock/sync/recovery.rs | 24 + .../src/wallet/asset_lock/tracked.rs | 6 + .../rs-platform-wallet/src/wallet/core/mod.rs | 2 + .../fund_from_asset_lock.rs | 5 + .../src/wallet/shielded/drain_recovery.rs | 855 ++++++++++++++++ .../wallet/shielded/fund_from_asset_lock.rs | 151 ++- .../src/wallet/shielded/mod.rs | 3 + .../src/wallet/shielded/wallet_drain.rs | 376 +++++++ .../Persistence/DashModelContainer.swift | 35 +- .../Models/PersistentAssetLock.swift | 14 +- .../PlatformWalletManager.swift | 11 +- ...PlatformWalletManagerShieldedFunding.swift | 2 +- .../PlatformWalletManagerWalletDrain.swift | 263 +++++ .../PlatformWalletPersistenceHandler.swift | 289 +++++- .../PlatformWallet/PlatformWalletResult.swift | 8 + .../AssetLockOperationPersistenceTests.swift | 169 ++++ .../AssetLockSpendVisibilityTests.swift | 4 +- .../DashLegacySchemaMigrationTests.swift | 16 +- .../DashModelMigrationTests.swift | 14 +- .../InvitationPersistenceTests.swift | 2 + .../SwiftDashSDKTests/WalletDrainTests.swift | 82 ++ 57 files changed, 5384 insertions(+), 146 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs create mode 100644 packages/rs-platform-wallet-storage/migrations/V019__asset_lock_operation_id.rs create mode 100644 packages/rs-platform-wallet-storage/migrations/V020__shielded_funding_attempts.rs create mode 100644 packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs create mode 100644 packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs create mode 100644 packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerWalletDrain.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index d5f32274deb..4309dabf482 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -4,11 +4,14 @@ "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_funding_attempts", "shielded_viewing_keys", + "tracked_asset_locks", "unsigned_token_storage", "wallet_restore" ], @@ -40,6 +43,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 +1610,80 @@ "covers_restart": false } ] + }, + { + "id": "shielded.core_wallet_drain", + "title": "Pooled Core-to-shielded Max with authenticated restart 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", + "shielded_funding_attempts", + "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 and append-only signed funding attempts. Restart recovery verifies exact signed outputs against authenticated shielded scan data before durably completing the operation. Unit tests cover store reopen, failed persistence, foreign or partial evidence, and earlier randomized retries. An iOS wallet testnet process-death test confirmed recovery after dispatch and enabled a subsequent Max transfer. Legacy test operations without saved attempts remain pending. 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, operation-ID callbacks, and signed-attempt persistence 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/drain_recovery.rs", + "id": "should_reconcile_drain_after_dispatch_crash_using_an_earlier_exact_attempt", + "command": "cargo test -p platform-wallet --features shielded --lib drain_recovery::tests", + "covers_restart": true + }, + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift", + "id": "testSignedFundingAttemptsAreAppendOnlyAndSurviveConsumedReopen", + "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 + } + ] } ] } 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 420ad1a6bbb..21c25394653 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 @@ -232,6 +232,10 @@ sealed class DashSdkError( class AssetLockInsufficientFunds(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** Code 59: 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 @@ -738,6 +742,7 @@ sealed class DashSdkError( 25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch 26 -> PlatformWallet.TransactionBroadcastRejected(message, cause) // ErrorTransactionBroadcastRejected 29 -> PlatformWallet.AssetLockInsufficientFunds(message, cause) // ErrorAssetLockInsufficientFunds + 59 -> 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 7129c55f633..403f03fb1c4 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 + 59, "501 exceeds 500") + 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 3399060f53b..ab143e5bda9 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,7 +114,9 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 59** — 27–58 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 60** — 59 is claimed by the pooled Core-to-shielded +wallet drain for `ErrorAssetLockTooManyInputs` (Rust, Swift and Kotlin mirrors). +27–58 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 @@ -172,6 +174,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 56 | `ErrorShieldedRecoveryCorrupted` | #4715 | Proposed — durable shielded recovery data is malformed or invalid; preserved for diagnosis. Rust, Swift and Kotlin preserve this typed error | | 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 | `ErrorAssetLockTooManyInputs` | Core-to-shielded pooled Max | Proposed — the full drain exceeds the builder input cap; no partial drain was broadcast. Rust, Swift and Kotlin retain the typed refusal | **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_persistence.rs b/packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs index fc0f4e0d5fe..512a89af316 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 { @@ -172,3 +174,50 @@ fn status_to_u8(status: &AssetLockStatus) -> u8 { AssetLockStatus::RecoveredFromChain => 5, } } + +#[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 5e7bc342baf..f22563b30cc 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -600,6 +600,10 @@ pub enum PlatformWalletFFIResultCode { /// is safe; this does not imply missing ownership or require registration. ErrorIdentityBalanceUnavailable = 58, + /// An all-input asset lock exceeds the builder's input cap. Nothing was + /// broadcast; the host must not silently substitute a partial drain. + ErrorAssetLockTooManyInputs = 59, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -1006,6 +1010,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 dc32a5957b6..0b40bd8eec7 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -79,6 +79,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; @@ -154,6 +156,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 20f0c421b20..82f147a3077 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,8 +8,10 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, FreeTrackedMasternodesFn, LoadIdentityBalanceBlockTimeFn, - LoadTrackedMasternodesFn, PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, + FFIPersister, FreeShieldedFundingAttemptsFn, FreeTrackedMasternodesFn, + LoadAssetLockOperationIdFn, LoadIdentityBalanceBlockTimeFn, LoadShieldedFundingAttemptsFn, + LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, PersistDpnsNameStatesFn, + PersistIdentityBalanceBlockTimeFn, PersistShieldedFundingAttemptFn, PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, PersistenceCallbacks, PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, @@ -277,6 +279,26 @@ unsafe fn persistence_extension_callbacks( on_load_identity_balance_block_time_fn, LoadIdentityBalanceBlockTimeFn ), + 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 + ), + persist_shielded_funding_attempt: slot!( + on_persist_shielded_funding_attempt_fn, + PersistShieldedFundingAttemptFn + ), + load_shielded_funding_attempts: slot!( + on_load_shielded_funding_attempts_fn, + LoadShieldedFundingAttemptsFn + ), + load_shielded_funding_attempts_free: slot!( + on_load_shielded_funding_attempts_free_fn, + FreeShieldedFundingAttemptsFn + ), } } @@ -1306,6 +1328,123 @@ 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()); + } + + #[test] + fn should_size_gate_shielded_funding_attempt_slots_independently() { + unsafe extern "C" fn persist( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *const u8, + _: u32, + _: *const u8, + _: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *mut *mut crate::persistence::ShieldedFundingAttemptFFI, + _: *mut usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn free( + _: *mut c_void, + _: *mut crate::persistence::ShieldedFundingAttemptFFI, + _: usize, + ) { + } + let mut ext = PersistenceCallbacksExtension { + on_persist_shielded_funding_attempt_fn: Some(persist), + on_load_shielded_funding_attempts_fn: Some(load), + on_load_shielded_funding_attempts_free_fn: Some(free), + ..Default::default() + }; + for (size, expected) in [ + ( + std::mem::size_of::(), + [true, true, true], + ), + ( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_shielded_funding_attempts_free_fn + ), + [true, true, false], + ), + ( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_shielded_funding_attempts_fn + ), + [true, false, false], + ), + ( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_shielded_funding_attempt_fn + ), + [false, false, false], + ), + ] { + ext.struct_size = size; + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert_eq!( + [ + parsed.persist_shielded_funding_attempt.is_some(), + parsed.load_shielded_funding_attempts.is_some(), + parsed.load_shielded_funding_attempts_free.is_some() + ], + expected + ); + } + } + /// 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 d95a1f31db8..271b70bfabf 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; @@ -154,6 +155,8 @@ 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; +pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_SHIELDED_FUNDING_ATTEMPTS: u64 = 1 << 14; /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately /// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by @@ -276,6 +279,62 @@ pub type LoadIdentityBalanceBlockTimeFn = unsafe extern "C" fn( out_block_time: *mut crate::types::BlockTime, ) -> 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; + +/// Host-owned exact signed transition bytes. The array order is the attempt index. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ShieldedFundingAttemptFFI { + pub bytes: *const u8, + pub bytes_len: usize, +} + +/// Persist one append-only attempt inside the active atomic changeset. An existing +/// index must contain exactly these bytes; an absent index must equal the count. +pub type PersistShieldedFundingAttemptFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_point: *const u8, + operation_id: *const u8, + attempt_index: u32, + bytes: *const u8, + bytes_len: usize, +) -> i32; + +/// Load all attempts, including consumed tombstones. Missing rows/read errors fail. +pub type LoadShieldedFundingAttemptsFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_point: *const u8, + out_entries: *mut *mut ShieldedFundingAttemptFFI, + out_count: *mut usize, +) -> i32; + +pub type FreeShieldedFundingAttemptsFn = unsafe extern "C" fn( + context: *mut c_void, + entries: *mut ShieldedFundingAttemptFFI, + count: usize, +); + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -406,6 +465,53 @@ pub struct PersistenceCallbacksExtension { out_block_time: *mut crate::types::BlockTime, ) -> 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, + >, + pub on_persist_shielded_funding_attempt_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_point: *const u8, + operation_id: *const u8, + attempt_index: u32, + bytes: *const u8, + bytes_len: usize, + ) -> i32, + >, + pub on_load_shielded_funding_attempts_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_point: *const u8, + out_entries: *mut *mut ShieldedFundingAttemptFFI, + out_count: *mut usize, + ) -> i32, + >, + pub on_load_shielded_funding_attempts_free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *mut ShieldedFundingAttemptFFI, + count: usize, + ), + >, } impl Default for PersistenceCallbacksExtension { @@ -423,6 +529,11 @@ impl Default for PersistenceCallbacksExtension { on_persist_wallet_changeset_utxo_verdicts_fn: None, on_persist_identity_balance_block_time_fn: None, on_load_identity_balance_block_time_fn: None, + on_persist_asset_lock_operation_id_fn: None, + on_load_asset_lock_operation_id_fn: None, + on_persist_shielded_funding_attempt_fn: None, + on_load_shielded_funding_attempts_fn: None, + on_load_shielded_funding_attempts_free_fn: None, } } } @@ -441,6 +552,11 @@ pub struct PersistenceExtensionCallbacks { pub wallet_changeset_utxo_verdicts: Option, pub persist_identity_balance_block_time: Option, pub load_identity_balance_block_time: Option, + pub persist_asset_lock_operation_id: Option, + pub load_asset_lock_operation_id: Option, + pub persist_shielded_funding_attempt: Option, + pub load_shielded_funding_attempts: Option, + pub load_shielded_funding_attempts_free: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1550,6 +1666,37 @@ 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 capabilities.contains(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS) + && self + .tracked_masternodes_callbacks + .persist_shielded_funding_attempt + .is_some() + && self + .tracked_masternodes_callbacks + .load_shielded_funding_attempts + .is_some() + && self + .tracked_masternodes_callbacks + .load_shielded_funding_attempts_free + .is_some() + { + capabilities = capabilities.union(PersistenceCapabilities::SHIELDED_FUNDING_ATTEMPTS); + } if self .tracked_masternodes_callbacks .persist_tracked_masternodes @@ -2443,6 +2590,67 @@ 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); + } + } + if !entry.shielded_funding_attempts.is_empty() { + let (Some(operation_id), Some(cb)) = ( + entry.operation_id, + self.tracked_masternodes_callbacks + .persist_shielded_funding_attempt, + ) else { + outcome.record(1); + continue; + }; + let out_point = outpoint_to_bytes(&entry.out_point); + for (index, attempt) in entry.shielded_funding_attempts.iter().enumerate() { + let Ok(index) = u32::try_from(index) else { + outcome.record(1); + break; + }; + if attempt.is_empty() { + outcome.record(1); + break; + } + let rc = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + out_point.as_ptr(), + operation_id.as_ptr(), + index, + attempt.as_ptr(), + attempt.len(), + ) + }; + if rc != 0 { + outcome.record(rc); + break; + } + } + } + } } // Send invitation changeset — DIP-13 sent-invitation records, one @@ -3186,7 +3394,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); } @@ -3196,7 +3409,41 @@ 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 + .tracked_masternodes_callbacks + .load_shielded_funding_attempts + { + let free = self + .tracked_masternodes_callbacks + .load_shielded_funding_attempts_free + .ok_or_else(|| { + PersistenceError::backend("missing shielded funding attempts free callback") + })?; + load_shielded_funding_attempts( + cb, + free, + 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() { @@ -5876,22 +6123,118 @@ 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(()) +} + +/// Own host buffers until copying finishes, including every failure path. +fn load_shielded_funding_attempts( + callback: LoadShieldedFundingAttemptsFn, + free: FreeShieldedFundingAttemptsFn, + context: *mut c_void, + wallet_id: &WalletId, + locks: &mut BTreeMap>, +) -> Result<(), PersistenceError> { + struct Guard { + free: FreeShieldedFundingAttemptsFn, + context: *mut c_void, + entries: *mut ShieldedFundingAttemptFFI, + count: usize, + } + impl Drop for Guard { + fn drop(&mut self) { + unsafe { (self.free)(self.context, self.entries, self.count) }; + } + } + for lock in locks.values_mut().flat_map(|locks| locks.values_mut()) { + let out_point = outpoint_to_bytes(&lock.out_point); + let mut entries = std::ptr::null_mut(); + let mut count = 0; + let rc = unsafe { + callback( + context, + wallet_id.as_ptr(), + out_point.as_ptr(), + &mut entries, + &mut count, + ) + }; + let _guard = Guard { + free, + context, + entries, + count, + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + "Loading shielded funding attempts failed", + )); + } + if count > isize::MAX as usize / std::mem::size_of::() + || (count != 0 && (entries.is_null() || lock.operation_id.is_none())) + { + return Err(PersistenceError::backend( + "invalid shielded funding attempt rows", + )); + } + let rows = if count == 0 { + &[] + } else { + unsafe { slice::from_raw_parts(entries, count) } + }; + let mut attempts = Vec::with_capacity(count); + for row in rows { + if row.bytes.is_null() || row.bytes_len == 0 || row.bytes_len > isize::MAX as usize { + return Err(PersistenceError::backend( + "invalid signed shielded funding attempt", + )); + } + attempts.push(unsafe { slice::from_raw_parts(row.bytes, row.bytes_len) }.to_vec()); + } + lock.shielded_funding_attempts = attempts; + } + 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< @@ -5900,9 +6243,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 @@ -5914,6 +6260,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| { @@ -5927,6 +6274,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 { @@ -5981,6 +6333,8 @@ fn build_unused_asset_locks( funding_type, identity_index: spec.identity_index, amount: spec.amount_duffs, + operation_id: None, + shielded_funding_attempts: Vec::new(), status, proof, }; @@ -8467,6 +8821,467 @@ 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 in [None, Some([0xA5; 16])] { + 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: platform_wallet::AssetLockStatus::Consumed, + proof: None, + operation_id, + shielded_funding_attempts: Vec::new(), + }; + 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, + platform_wallet::AssetLockStatus::Consumed + ); + 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_require_complete_shielded_funding_attempt_persistence_attestation() { + unsafe extern "C" fn persist_id( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *const u8, + ) -> i32 { + 0 + } + unsafe extern "C" fn load_id( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *mut bool, + _: *mut u8, + ) -> i32 { + 0 + } + unsafe extern "C" fn persist( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *const u8, + _: u32, + _: *const u8, + _: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *mut *mut ShieldedFundingAttemptFFI, + _: *mut usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn free(_: *mut c_void, _: *mut ShieldedFundingAttemptFFI, _: usize) {} + 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 extensions = PersistenceExtensionCallbacks { + persist_asset_lock_operation_id: Some(persist_id), + load_asset_lock_operation_id: Some(load_id), + persist_shielded_funding_attempt: Some(persist), + load_shielded_funding_attempts: Some(load), + load_shielded_funding_attempts_free: Some(free), + ..Default::default() + }; + let required = PersistenceCapabilities::SHIELDED_FUNDING_ATTEMPTS; + let capabilities = |declaration, extensions| { + FFIPersister::new_with_persistence_capabilities_and_extensions( + callbacks(), + declaration, + extensions, + ) + .persistence_capabilities() + }; + assert!(capabilities(required, extensions).contains(required)); + assert!(!capabilities(PersistenceCapabilities::NONE, extensions).contains(required)); + for incomplete in [ + PersistenceExtensionCallbacks { + persist_shielded_funding_attempt: None, + ..extensions + }, + PersistenceExtensionCallbacks { + load_shielded_funding_attempts: None, + ..extensions + }, + PersistenceExtensionCallbacks { + load_shielded_funding_attempts_free: None, + ..extensions + }, + PersistenceExtensionCallbacks { + load_asset_lock_operation_id: None, + ..extensions + }, + ] { + assert!(!capabilities(required, incomplete).contains(required)); + } + } + + #[test] + fn should_restore_signed_shielded_funding_attempts_and_free_on_failure() { + use dashcore::hashes::Hash; + struct Host { + rows: Vec, + freed: bool, + fail: bool, + persisted: Vec>, + committed: bool, + } + unsafe extern "C" fn load( + context: *mut c_void, + _: *const u8, + _: *const u8, + entries: *mut *mut ShieldedFundingAttemptFFI, + count: *mut usize, + ) -> i32 { + let host = &mut *context.cast::(); + *entries = host.rows.as_mut_ptr(); + *count = host.rows.len(); + if host.fail { + -1 + } else { + 0 + } + } + unsafe extern "C" fn free( + context: *mut c_void, + _: *mut ShieldedFundingAttemptFFI, + _: usize, + ) { + (*context.cast::()).freed = true; + } + let attempts = [vec![18, 1, 2, 3], vec![18, 4, 5, 6, 7]]; + let mut host = Host { + rows: attempts + .iter() + .map(|bytes| ShieldedFundingAttemptFFI { + bytes: bytes.as_ptr(), + bytes_len: bytes.len(), + }) + .collect(), + freed: false, + fail: false, + persisted: Vec::new(), + committed: false, + }; + let out_point = dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([3; 32]), + vout: 0, + }; + let tracked = platform_wallet::TrackedAssetLock { + out_point, transaction: dashcore::Transaction { version: 3, lock_time: 0, + input: vec![], output: vec![], special_transaction_payload: None }, + account_index: 0, funding_type: key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType::AssetLockShieldedAddressTopUp, + identity_index: 0, amount: 1000, status: platform_wallet::AssetLockStatus::Consumed, + proof: None, operation_id: Some([7; 16]), shielded_funding_attempts: Vec::new(), + }; + let mut locks = [(0, [(out_point, tracked)].into_iter().collect())] + .into_iter() + .collect(); + load_shielded_funding_attempts( + load, + free, + std::ptr::addr_of_mut!(host).cast(), + &[42; 32], + &mut locks, + ) + .unwrap(); + assert!(host.freed); + assert_eq!(locks[&0][&out_point].shielded_funding_attempts, attempts); + unsafe extern "C" fn persist_id( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *const u8, + ) -> i32 { + 0 + } + unsafe extern "C" fn persist_attempt( + context: *mut c_void, + _: *const u8, + _: *const u8, + operation: *const u8, + index: u32, + bytes: *const u8, + len: usize, + ) -> i32 { + let host = &mut *context.cast::(); + assert_eq!(slice::from_raw_parts(operation, 16), &[7; 16]); + if host.fail { + return -1; + } + assert_eq!(index as usize, host.persisted.len()); + host.persisted + .push(slice::from_raw_parts(bytes, len).to_vec()); + 0 + } + unsafe extern "C" fn end(context: *mut c_void, _: *const u8, success: bool) -> i32 { + (*context.cast::()).committed = success; + 0 + } + let writer = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: std::ptr::addr_of_mut!(host).cast(), + on_persist_asset_locks_fn: Some(noop_asset_locks), + on_changeset_end_fn: Some(end), + ..Default::default() + }, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + persist_asset_lock_operation_id: Some(persist_id), + persist_shielded_funding_attempt: Some(persist_attempt), + ..Default::default() + }, + ); + let changeset = platform_wallet::changeset::PlatformWalletChangeSet { + asset_locks: Some(platform_wallet::changeset::AssetLockChangeSet { + asset_locks: [( + out_point, + platform_wallet::changeset::AssetLockEntry::from(&locks[&0][&out_point]), + )] + .into_iter() + .collect(), + ..Default::default() + }), + ..Default::default() + }; + writer.store([42; 32], changeset.clone()).unwrap(); + assert_eq!(host.persisted, attempts); + assert!(host.committed); + host.fail = true; + assert!(writer.store([42; 32], changeset).is_err()); + assert!(!host.committed); + host.freed = false; + host.fail = true; + assert!(load_shielded_funding_attempts( + load, + free, + std::ptr::addr_of_mut!(host).cast(), + &[42; 32], + &mut locks + ) + .is_err()); + assert!(host.freed); + host.freed = false; + host.fail = false; + host.rows[0].bytes = std::ptr::null(); + assert!(load_shielded_funding_attempts( + load, + free, + std::ptr::addr_of_mut!(host).cast(), + &[42; 32], + &mut locks + ) + .is_err()); + assert!(host.freed); + } + + #[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 { @@ -8618,8 +9433,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, @@ -8705,6 +9520,56 @@ mod tests { PersistenceCallbacksExtension, on_load_identity_balance_block_time_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::offset_of!( + PersistenceCallbacksExtension, + on_persist_shielded_funding_attempt_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_shielded_funding_attempt_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_shielded_funding_attempts_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_shielded_funding_attempts_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_shielded_funding_attempts_free_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_shielded_funding_attempts_free_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( @@ -8763,6 +9628,14 @@ 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_SHIELDED_FUNDING_ATTEMPTS, + PersistenceCapabilities::SHIELDED_FUNDING_ATTEMPTS.bits() + ); assert_eq!( PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ACCOUNT_ADDRESS_POOLS, PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_FUNDING_INDICES diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index f0570b466d4..a79c859c9c3 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -712,7 +712,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, @@ -772,7 +772,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 { @@ -2240,7 +2240,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..1e72589fb84 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs @@ -0,0 +1,379 @@ +//! 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 (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, + }; + 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; + result +} + +/// Reconcile exact outputs from authenticated local scan data; never broadcasts. +/// +/// # 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, coordinator) = 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(&coordinator, 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, 59); + unsafe { crate::error::platform_wallet_ffi_result_free(&mut result) }; + } +} 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/migrations/V020__shielded_funding_attempts.rs b/packages/rs-platform-wallet-storage/migrations/V020__shielded_funding_attempts.rs new file mode 100644 index 00000000000..7fdf6729bff --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V020__shielded_funding_attempts.rs @@ -0,0 +1,5 @@ +//! Exact signed funding transitions live outside the frozen lifecycle codec. +pub fn migration() -> String { + "ALTER TABLE asset_locks ADD COLUMN shielded_funding_attempts_blob BLOB \ + CHECK (shielded_funding_attempts_blob IS NULL OR typeof(shielded_funding_attempts_blob) = 'blob');".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 fd865405121..7d40c9e7e7f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1319,6 +1319,8 @@ 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::SHIELDED_FUNDING_ATTEMPTS) .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..6d33bb4e4ca 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,27 @@ struct AssetLockEntryWire { // PUBLIC material only: asset-lock lifecycle reaching `lifecycle_blob`. impl_persistable_blob!(AssetLockEntryWire); +/// Separate versioned sidecar; never extend the published lifecycle wire type. +#[derive(Serialize, Deserialize)] +struct ShieldedFundingAttemptsWire { + version: u32, + attempts: Vec>, +} +impl_persistable_blob!(ShieldedFundingAttemptsWire); + +fn decode_attempts(bytes: Option<&[u8]>) -> Result>, WalletStorageError> { + let Some(bytes) = bytes else { + return Ok(Vec::new()); + }; + let wire: ShieldedFundingAttemptsWire = blob::decode(bytes)?; + if wire.version != 1 || wire.attempts.iter().any(Vec::is_empty) { + return Err(WalletStorageError::blob_decode( + "invalid shielded funding attempts", + )); + } + Ok(wire.attempts) +} + /// 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 +135,8 @@ impl AssetLockEntryWire { amount_duffs: self.amount_duffs, status: self.status, proof, + operation_id: None, + shielded_funding_attempts: Vec::new(), }) } } @@ -139,20 +163,60 @@ 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, shielded_funding_attempts_blob) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) \ 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), \ + shielded_funding_attempts_blob = excluded.shielded_funding_attempts_blob \ 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)?)?; - stmt.execute(params![ + let existing: Option<(Option>, Option>)> = tx.query_row( + "SELECT operation_id, shielded_funding_attempts_blob FROM asset_locks WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), &op_bytes[..]], + |row| Ok((row.get(0)?, row.get(1)?)), + ).optional()?; + let old_attempts = + decode_attempts(existing.as_ref().and_then(|(_, bytes)| bytes.as_deref()))?; + let incoming = &entry.shielded_funding_attempts; + if incoming.iter().any(Vec::is_empty) + || (!incoming.starts_with(&old_attempts) && !old_attempts.starts_with(incoming)) + { + return Err(WalletStorageError::blob_decode( + "shielded funding attempts cannot change", + )); + } + let attempts = if incoming.len() > old_attempts.len() { + incoming.clone() + } else { + old_attempts + }; + let operation_id = entry + .operation_id + .as_ref() + .map(|id| id.as_slice()) + .or_else(|| existing.as_ref().and_then(|(id, _)| id.as_deref())); + if !attempts.is_empty() && operation_id.is_none() { + return Err(WalletStorageError::blob_decode( + "shielded funding attempts require an operation ID", + )); + } + let attempts_blob = if attempts.is_empty() { + None + } else { + Some(blob::encode(&ShieldedFundingAttemptsWire { + version: 1, + attempts, + })?) + }; + let applied = stmt.execute(params![ wallet_id.as_slice(), &op_bytes[..], status_str(&entry.status), @@ -163,7 +227,17 @@ pub fn apply( entry.amount_duffs, )?, lifecycle_blob, + entry.operation_id.as_ref().map(|id| id.as_slice()), + attempts_blob, ])?; + if applied == 0 { + // The terminal lifecycle guard may suppress a stale status, + // but signed evidence must still be durable before dispatch. + tx.execute( + "UPDATE asset_locks SET operation_id = COALESCE(?3, operation_id), shielded_funding_attempts_blob = ?4 WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), &op_bytes[..], entry.operation_id.as_ref().map(|id| id.as_slice()), attempts_blob], + )?; + } } } if !cs.removed.is_empty() { @@ -256,11 +330,26 @@ fn decode_row( account_index: i64, blob_bytes: &[u8], typed_status: &str, + operation_id: Option>, + attempts_blob: 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()?; + entry.shielded_funding_attempts = decode_attempts(attempts_blob.as_deref())?; + if !entry.shielded_funding_attempts.is_empty() && entry.operation_id.is_none() { + return Err(WalletStorageError::blob_decode( + "shielded funding attempts require an operation ID", + )); + } 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 @@ -302,6 +391,8 @@ fn decode_row( amount: entry.amount_duffs, status: entry.status, proof: entry.proof, + operation_id: entry.operation_id, + shielded_funding_attempts: entry.shielded_funding_attempts, }; Ok((account_index, outpoint, tracked)) } @@ -317,7 +408,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, length(shielded_funding_attempts_blob), shielded_funding_attempts_blob \ FROM asset_locks WHERE wallet_id = ?1", )?; let mut rows = stmt.query(params![wallet_id.as_slice()])?; @@ -329,28 +420,40 @@ 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)?; + if let Some(size) = row.get::<_, Option>(7)? { + blob::check_size(size)?; + } + let attempts_blob: Option> = row.get(8)?; + let (acct, outpoint, tracked) = decode_row( + &op_bytes, + account_index, + &blob_bytes, + &status, + operation_id, + attempts_blob, + 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, length(shielded_funding_attempts_blob), shielded_funding_attempts_blob \ + 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 +464,21 @@ 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)?; + if let Some(size) = row.get::<_, Option>(7)? { + blob::check_size(size)?; + } + let attempts_blob: Option> = row.get(8)?; + let (acct, outpoint, tracked) = decode_row( + &op_bytes, + account_index, + &blob_bytes, + &status, + operation_id, + attempts_blob, + ctx, + )?; + if tracked.status == AssetLockStatus::Consumed && tracked.operation_id.is_none() { continue; } out.entry(acct).or_default().insert(outpoint, tracked); @@ -426,6 +541,8 @@ mod tests { account_index: 0, funding_type: AssetLockFundingType::IdentityRegistration, identity_index: 0, + operation_id: None, + shielded_funding_attempts: Vec::new(), amount_duffs: 1000, status, proof, @@ -463,6 +580,8 @@ mod tests { amount_duffs: tracked.amount, status: tracked.status, proof: tracked.proof, + operation_id: tracked.operation_id, + shielded_funding_attempts: tracked.shielded_funding_attempts, }) .expect("stored asset lock must load back") } @@ -543,9 +662,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 +732,8 @@ mod tests { account_index: 0, funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, + operation_id: None, + shielded_funding_attempts: Vec::new(), amount_duffs: 1000, status: AssetLockStatus::Built, proof: None, diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rs b/packages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rs index fda58682e27..e6c2a32b8b4 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rs @@ -34,6 +34,8 @@ fn entry(op: OutPoint, status: AssetLockStatus) -> AssetLockEntry { account_index: 0, funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, + operation_id: None, + shielded_funding_attempts: Vec::new(), amount_duffs: 1000, status, proof: None, @@ -144,3 +146,199 @@ 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!(locks[&0][&outpoint].shielded_funding_attempts.is_empty()); + 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_restore_append_only_signed_attempts_and_reject_replacement() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xA9); + ensure_wallet_meta(&persister, &w); + let outpoint = op(0x33); + let first = vec![18, 1, 2, 3]; + let second = vec![18, 4, 5, 6, 7]; + let store = |attempts: Vec>, status, id| { + let mut row = entry(outpoint, status); + row.operation_id = id; + row.shielded_funding_attempts = attempts; + persister.store( + w, + platform_wallet::changeset::PlatformWalletChangeSet { + asset_locks: Some(AssetLockChangeSet { + asset_locks: [(outpoint, row)].into_iter().collect(), + ..Default::default() + }), + ..Default::default() + }, + ) + }; + store( + vec![first.clone()], + AssetLockStatus::Broadcast, + Some([8; 16]), + ) + .unwrap(); + store( + vec![first.clone()], + AssetLockStatus::Consumed, + Some([8; 16]), + ) + .unwrap(); + // A delayed evidence snapshot preserves terminal status while appending bytes. + store( + vec![first.clone(), second.clone()], + AssetLockStatus::Broadcast, + Some([8; 16]), + ) + .unwrap(); + assert!(store(vec![vec![18, 9]], AssetLockStatus::Consumed, Some([8; 16])).is_err()); + // Legacy and stale prefixes cannot erase persisted attempts, even at terminal status. + store(Vec::new(), AssetLockStatus::Consumed, None).unwrap(); + store( + vec![first.clone()], + AssetLockStatus::Consumed, + Some([8; 16]), + ) + .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(); + assert_eq!( + locks[&0][&outpoint].shielded_funding_attempts, + vec![first, second] + ); + assert_eq!(locks[&0][&outpoint].status, AssetLockStatus::Consumed); + conn.execute( + "UPDATE asset_locks SET shielded_funding_attempts_blob = X'00'", + [], + ) + .unwrap(); + assert!( + asset_locks::load_unconsumed(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .is_err() + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_blob_roundtrip_coverage.rs b/packages/rs-platform-wallet-storage/tests/sqlite_blob_roundtrip_coverage.rs index 19c16add348..349474de9ba 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_blob_roundtrip_coverage.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_blob_roundtrip_coverage.rs @@ -52,6 +52,8 @@ fn entry(op: OutPoint, status: AssetLockStatus, proof: Option) - account_index: 0, funding_type: AssetLockFundingType::IdentityRegistration, identity_index: 0, + operation_id: None, + shielded_funding_attempts: Vec::new(), amount_duffs: 1000, status, proof, diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs b/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs index bfd04156869..27a2fb2fe15 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs @@ -1087,6 +1087,8 @@ fn tc_p4_005_load_asset_locks_bucketed() { account_index: account, funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, + operation_id: None, + shielded_funding_attempts: Vec::new(), amount_duffs: 1000, status: AssetLockStatus::Built, proof: None, @@ -1392,6 +1394,8 @@ fn tc_p4_008c_asset_locks_corruption_is_hard_error() { account_index: 0, funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, + operation_id: None, + shielded_funding_attempts: Vec::new(), amount_duffs: 1000, status: AssetLockStatus::Built, proof: None, diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs index 9757deee4de..e0955c2e3aa 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs @@ -406,6 +406,8 @@ fn tc010_asset_lock_roundtrip() { account_index: 5, funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 9, + operation_id: None, + shielded_funding_attempts: Vec::new(), amount_duffs: 12_345, status: AssetLockStatus::Built, proof: None, @@ -472,6 +474,8 @@ fn tc010b_recovered_from_chain_lock_roundtrip() { account_index: 0, funding_type: AssetLockFundingType::AssetLockShieldedAddressTopUp, identity_index: 0, + operation_id: None, + shielded_funding_attempts: Vec::new(), amount_duffs: 500_000, status: AssetLockStatus::RecoveredFromChain, proof: Some(dpp::prelude::AssetLockProof::Chain(ChainAssetLockProof { @@ -543,6 +547,8 @@ fn tc010c_stale_recovery_snapshot_cannot_regress_consumed_row() { account_index: 0, funding_type: AssetLockFundingType::IdentityRegistration, identity_index: 0, + operation_id: None, + shielded_funding_attempts: Vec::new(), amount_duffs: 1_000_000, status: status.clone(), proof: match status { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_recovery_mode.rs b/packages/rs-platform-wallet-storage/tests/sqlite_recovery_mode.rs index 3bb4c2a5fea..08f159a3e4e 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_recovery_mode.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_recovery_mode.rs @@ -290,6 +290,8 @@ fn seed_asset_lock_status_drift( account_index: 0, funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, + operation_id: None, + shielded_funding_attempts: Vec::new(), amount_duffs: 1_000, status: AssetLockStatus::Consumed, proof: None, diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs index 282a2f5abf1..4421094e100 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs +++ b/packages/rs-platform-wallet-storage/tests/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"; + "257836a39b1c58c0a2765bf48c328a4fbc5caefe10b28481d743761a67999f2a"; /// 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"; + "bbff5b9244da4c9db6567c3dd520276e69388f4a1851e488a1b44939ef3b8229"; /// 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/sqlite_structural_hardening.rs b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs index 5778b2694a0..1e534c92d2d 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs @@ -380,6 +380,8 @@ fn asset_lock_typed_vs_blob_mismatch_rejected() { account_index: 99, // blob says 99 funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, + operation_id: None, + shielded_funding_attempts: Vec::new(), amount_duffs: 1, status: AssetLockStatus::Built, proof: None, diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs b/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs index 17ad01521aa..50b1a36f3cb 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs @@ -366,6 +366,8 @@ fn asset_lock_changeset() -> AssetLockChangeSet { account_index: 0, funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, + operation_id: None, + shielded_funding_attempts: Vec::new(), 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 1fe4d1eb514..f7de237a548 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1430,6 +1430,12 @@ 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]>, + /// Exact signed shielded funding transitions, retained in broadcast order. + #[cfg_attr(feature = "serde", serde(default))] + pub shielded_funding_attempts: Vec>, /// The outpoint identifying this credit output (txid + vout). pub out_point: OutPoint, /// The full asset lock transaction. @@ -1471,10 +1477,29 @@ 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 { + for (out_point, mut entry) in other.asset_locks { + if entry.operation_id.is_none() { + entry.operation_id = self + .asset_locks + .get(&out_point) + .and_then(|previous| previous.operation_id); + } + if let Some(previous) = self.asset_locks.get(&out_point) { + // A stale snapshot cannot erase an already signed attempt. + let mut attempts = previous.shielded_funding_attempts.clone(); + for attempt in &entry.shielded_funding_attempts { + if !attempts.contains(attempt) { + attempts.push(attempt.clone()); + } + } + entry.shielded_funding_attempts = attempts; + } if entry.status != AssetLockStatus::Consumed { - if let Some(existing) = self.asset_locks.get(&out_point) { + if let Some(existing) = self.asset_locks.get_mut(&out_point) { if existing.status == AssetLockStatus::Consumed { + // Terminality guards lifecycle state, never discards + // exact signed evidence carried by a delayed snapshot. + existing.shielded_funding_attempts = entry.shielded_funding_attempts; continue; } } @@ -2520,6 +2545,8 @@ mod tests { vout: 0, }; let entry_with = |status: AssetLockStatus| AssetLockEntry { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point: outpoint, transaction: Transaction { version: 3, @@ -2550,6 +2577,29 @@ mod tests { "a non-Consumed snapshot must not replace the Consumed terminal" ); + // Signed evidence is append-only even when its status snapshot is stale. + folded + .asset_locks + .get_mut(&outpoint) + .unwrap() + .shielded_funding_attempts = vec![vec![18, 1]]; + let mut delayed = cs_with(AssetLockStatus::Broadcast); + delayed + .asset_locks + .get_mut(&outpoint) + .unwrap() + .shielded_funding_attempts = vec![vec![18, 1], vec![18, 2]]; + folded.merge(delayed); + folded.merge(cs_with(AssetLockStatus::Consumed)); + assert_eq!( + folded.asset_locks[&outpoint].shielded_funding_attempts, + vec![vec![18, 1], vec![18, 2]] + ); + assert_eq!( + folded.asset_locks[&outpoint].status, + AssetLockStatus::Consumed + ); + // The legitimate direction still lands. let mut folded = cs_with(AssetLockStatus::RecoveredFromChain); folded.merge(cs_with(AssetLockStatus::Consumed)); @@ -3204,6 +3254,8 @@ mod tests { m.insert( OutPoint::default(), AssetLockEntry { + operation_id: None, + shielded_funding_attempts: Vec::new(), 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 7d19ffe7372..84c9efa1487 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -6115,6 +6115,8 @@ mod tests { asset_locks.asset_locks.insert( out_point, AssetLockEntry { + operation_id: None, + shielded_funding_attempts: Vec::new(), 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..f0144600728 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -106,13 +106,22 @@ 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); + + /// Exact signed shielded funding attempts are append-only, atomically persisted + /// before dispatch and restored losslessly, including consumed tombstones. + pub const SHIELDED_FUNDING_ATTEMPTS: Self = Self(1 << 14); + /// 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 = 14; /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -131,6 +140,14 @@ 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 + | Self::SHIELDED_FUNDING_ATTEMPTS.0, + ); + pub const fn from_bits_retain(bits: u64) -> Self { Self(bits) } @@ -205,6 +222,14 @@ impl PersistenceCapabilities { PersistenceCapabilities::DASHPAY_PAYMENTS, "dashpay_payments", ), + ( + PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS, + "asset_lock_operation_ids", + ), + ( + PersistenceCapabilities::SHIELDED_FUNDING_ATTEMPTS, + "shielded_funding_attempts", + ), ]; KNOWN @@ -222,7 +247,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::SHIELDED_FUNDING_ATTEMPTS.bits() == 1u64 << PersistenceCapabilities::HIGHEST_DECLARED_BIT, "HIGHEST_DECLARED_BIT must name the highest declared capability bit" ); @@ -250,6 +275,15 @@ 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::SHIELDED_FUNDING_ATTEMPTS.bits(), + 0x4000 + ); + assert_eq!(PersistenceCapabilities::WALLET_DRAIN.bits(), 0x6285); 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 d87b3f1ba58..fc5eef870e5 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -581,6 +581,10 @@ pub enum PlatformWalletError { )] AssetLockInsufficientFunds { available: u64, required: u64 }, + /// A whole-balance asset lock cannot fit into one standard transaction. + #[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/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 0f4ec4a8c33..a82d0b94dac 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -356,6 +356,8 @@ impl PlatformWalletInfo { self.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: entry.operation_id, + shielded_funding_attempts: entry.shielded_funding_attempts.clone(), out_point: entry.out_point, transaction: entry.transaction, account_index: entry.account_index, @@ -719,6 +721,8 @@ mod tests { al_cs.asset_locks.insert( out_point, AssetLockEntry { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point, transaction: dashcore::Transaction { version: 3, 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..e48a3c2e25a 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,14 @@ 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::{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::{AssetLockOperationContext, AssetLockOperationState}; // --------------------------------------------------------------------------- // Asset lock transaction building @@ -913,6 +914,56 @@ impl AssetLockManager { funding_type: AssetLockFundingType, 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> { // Serialize build→persist so a concurrent build cannot interleave its // pool snapshot with ours. The snapshot is collected from live wallet @@ -1029,6 +1080,12 @@ impl AssetLockManager { } } + // Record the exact outpoint BEFORE any persistence callback can fail + // or panic. An uncertain store/flush keeps this context pending even + // though no network call has happened: restart may recover the row. + if let Some(operation) = operation { + operation.set(AssetLockOperationState::Pending, Some(out_point)); + } // Persist the funding account's address pool now that the build marked // its index used. These asset-lock accounts fund OP_RETURN-payload // credit outputs that never appear as on-chain UTXOs, so SPV can never @@ -1043,13 +1100,23 @@ impl AssetLockManager { // 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 => { + 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 operation.is_some() { + // Do not release an ambiguous durability write or expose its + // row to resume. The call-local context remains fail-closed. + return Err(PlatformWalletError::Persistence(format!( + "drain funding indices could not be made durable before dispatch: {e}" + ))); + } if funding_type == AssetLockFundingType::IdentityInvitation { // The pooled build reserved every selected input across its // contributing accounts under `reservation_token`. Nothing @@ -1089,26 +1156,42 @@ 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), + shielded_funding_attempts: Vec::new(), + out_point, + transaction: tx.clone(), + account_index: source_index, + funding_type, + identity_index, + amount: locked_amount_duffs, + status: AssetLockStatus::Built, + proof: None, + }; + if operation.is_some() { + // 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; + } 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 +1244,26 @@ 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() + }) + .and_then(|()| self.persister.flush()) + { + // 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}" + ))); + } + } 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 — @@ -1186,6 +1288,9 @@ impl AssetLockManager { ) .await; in_broadcast_pin.settle_released(); + if let Some(operation) = operation { + operation.set(AssetLockOperationState::NotSent, None); + } } else { // An untrack guard fired: the row advanced past `Built`, or // resume state says the transaction may already be live or @@ -1347,6 +1452,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 +1475,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 +1486,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, @@ -1461,9 +1572,23 @@ mod tests { stored: Mutex>, flushes: std::sync::atomic::AtomicUsize, fail_flush: bool, + fail_on_flush: Option, + restored_wallet: Option<(Wallet, ManagedWalletInfo)>, } 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())), + ..Default::default() + } + } + /// Outpoints queued for persisted-row deletion across all stored /// changesets. fn removed_outpoints(&self) -> Vec { @@ -1478,6 +1603,10 @@ mod tests { } impl PlatformWalletPersistence for CapturingPersistence { + fn persistence_capabilities(&self) -> crate::changeset::PersistenceCapabilities { + crate::changeset::PersistenceCapabilities::WALLET_DRAIN + } + fn store( &self, _wallet_id: WalletId, @@ -1491,16 +1620,31 @@ mod tests { } 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")); } Ok(()) } fn load(&self) -> Result { - Ok(ClientStartState::default()) + let mut state = ClientStartState::default(); + if let Some((wallet, info)) = &self.restored_wallet { + state.wallets.insert( + wallet.compute_wallet_id(), + ClientWalletStartState { + wallet: wallet.clone(), + wallet_info: info.clone(), + identity_manager: Default::default(), + unused_asset_locks: Default::default(), + unconfirmed_outgoing_txs: Vec::new(), + }, + ); + } + Ok(state) } } @@ -2737,7 +2881,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 +3826,437 @@ 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(), + WalletPersister::new(wallet_id, Arc::new(CapturingPersistence::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(_)) + )); + let operation = Arc::new(super::AssetLockOperationContext::new([81; 16])); + assert!(matches!( + manager.begin_wallet_drain(&operation).await, + Err(PlatformWalletError::Persistence(_)) + )); + 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_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])); + 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])); + assert_eq!( + manager.begin_wallet_drain(&replay).await.unwrap(), + Some(outpoint) + ); + let second = Arc::new(super::AssetLockOperationContext::new([12; 16])); + assert!(manager.begin_wallet_drain(&second).await.is_err()); + assert_eq!( + second.snapshot().state, + super::AssetLockOperationState::NotSent + ); + assert_eq!(manager.broadcaster.calls(), 1); + // Authenticated consumption, unlike an already-consumed report, is + // terminal and may be reported Completed by operation-ID lookup. + manager.consume_asset_lock(&outpoint).await.unwrap(); + assert_eq!( + manager + .wallet_drain_operation_status([11; 16]) + .await + .unwrap() + .state, + super::AssetLockOperationState::Completed + ); + } + + #[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_501_inputs_before_dispatch_and_allow_500() { + for count in [501, 500] { + 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 == 501 { + assert!(matches!( + result, + Err(PlatformWalletError::AssetLockTooManyInputs { + count: 501, + max: 500 + }) + )); + 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(), + 500 + ); + } + } + } + + #[tokio::test] + async fn should_wallet_drain_flush_failure_keep_exact_context_without_exposing_resume() { + let (wallet_manager, wallet_id, _, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let persistence = Arc::new(CapturingPersistence { + fail_flush: 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), + ); + let operation = Arc::new(super::AssetLockOperationContext::new([41; 16])); + 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!(operation.snapshot().out_point.is_some()); + assert_eq!( + operation.snapshot().state, + super::AssetLockOperationState::Pending + ); + assert_eq!( + manager + .wallet_drain_operation_status([41; 16]) + .await + .unwrap(), + operation.snapshot() + ); + let second = Arc::new(super::AssetLockOperationContext::new([42; 16])); + assert!(manager.begin_wallet_drain(&second).await.is_err()); + } + + #[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])); + 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])); + assert_eq!(manager.begin_wallet_drain(&second).await.unwrap(), None); + } + + #[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), + ..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])); + 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 + ); + } + + 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])); + 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..019653a4189 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,10 @@ //! Platform consumption. Shared across sub-wallets via `Arc`. mod build; +mod operation; +pub use operation::{ + AssetLockOperationContext, AssetLockOperationSnapshot, AssetLockOperationState, +}; pub mod lock_notify_handler; pub mod manager; pub mod orchestration; 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..1448ec9c7b2 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs @@ -0,0 +1,348 @@ +//! Exact, restart-safe association of a host drain request with its asset lock. + +#[cfg(any(feature = "shielded", test))] +use std::collections::BTreeSet; +#[cfg(any(feature = "shielded", test))] +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use dashcore::OutPoint; + +use crate::broadcaster::TransactionBroadcaster; +use crate::changeset::PersistenceCapabilities; +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, + snapshot: Mutex, +} + +impl AssetLockOperationContext { + pub fn new(operation_id: [u8; 16]) -> Self { + Self { + operation_id, + #[cfg(any(feature = "shielded", test))] + started: AtomicBool::new(false), + snapshot: Mutex::new(AssetLockOperationSnapshot { + state: AssetLockOperationState::Pending, + out_point: 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 }; + } +} + +fn snapshot_for_lock(lock: &TrackedAssetLock) -> AssetLockOperationSnapshot { + AssetLockOperationSnapshot { + state: if lock.status == AssetLockStatus::Consumed { + AssetLockOperationState::Completed + } else { + AssetLockOperationState::Pending + }, + out_point: Some(lock.out_point), + } +} + +impl AssetLockManager { + /// Retain every exact signed attempt before dispatch. Failed writes retain + /// the candidate in memory too: a backend may have committed before its + /// flush failed, so the next attempt must never reuse its append index. + #[cfg(feature = "shielded")] + pub(crate) async fn persist_wallet_drain_attempt( + &self, + out_point: OutPoint, + signed_attempt: Vec, + ) -> Result<(), PlatformWalletError> { + self.require_wallet_drain_persistence()?; + let candidate = { + 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)) + .ok_or(PlatformWalletError::AssetLockNotTracked(out_point))?; + if lock.operation_id.is_none() { + return Err(PlatformWalletError::Persistence( + "drain attempt has no operation UUID".into(), + )); + } + if lock.status == AssetLockStatus::Consumed { + return Err(PlatformWalletError::AssetLockAlreadyConsumed(out_point)); + } + if !lock.shielded_funding_attempts.contains(&signed_attempt) { + lock.shielded_funding_attempts.push(signed_attempt); + } + lock.clone() + }; + self.persist_drain_lock_snapshot(&candidate) + } + + /// The evidence remains on the terminal row. Commit the tombstone before + /// publishing completion, so a write failure remains safely retryable. + #[cfg(feature = "shielded")] + pub(crate) async fn complete_wallet_drain( + &self, + out_point: OutPoint, + operation_id: [u8; 16], + ) -> Result<(), PlatformWalletError> { + self.require_wallet_drain_persistence()?; + let mut candidate = self + .list_tracked_locks() + .await + .into_iter() + .find(|lock| lock.out_point == out_point && lock.operation_id == Some(operation_id)) + .ok_or(PlatformWalletError::AssetLockNotTracked(out_point))?; + candidate.status = AssetLockStatus::Consumed; + candidate.proof = None; + self.persist_drain_lock_snapshot(&candidate)?; + 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))?; + lock.status = AssetLockStatus::Consumed; + lock.proof = None; + self.resume_dispatch_claims + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&out_point); + Ok(()) + } + + #[cfg(feature = "shielded")] + fn persist_drain_lock_snapshot( + &self, + lock: &TrackedAssetLock, + ) -> Result<(), PlatformWalletError> { + let mut changes = crate::changeset::AssetLockChangeSet::default(); + changes.asset_locks.insert(lock.out_point, lock.into()); + self.persister + .store(crate::changeset::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())) + } + + 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() + ))); + } + 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 { + 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) { + self.drain_operations + .lock() + .unwrap_or_else(|error| error.into_inner()) + .entry(operation.operation_id) + .or_insert(operation); + } + + /// 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> { + self.register_wallet_drain_operation(Arc::clone(operation)); + 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); + return previous.out_point.map(Some).ok_or_else(|| { + PlatformWalletError::TransactionBroadcastUnconfirmed( + "drain operation remains unresolved; no new asset lock was built".into(), + ) + }); + } + AssetLockOperationState::NotSent => {} + } + // 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::Consumed); + let live_locks = self.list_tracked_locks().await; + let live_pending = live_locks + .iter() + .any(|lock| lock.operation_id.is_some() && lock.status != AssetLockStatus::Consumed); + 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::Consumed) + .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); + self.drain_operations + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(operation.operation_id, Arc::clone(operation)); + 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..806e4ce948d 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, @@ -1102,6 +1146,8 @@ mod tests { .insert( out_point, TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), 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..4cb858b7b9c 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,8 @@ mod chain_lock_wait_tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -2117,6 +2119,8 @@ mod chain_lock_wait_tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), 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 a14417dba53..ae83a74cdc8 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,8 @@ fn reconstruct_candidates( } let (status, proof) = recovered_status(record, out_point); Some(TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point, transaction: record.transaction.clone(), account_index, @@ -762,6 +764,8 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point, transaction: tx.clone(), account_index: 0, @@ -997,6 +1001,8 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), 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 7e886ea7da6..5219c522df7 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,8 @@ impl AssetLockManager { return; } let lock = TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point, transaction: tx, account_index, @@ -2171,6 +2173,8 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point, transaction: transaction.clone(), account_index: 0, @@ -2306,6 +2310,8 @@ mod tests { .insert( out_point, TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -2527,6 +2533,8 @@ mod tests { }; let out_point = OutPoint::new(tx.txid(), 0); let lock = TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point, transaction: tx, account_index: 0, @@ -2803,6 +2811,8 @@ mod tests { info.tracked_asset_locks.insert( self.out_point, TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point: self.out_point, transaction: self.transaction.clone(), account_index: 0, @@ -4313,6 +4323,8 @@ mod tests { .insert( out_point, TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -4826,6 +4838,8 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -4949,6 +4963,8 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -5451,6 +5467,8 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -5555,6 +5573,8 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -5833,6 +5853,8 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -5977,6 +5999,8 @@ mod tests { .insert( out_point, TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, 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..9256b18ae57 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs @@ -114,6 +114,10 @@ pub enum AssetLockStatus { /// 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]>, + /// Exact signed Type18 attempts, persisted before dispatch and retained on completion. + pub shielded_funding_attempts: Vec>, /// The outpoint identifying this credit output (txid + vout). pub out_point: OutPoint, pub transaction: Transaction, @@ -135,6 +139,8 @@ pub struct TrackedAssetLock { impl From<&TrackedAssetLock> for AssetLockEntry { fn from(lock: &TrackedAssetLock) -> Self { Self { + operation_id: lock.operation_id, + shielded_funding_attempts: lock.shielded_funding_attempts.clone(), 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/drain_recovery.rs b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs new file mode 100644 index 00000000000..72cce0bfdd0 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs @@ -0,0 +1,855 @@ +//! Restart recovery for a drain's exact Type18 attempts. Only proof-verified +//! note sightings close the crash-after-dispatch window; balances and remote +//! rejection messages never establish completion. + +use std::collections::{BTreeMap, BTreeSet}; + +use dash_sdk::platform::shielded::{try_decrypt_note, try_recover_outgoing_note}; +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::transition::put_settings::PutSettings; +use dash_sdk::query_types::ShieldedEncryptedNote; +use dashcore::blockdata::transaction::special_transaction::TransactionPayload; +use dashcore::hashes::{sha256d, Hash}; +use dashcore::OutPoint; +use dpp::balances::credits::CREDITS_PER_DUFF; +use dpp::identity::state_transition::AssetLockProved; +use dpp::serialization::{PlatformDeserializableUntrusted, PlatformSerializable, Signable}; +use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; +use dpp::state_transition::StateTransition; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + +use crate::error::{ + as_asset_lock_proof_cl_height_too_low, is_asset_lock_proof_transaction_height_invalid, + is_instant_lock_proof_invalid, PlatformWalletError, +}; +use crate::wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; +use crate::wallet::PlatformWallet; + +use super::keys::AccountViewingKeys; +use super::store::{ShieldedStore, SubwalletId}; +use super::NetworkShieldedCoordinator; + +fn invalid(reason: impl std::fmt::Display) -> PlatformWalletError { + PlatformWalletError::Persistence(format!("invalid wallet drain recovery evidence: {reason}")) +} + +/// Recover the complete nonzero output set from the locally signed request, +/// not from arbitrary activity metadata. The asset-lock signature authenticates +/// the outpoint and bundle together. Missing/wrong keys cannot yield success. +fn attempt_outputs( + lock: &TrackedAssetLock, + bytes: &[u8], + keys: &BTreeMap, +) -> Result, PlatformWalletError> { + if lock.operation_id.is_none() + || lock.funding_type != AssetLockFundingType::AssetLockShieldedAddressTopUp + || lock.transaction.txid() != lock.out_point.txid + { + return Err(invalid("wrong operation, funding role or transaction")); + } + let st = StateTransition::deserialize_from_bytes_untrusted(bytes).map_err(invalid)?; + let StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(v0)) = &st else { + return Err(invalid("expected a signed Type18 transition")); + }; + if v0.asset_lock_proof.out_point() != Some(lock.out_point) { + return Err(invalid( + "attempt belongs to a different asset-lock outpoint", + )); + } + let Some(TransactionPayload::AssetLockPayloadType(payload)) = + &lock.transaction.special_transaction_payload + else { + return Err(invalid("tracked transaction has no asset-lock payload")); + }; + let credit_output = payload + .credit_outputs + .get(lock.out_point.vout as usize) + .ok_or_else(|| invalid("asset-lock credit output is missing"))?; + if credit_output.value != lock.amount + || v0.value_balance == 0 + || lock + .amount + .checked_mul(CREDITS_PER_DUFF) + .is_none_or(|amount| v0.value_balance > amount) + { + return Err(invalid("attempt value does not match the tracked lock")); + } + let public_key_hash = credit_output + .script_pubkey + .p2pkh_public_key_hash_bytes() + .ok_or_else(|| invalid("asset-lock credit output is not P2PKH"))?; + let digest = sha256d::Hash::hash(&st.signable_bytes().map_err(invalid)?); + dashcore::signer::verify_hash_signature( + digest.as_byte_array(), + v0.signature.as_slice(), + public_key_hash, + ) + .map_err(invalid)?; + + let mut outputs = BTreeSet::new(); + let mut recovered_value = 0u64; + for action in &v0.actions { + let wire = ShieldedEncryptedNote { + cmx: action.cmx.to_vec(), + nullifier: action.nullifier.to_vec(), + cv_net: action.cv_net.to_vec(), + encrypted_note: action.encrypted_note.clone(), + }; + for (account, keys) in keys { + let note = try_decrypt_note(&keys.prepared_ivk, &wire) + .map(|(note, _)| note) + .or_else(|| { + try_recover_outgoing_note(&keys.outgoing_viewing_key, &wire) + .map(|(note, _, _)| note) + }); + if let Some(note) = note { + if note.value().inner() > 0 { + if !outputs.insert((*account, action.cmx)) { + return Err(invalid("duplicate output commitment")); + } + recovered_value = recovered_value + .checked_add(note.value().inner()) + .ok_or_else(|| invalid("output value overflow"))?; + } + break; + } + } + } + if outputs.is_empty() || recovered_value != v0.value_balance { + return Err(invalid( + "viewing keys cannot recover the complete funded output set", + )); + } + Ok(outputs) +} + +impl PlatformWallet { + /// Replay the newest byte-identical attempt first. A recognized proof + /// freshness rejection permits the normal same-outpoint proof rebuild; + /// every other verdict remains ambiguous until exact outputs are observed. + /// Returns true when an existing attempt still awaits authenticated scan. + pub(super) async fn replay_wallet_drain_attempt( + &self, + out_point: OutPoint, + settings: Option, + ) -> Result { + let Some(lock) = self + .asset_locks + .list_tracked_locks() + .await + .into_iter() + .find(|lock| lock.out_point == out_point && lock.operation_id.is_some()) + else { + return Ok(false); + }; + let Some(bytes) = lock.shielded_funding_attempts.last() else { + return Ok(false); + }; + let keys = self + .shielded_keys + .read() + .await + .clone() + .ok_or_else(|| invalid("shielded viewing keys are not bound"))?; + attempt_outputs(&lock, bytes, &keys)?; + // Also retries a prior failed store/flush before any network effect. + self.asset_locks + .persist_wallet_drain_attempt(out_point, bytes.clone()) + .await?; + let st = StateTransition::deserialize_from_bytes_untrusted(bytes).map_err(invalid)?; + if let Err(error) = st.broadcast(&self.sdk, settings).await { + if is_instant_lock_proof_invalid(&error) + || as_asset_lock_proof_cl_height_too_low(&error).is_some() + || is_asset_lock_proof_transaction_height_invalid(&error) + { + if let StateTransition::ShieldFromAssetLock(ref transition) = st { + self.asset_locks + .invalidate_rejected_chain_proof( + &out_point, + transition.asset_lock_proof(), + &error, + ) + .await; + } + return Ok(false); + } + tracing::info!(%out_point, %error, "exact drain replay remains unresolved until its outputs are observed"); + } + Ok(true) + } + + /// Called while holding the wallet's shield single-flight guard. + pub(super) async fn persist_drain_shielded_attempt( + &self, + out_point: OutPoint, + transition: &StateTransition, + ) -> Result<(), PlatformWalletError> { + let Some(lock) = self + .asset_locks + .list_tracked_locks() + .await + .into_iter() + .find(|lock| lock.out_point == out_point && lock.operation_id.is_some()) + else { + return Ok(()); + }; + let bytes = transition.serialize_to_bytes().map_err(invalid)?; + let keys = self.shielded_keys.read().await; + let keys = keys + .as_ref() + .ok_or_else(|| invalid("shielded viewing keys are not bound"))?; + attempt_outputs(&lock, &bytes, keys)?; + self.asset_locks + .persist_wallet_drain_attempt(out_point, bytes) + .await + } + + /// The store's note/OVK sightings come exclusively from authenticated sync. + /// Keep all attempts, including unsuccessful retries, until and after a + /// durable consumed tombstone exists. A malformed attempt fails closed. + pub(super) async fn reconcile_wallet_drain( + &self, + coordinator: &NetworkShieldedCoordinator, + out_point: OutPoint, + ) -> Result { + let Some(lock) = self + .asset_locks + .list_tracked_locks() + .await + .into_iter() + .find(|lock| lock.out_point == out_point && lock.operation_id.is_some()) + else { + return Ok(false); + }; + if lock.status == AssetLockStatus::Consumed { + return Ok(true); + } + if lock.shielded_funding_attempts.is_empty() { + return Ok(false); + } + let keys = self + .shielded_keys + .read() + .await + .clone() + .ok_or_else(|| invalid("shielded viewing keys are not bound"))?; + let attempts = lock + .shielded_funding_attempts + .iter() + .map(|bytes| attempt_outputs(&lock, bytes, &keys)) + .collect::, _>>()?; + let observed = { + let store = coordinator.store().read().await; + let mut observed = BTreeSet::new(); + for account in keys.keys() { + let id = SubwalletId::new(self.wallet_id(), *account); + for note in store.get_all_notes(id).map_err(invalid)? { + observed.insert((*account, note.cmx)); + } + for note in store.get_outgoing_notes(id).map_err(invalid)? { + observed.insert((*account, note.cmx)); + } + } + observed + }; + if !attempts.iter().any(|outputs| outputs.is_subset(&observed)) { + return Ok(false); + } + let operation_id = lock + .operation_id + .ok_or_else(|| invalid("operation UUID missing"))?; + self.asset_locks + .complete_wallet_drain(out_point, operation_id) + .await?; + tracing::info!(%out_point, "wallet drain completed from authenticated exact output sightings"); + Ok(true) + } + + pub(super) async fn reconcile_wallet_drains( + &self, + coordinator: &NetworkShieldedCoordinator, + ) -> Result<(), PlatformWalletError> { + for lock in self.asset_locks.list_tracked_locks().await { + if lock.operation_id.is_some() && lock.status != AssetLockStatus::Consumed { + self.reconcile_wallet_drain(coordinator, lock.out_point) + .await?; + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::broadcaster::SpvBroadcaster; + use crate::changeset::{ + AssetLockEntry, ClientStartState, ClientWalletStartState, PersistenceCapabilities, + PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + }; + use crate::events::PlatformEventManager; + use crate::spv::SpvRuntime; + use crate::test_support::funded_wallet_manager; + use crate::wallet::asset_lock::{AssetLockOperationContext, AssetLockOperationState}; + use crate::wallet::shielded::{FileBackedShieldedStore, OrchardKeySet, ShieldedNote}; + use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; + use dashcore::{Network, ScriptBuf, Transaction, TxOut}; + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + use dpp::prelude::AssetLockProof; + use dpp::shielded::SerializedAction; + use dpp::state_transition::shield_from_asset_lock_transition::methods::ShieldFromAssetLockTransitionMethodsV0; + use dpp::state_transition::shield_from_asset_lock_transition::v0::ShieldFromAssetLockTransitionV0; + use grovedb_commitment_tree::{ + DashMemo, Domain, ExtractedNoteCommitment, Note, NoteValue, Nullifier, OrchardDomain, + RandomSeed, Rho, ValueCommitTrapdoor, ValueCommitment, + }; + use key_wallet::account::StandardAccountType; + use key_wallet::wallet::ManagedWalletInfo; + use key_wallet::Wallet; + use rand::{rngs::OsRng, RngCore}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + use tokio::sync::Notify; + + fn encrypted_action(keys: &AccountViewingKeys) -> SerializedAction { + let recipient = keys.default_address; + let ovk = keys.outgoing_viewing_key.clone(); + let value_credits = 1_000; + let memo = [0; 36]; + let mut rng = OsRng; + + let (nf, rho) = loop { + let mut b = [0u8; 32]; + rng.fill_bytes(&mut b); + if let (Some(nf), Some(rho)) = ( + Nullifier::from_bytes(&b).into_option(), + Rho::from_bytes(&b).into_option(), + ) { + break (nf, rho); + } + }; + let rseed = loop { + let mut b = [0u8; 32]; + rng.fill_bytes(&mut b); + if let Some(rseed) = RandomSeed::from_bytes(b, &rho).into_option() { + break rseed; + } + }; + let value = NoteValue::from_raw(value_credits); + let note = Note::from_parts(recipient, value, rho, rseed) + .into_option() + .expect("valid note parts"); + let cmx = ExtractedNoteCommitment::from(note.commitment()); + + let rcv = loop { + let mut b = [0u8; 32]; + rng.fill_bytes(&mut b); + if let Some(rcv) = ValueCommitTrapdoor::from_bytes(b).into_option() { + break rcv; + } + }; + let cv = ValueCommitment::derive(value - NoteValue::from_raw(0), rcv); + + let ne = + grovedb_commitment_tree::OrchardNoteEncryption::::new(Some(ovk), note, memo); + let epk = OrchardDomain::::epk_bytes(ne.epk()); + let enc = ne.encrypt_note_plaintext(); + let out = ne.encrypt_outgoing_plaintext(&cv, &cmx, &mut rng); + + let mut encrypted_note = Vec::with_capacity(216); + encrypted_note.extend_from_slice(&epk.0); + encrypted_note.extend_from_slice(enc.as_ref()); + encrypted_note.extend_from_slice(&out); + assert_eq!(encrypted_note.len(), 216, "wire note must be 216 bytes"); + + SerializedAction { + cmx: cmx.to_bytes(), + nullifier: nf.to_bytes(), + cv_net: cv.to_bytes(), + rk: [0; 32], + spend_auth_sig: [0; 64], + encrypted_note, + } + } + + fn evidence_fixture() -> ( + TrackedAssetLock, + BTreeMap, + StateTransition, + ) { + let keys = OrchardKeySet::from_seed(&[0x42; 32], Network::Testnet, 0) + .unwrap() + .viewing_keys(); + let secret = dashcore::secp256k1::SecretKey::from_slice(&[0x11; 32]).unwrap(); + let public = dashcore::PublicKey::new(dashcore::secp256k1::PublicKey::from_secret_key( + &dashcore::secp256k1::Secp256k1::new(), + &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, + }); + let actions = vec![encrypted_action(&keys), encrypted_action(&keys)]; + // Actual encrypted outputs and ECDSA signature, with no expensive Halo2 + // proof: admission is outside this test; sightings model verified sync. + let st = ShieldFromAssetLockTransitionV0::try_from_asset_lock_with_bundle( + proof.clone(), + &secret.secret_bytes(), + actions, + 2_000, + [0; 32], + vec![], + [0; 64], + None, + dpp::version::PlatformVersion::latest(), + ) + .unwrap(); + ( + TrackedAssetLock { + operation_id: Some([0x33; 16]), + shielded_funding_attempts: Vec::new(), + 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)]), + st, + ) + } + + struct RecoveryPersistence { + wallet: Wallet, + info: ManagedWalletInfo, + committed: Mutex>, + staged: Mutex>, + fail_store: AtomicBool, + fail_flush: AtomicBool, + } + impl PlatformWalletPersistence for RecoveryPersistence { + 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); + } + Ok(()) + } + fn flush(&self, _wallet_id: [u8; 32]) -> Result<(), PersistenceError> { + if self.fail_flush.load(Ordering::SeqCst) { + return Err(PersistenceError::backend("flush failed")); + } + self.committed + .lock() + .unwrap() + .extend(self.staged.lock().unwrap().clone()); + Ok(()) + } + fn load(&self) -> Result { + let locks = self + .committed + .lock() + .unwrap() + .iter() + .map(|(out_point, row)| { + ( + *out_point, + TrackedAssetLock { + operation_id: row.operation_id, + shielded_funding_attempts: row.shielded_funding_attempts.clone(), + 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 { + 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() + }) + } + } + + 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), + }); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().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)), + ); + 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!("drain_recovery_{}", 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) + } + + 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)), + ); + *wallet.shielded_keys.write().await = old.shielded_keys.read().await.clone(); + wallet + } + + async fn observe( + coordinator: &NetworkShieldedCoordinator, + wallet_id: [u8; 32], + account: u32, + cmx: [u8; 32], + position: u64, + ) { + coordinator + .store() + .write() + .await + .save_note( + SubwalletId::new(wallet_id, account), + &ShieldedNote { + position, + cmx, + nullifier: [position as u8; 32], + block_height: 42, + is_spent: false, + value: 1_000, + note_data: vec![], + }, + ) + .unwrap(); + } + + #[tokio::test] + async fn should_reconcile_drain_after_dispatch_crash_using_an_earlier_exact_attempt() { + let (lock, keys, st) = evidence_fixture(); + let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); + let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys.clone()).await; + // Dispatch is permitted only after these exact signed bytes are durable. + wallet + .persist_drain_shielded_attempt(lock.out_point, &st) + .await + .unwrap(); + assert_eq!( + persistence.committed.lock().unwrap()[&lock.out_point].shielded_funding_attempts, + vec![st.serialize_to_bytes().unwrap()] + ); + // A later randomized retry must never erase the first dispatched attempt. + let (_, _, later) = evidence_fixture(); + wallet + .persist_drain_shielded_attempt(lock.out_point, &later) + .await + .unwrap(); + let wallet = restart_wallet(wallet, persistence.clone()).await; + assert_eq!( + wallet.asset_locks.list_tracked_locks().await[0] + .shielded_funding_attempts + .len(), + 2 + ); + assert_eq!( + wallet + .shielded_wallet_drain_operation_status(&coordinator, [0x33; 16]) + .await + .unwrap() + .state, + AssetLockOperationState::Pending + ); + // Another wallet/account, and even a partial matching set, cannot confirm. + for (index, (account, cmx)) in outputs.iter().enumerate() { + observe(&coordinator, [0x99; 32], *account, *cmx, index as u64).await; + observe(&coordinator, wallet.wallet_id(), 9, *cmx, index as u64).await; + } + assert!(!wallet + .reconcile_wallet_drain(&coordinator, lock.out_point) + .await + .unwrap()); + let (account, first) = outputs.iter().next().unwrap(); + observe(&coordinator, wallet.wallet_id(), *account, *first, 0).await; + assert!(!wallet + .reconcile_wallet_drain(&coordinator, lock.out_point) + .await + .unwrap()); + for (index, (account, cmx)) in outputs.iter().enumerate() { + observe( + &coordinator, + wallet.wallet_id(), + *account, + *cmx, + index as u64, + ) + .await; + } + let status = wallet + .shielded_wallet_drain_operation_status(&coordinator, [0x33; 16]) + .await + .unwrap(); + assert_eq!(status.state, AssetLockOperationState::Completed); + assert_eq!(status.out_point, Some(lock.out_point)); + assert_eq!( + persistence.committed.lock().unwrap()[&lock.out_point].status, + AssetLockStatus::Consumed + ); + let wallet = restart_wallet(wallet, persistence).await; + assert_eq!( + wallet + .shielded_wallet_drain_operation_status(&coordinator, [0x33; 16]) + .await + .unwrap() + .state, + AssetLockOperationState::Completed + ); + assert!( + wallet + .asset_locks + .begin_wallet_drain(&Arc::new(AssetLockOperationContext::new([0x44; 16]))) + .await + .unwrap() + .is_none(), + "a new explicitly requested drain is no longer blocked" + ); + } + + #[tokio::test] + async fn should_fail_closed_on_attempt_or_completion_persistence_failure() { + let (lock, keys, st) = evidence_fixture(); + let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); + let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; + persistence.fail_store.store(true, Ordering::SeqCst); + assert!(wallet + .persist_drain_shielded_attempt(lock.out_point, &st) + .await + .is_err()); + assert!(persistence.committed.lock().unwrap()[&lock.out_point] + .shielded_funding_attempts + .is_empty()); + persistence.fail_store.store(false, Ordering::SeqCst); + persistence.fail_flush.store(true, Ordering::SeqCst); + assert!(wallet + .persist_drain_shielded_attempt(lock.out_point, &st) + .await + .is_err()); + assert!(persistence.committed.lock().unwrap()[&lock.out_point] + .shielded_funding_attempts + .is_empty()); + persistence.fail_flush.store(false, Ordering::SeqCst); + wallet + .persist_drain_shielded_attempt(lock.out_point, &st) + .await + .unwrap(); + for (index, (account, cmx)) in outputs.iter().enumerate() { + observe( + &coordinator, + wallet.wallet_id(), + *account, + *cmx, + index as u64, + ) + .await; + } + persistence.fail_flush.store(true, Ordering::SeqCst); + assert!(wallet + .reconcile_wallet_drain(&coordinator, lock.out_point) + .await + .is_err()); + assert_ne!( + wallet.asset_locks.list_tracked_locks().await[0].status, + AssetLockStatus::Consumed + ); + assert_ne!( + persistence.committed.lock().unwrap()[&lock.out_point].status, + AssetLockStatus::Consumed + ); + persistence.fail_flush.store(false, Ordering::SeqCst); + assert!(wallet + .reconcile_wallet_drain(&coordinator, lock.out_point) + .await + .unwrap()); + } + + #[test] + fn should_reject_foreign_corrupted_unsigned_or_incomplete_drain_evidence() { + let (lock, keys, st) = evidence_fixture(); + let bytes = st.serialize_to_bytes().unwrap(); + assert!(attempt_outputs(&lock, &bytes, &keys).is_ok()); + assert!(attempt_outputs(&lock, b"not a transition", &keys).is_err()); + let mut foreign = lock.clone(); + foreign.out_point.vout += 1; + assert!(attempt_outputs(&foreign, &bytes, &keys).is_err()); + let mut unrelated = lock.clone(); + unrelated.operation_id = None; + assert!(attempt_outputs(&unrelated, &bytes, &keys).is_err()); + let mut wrong_role = lock.clone(); + wrong_role.funding_type = AssetLockFundingType::IdentityRegistration; + assert!(attempt_outputs(&wrong_role, &bytes, &keys).is_err()); + let other_keys = BTreeMap::from([( + 0, + OrchardKeySet::from_seed(&[0x88; 32], Network::Testnet, 0) + .unwrap() + .viewing_keys(), + )]); + assert!(attempt_outputs(&lock, &bytes, &other_keys).is_err()); + let mut altered = st.clone(); + if let StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(v0)) = + &mut altered + { + v0.value_balance += 1; + } + assert!(attempt_outputs(&lock, &altered.serialize_to_bytes().unwrap(), &keys).is_err()); + let mut unsigned = st; + if let StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(v0)) = + &mut unsigned + { + v0.signature = Default::default(); + } + assert!(attempt_outputs(&lock, &unsigned.serialize_to_bytes().unwrap(), &keys).is_err()); + } + + #[tokio::test] + async fn should_leave_legacy_drain_without_evidence_pending_even_with_matching_balance() { + let (lock, keys, st) = evidence_fixture(); + let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); + let (wallet, _, coordinator, _dir) = wallet_fixture(&lock, keys).await; + for (index, (account, cmx)) in outputs.iter().enumerate() { + observe( + &coordinator, + wallet.wallet_id(), + *account, + *cmx, + index as u64, + ) + .await; + } + assert!(!wallet + .reconcile_wallet_drain(&coordinator, lock.out_point) + .await + .unwrap()); + assert_eq!( + wallet + .shielded_wallet_drain_operation_status(&coordinator, [0x33; 16]) + .await + .unwrap() + .state, + AssetLockOperationState::Pending + ); + } +} 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 097fd169f45..cf882569124 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 @@ -36,6 +36,7 @@ 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; @@ -141,6 +142,112 @@ 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, + }; + if let Some(operation) = &operation { + self.register_wallet_drain_operation(operation.clone()); + } + let _shield_guard = self.shield_guard.lock().await; + if operation.is_some() { + self.reconcile_wallet_drains(coordinator).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 + }; + // A generic exact-outpoint resume can resolve the same durable drain. + let resumed_outpoint = match &funding { + AssetLockFunding::FromExistingAssetLock { out_point, .. } => Some(*out_point), + _ => None, + }; + if let Some(out_point) = resumed_outpoint { + if self.reconcile_wallet_drain(coordinator, out_point).await? { + if let Some(operation) = &operation { + operation.set(AssetLockOperationState::Completed, Some(out_point)); + } + return Ok(()); + } + if self + .replay_wallet_drain_attempt(out_point, settings) + .await? + { + if self.reconcile_wallet_drain(coordinator, out_point).await? { + if let Some(operation) = &operation { + operation.set(AssetLockOperationState::Completed, Some(out_point)); + } + return Ok(()); + } + return Err(PlatformWalletError::TransactionBroadcastUnconfirmed( + "original drain attempt replayed; waiting for authenticated output confirmation".into(), + )); + } + } + let mut result = self + .shielded_fund_from_asset_lock_inner( + coordinator, + funding, + recipients, + asset_lock_signer, + prover, + surplus_output, + dummy_outputs, + settings, + cl_wait, + ) + .await; + // A scan may have completed during the network wait. Only exact + // persisted outputs can upgrade an ambiguous/already-consumed reply. + if result.is_err() { + let out_point = resumed_outpoint + .or_else(|| operation.as_ref().and_then(|op| op.snapshot().out_point)); + if let Some(out_point) = out_point { + if self.reconcile_wallet_drain(coordinator, out_point).await? { + result = Ok(()); + } + } + } + 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 +317,21 @@ impl PlatformWallet { minimum_lock_duffs: Some(minimum_lock_duffs), } } + AssetLockFunding::DrainWalletBalance { + account_index, + operation, + .. + } => { + 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` — @@ -377,6 +491,7 @@ impl PlatformWallet { // them on success. let (submit_result, effective_proof) = match submit_with_cl_height_retry(settings, |s| { build_and_broadcast_shielded( + self, sdk.clone(), recipient, shield_amount, @@ -415,6 +530,7 @@ impl PlatformWallet { self.asset_locks.queue_asset_lock_changeset(cs); let submit_result = submit_with_cl_height_retry(settings, |s| { build_and_broadcast_shielded( + self, sdk.clone(), recipient, shield_amount, @@ -470,6 +586,19 @@ impl PlatformWallet { // address-funding, which writes proof-attested balances back // into `ManagedPlatformAccount`). if let Some(out_point) = tracked_out_point { + if let Some(operation_id) = self + .asset_locks + .list_tracked_locks() + .await + .into_iter() + .find(|lock| lock.out_point == out_point) + .and_then(|lock| lock.operation_id) + { + self.asset_locks + .complete_wallet_drain(out_point, operation_id) + .await?; + return Ok(()); + } // 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 @@ -705,6 +834,7 @@ async fn lookup_asset_lock_value_credits( /// scan will recover). #[allow(clippy::too_many_arguments)] async fn build_and_broadcast_shielded( + wallet: &PlatformWallet, sdk: std::sync::Arc, recipient: OrchardAddress, shield_amount: Credits, @@ -724,6 +854,7 @@ where use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; use dpp::state_transition::StateTransition; + let out_point = out_point_from_proof(&proof); let st = build_shield_from_asset_lock_transition_with_signer( &recipient, shield_amount, @@ -748,6 +879,14 @@ where _ => Vec::new(), }; + // This is the last fallible local step before dispatch. Every rebuilt CL + // retry appends its own signed bytes, so a prior successful attempt remains + // identifiable after process death or a later unauthenticated rejection. + wallet + .persist_drain_shielded_attempt(out_point, &st) + .await + .map_err(|error| dash_sdk::Error::Generic(error.to_string()))?; + // Wait for the verified result rather than relay-ACK. Single-use // asset-lock proof: a false-positive on a transition Platform // later rejects would strand the L1 outpoint with no in-app @@ -997,6 +1136,8 @@ mod tests { info.tracked_asset_locks.insert( out_point, TrackedAssetLock { + operation_id: None, + shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index df2b4fa1ddc..ce1794c7593 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -31,12 +31,15 @@ //! `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; #[cfg(test)] mod balance_tests; pub mod coordinator; +mod drain_recovery; pub mod file_store; pub mod fund_from_asset_lock; pub mod keys; 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..259ca56bc66 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs @@ -0,0 +1,376 @@ +//! 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::{AssetLockOperationContext, 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) { + self.asset_locks.register_wallet_drain_operation(operation); + } + + pub async fn shielded_wallet_drain_operation_status( + &self, + coordinator: &super::NetworkShieldedCoordinator, + operation_id: [u8; 16], + ) -> Result { + let Ok(_shield_guard) = self.shield_guard.try_lock() else { + // A status read must not wait behind a network submission. The + // active operation's context already provides a fail-closed view. + return self + .asset_locks + .wallet_drain_operation_status(operation_id) + .await; + }; + let snapshot = self + .asset_locks + .wallet_drain_operation_status(operation_id) + .await?; + if snapshot.state == crate::wallet::asset_lock::AssetLockOperationState::Pending { + if let Some(out_point) = snapshot.out_point { + self.reconcile_wallet_drain(coordinator, out_point).await?; + } + } + 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, + }; + // 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)), + ) + } + + #[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; 501]).await; + let quote = large.shielded_wallet_drain_preview(0, 0).await.unwrap(); + assert_eq!( + quote.status, + WalletDrainPreviewStatus::InputLimitMayBeExceeded + ); + assert_eq!(quote.candidate_input_count, 501); + 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)), + ); + 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/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..05392e03d96 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift @@ -200,6 +200,16 @@ 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? + + /// Versioned binary property list of exact signed Type18 transitions. Rust + /// owns construction and recovery; this append-only sidecar stores bytes. + /// Nil on published V3 rows upgraded to the current, unshipped V4 schema. + public var shieldedFundingAttemptsBlob: Data? + /// Record timestamps. public var createdAt: Date public var updatedAt: Date @@ -213,7 +223,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 +235,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/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index b5551dae598..f0adc91dd19 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -107,6 +107,9 @@ 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 static let shieldedFundingAttempts: UInt64 = 1 << 14 public let version: UInt32 public let bits: UInt64 @@ -2345,7 +2348,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/PlatformWalletManagerShieldedFunding.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift index cc1f219e19f..417db7ec4e8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift @@ -458,7 +458,7 @@ extension PlatformWalletManager { /// produces a synchronous, type-specific error before paying /// for the `Task.detached` setup, the resolver allocation, and /// the (potentially long-running) Halo 2 proof build. - private func shieldedFundFromAssetLockPreflight( + func shieldedFundFromAssetLockPreflight( walletId: Data, recipients: [ShieldedFundFromAssetLockRecipient] ) throws { 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..d09e74fa9e4 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerWalletDrain.swift @@ -0,0 +1,263 @@ +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) + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { + 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) + } + }.value + } + + /// 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, + recipients: [ShieldedFundFromAssetLockRecipient] + ) async -> WalletDrainOutcome { + do { + try shieldedFundFromAssetLockPreflight(walletId: walletId, recipients: recipients) + } 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)) + } + + 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 recipient = recipients[0].recipientRaw43 + let coreSigner = MnemonicResolver() + return await Task.detached(priority: .userInitiated) { + do { + return try Self.withWalletDrainIDs(walletId, operationId: operationId) { walletPtr, operationPtr in + try recipient.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 read failure + /// throws; it is never converted into evidence that the operation was not sent. + 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) + if Self.walletDrainCalls[callIdentity] != nil { + return WalletDrainOutcome(state: .pending, outpoint: nil) + } + return try await Task.detached(priority: .userInitiated) { + 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) + } + }.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 d9ffdea6697..a2640bcf93f 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 @@ -559,6 +585,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 +597,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 +637,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 @@ -633,6 +668,113 @@ 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 + } + } + + private struct ShieldedFundingAttemptsArchive: Codable { + let version: UInt32 + var attempts: [Data] + } + + private static func decodeShieldedFundingAttempts(_ record: PersistentAssetLock) throws -> [Data] { + guard let blob = record.shieldedFundingAttemptsBlob else { return [] } + let archive = try PropertyListDecoder().decode(ShieldedFundingAttemptsArchive.self, from: blob) + guard archive.version == 1, !archive.attempts.contains(where: { $0.isEmpty }), + archive.attempts.isEmpty || record.operationId?.count == 16 else { + throw AssetLockPersistenceError.malformedRecord + } + return archive.attempts + } + + /// Idempotent per-index append, saved with the UUID and base row by endChangeset. + func persistShieldedFundingAttempt(walletId: Data, outPoint: Data, operationId: Data, + attemptIndex: UInt32, bytes: Data) throws { + try onQueue { + guard inChangeset else { throw AssetLockPersistenceError.requiresChangeset } + guard outPoint.count == 36, operationId.count == 16, !bytes.isEmpty 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 + } + guard record.operationId == operationId else { throw AssetLockPersistenceError.duplicateOperationId } + var attempts = try Self.decodeShieldedFundingAttempts(record) + let index = Int(attemptIndex) + if index < attempts.count { + guard attempts[index] == bytes else { throw AssetLockPersistenceError.malformedRecord } + return + } + guard index == attempts.count else { throw AssetLockPersistenceError.malformedRecord } + attempts.append(bytes) + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + record.shieldedFundingAttemptsBlob = try encoder.encode( + ShieldedFundingAttemptsArchive(version: 1, attempts: attempts)) + record.updatedAt = Date() + } + } + + func loadShieldedFundingAttempts(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 + } + return try Self.decodeShieldedFundingAttempts(record) + } + } + /// 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 +1083,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...5).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 +1118,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 +1138,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 +3316,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.trackedMasternodes | PlatformWalletPersistenceCapabilities.coreSweepRemoval | PlatformWalletPersistenceCapabilities.dashpayPayments + | PlatformWalletPersistenceCapabilities.assetLockOperationIds + | PlatformWalletPersistenceCapabilities.shieldedFundingAttempts ) } @@ -3197,6 +3355,11 @@ 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 + extensionCallbacks.on_persist_shielded_funding_attempt_fn = persistShieldedFundingAttemptCallback + extensionCallbacks.on_load_shielded_funding_attempts_fn = loadShieldedFundingAttemptsCallback + extensionCallbacks.on_load_shielded_funding_attempts_free_fn = loadShieldedFundingAttemptsFreeCallback return extensionCallbacks } @@ -7231,7 +7394,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 @@ -10243,8 +10413,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) @@ -11541,6 +11713,91 @@ 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 persistShieldedFundingAttemptCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, + outPoint: UnsafePointer?, operationId: UnsafePointer?, + attemptIndex: UInt32, bytes: UnsafePointer?, bytesLen: UInt +) -> Int32 { + guard let context, let walletId, let outPoint, let operationId, let bytes, + let count = Int(exactly: bytesLen), count > 0 else { return -1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + try handler.persistShieldedFundingAttempt( + walletId: Data(bytes: walletId, count: 32), outPoint: Data(bytes: outPoint, count: 36), + operationId: Data(bytes: operationId, count: 16), attemptIndex: attemptIndex, + bytes: Data(bytes: bytes, count: count)) + return 0 + } catch { return -1 } +} + +private func loadShieldedFundingAttemptsCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, outPoint: UnsafePointer?, + outEntries: UnsafeMutablePointer?>?, + outCount: UnsafeMutablePointer? +) -> Int32 { + guard let context, let walletId, let outPoint, let outEntries, let outCount else { return -1 } + outEntries.pointee = nil + outCount.pointee = 0 + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + let attempts = try handler.loadShieldedFundingAttempts( + walletId: Data(bytes: walletId, count: 32), outPoint: Data(bytes: outPoint, count: 36)) + guard !attempts.isEmpty else { return 0 } + let entries = UnsafeMutablePointer.allocate(capacity: attempts.count) + for (index, attempt) in attempts.enumerated() { + let bytes = UnsafeMutablePointer.allocate(capacity: attempt.count) + attempt.copyBytes(to: bytes, count: attempt.count) + entries.advanced(by: index).initialize(to: ShieldedFundingAttemptFFI( + bytes: UnsafePointer(bytes), bytes_len: UInt(attempt.count))) + } + outEntries.pointee = entries + outCount.pointee = UInt(attempts.count) + return 0 + } catch { return -1 } +} + +private func loadShieldedFundingAttemptsFreeCallback( + context: UnsafeMutableRawPointer?, entries: UnsafeMutablePointer?, count: UInt +) { + guard let entries, let count = Int(exactly: count) else { return } + for index in 0..?, 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 7fcfa941f44..48fe89a9faf 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -258,6 +258,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { case errorShieldedRecoveryKeysRequired = 57 /// Platform returned no balance. Retrying the read is safe; ownership is unchanged. case errorIdentityBalanceUnavailable = 58 + /// A whole-wallet asset lock exceeds the input cap; nothing was broadcast. + case errorAssetLockTooManyInputs = 59 /// 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. @@ -385,6 +387,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorShieldedRecoveryKeysRequired case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_IDENTITY_BALANCE_UNAVAILABLE: self = .errorIdentityBalanceUnavailable + 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: @@ -569,6 +573,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 @@ -788,6 +794,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), @@ -902,6 +909,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/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift new file mode 100644 index 00000000000..94a61f7941e --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift @@ -0,0 +1,169 @@ +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) + XCTAssertNil(rows.first?.shieldedFundingAttemptsBlob) + 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 testSignedFundingAttemptsAreAppendOnlyAndSurviveConsumedReopen() 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("attempts.store") + let first = Data([18, 1, 2, 3]) + let second = Data([18, 4, 5, 6, 7]) + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertThrowsError(try handler.loadShieldedFundingAttempts(walletId: walletId, outPoint: rawOutPoint)) + try persist(handler, status: 1, operationId: operationId) + handler.beginChangeset(walletId: walletId) + for (index, bytes) in [first, second].enumerated() { + try handler.persistShieldedFundingAttempt(walletId: walletId, outPoint: rawOutPoint, + operationId: operationId, attemptIndex: UInt32(index), bytes: bytes) + } + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + handler.beginChangeset(walletId: walletId) + try handler.persistShieldedFundingAttempt(walletId: walletId, outPoint: rawOutPoint, + operationId: operationId, attemptIndex: 0, bytes: first) + XCTAssertThrowsError(try handler.persistShieldedFundingAttempt(walletId: walletId, outPoint: rawOutPoint, + operationId: operationId, attemptIndex: 0, bytes: second)) + XCTAssertThrowsError(try handler.persistShieldedFundingAttempt(walletId: walletId, outPoint: rawOutPoint, + operationId: operationId, attemptIndex: 3, bytes: second)) + XCTAssertFalse(handler.endChangeset(walletId: walletId, success: false)) + try persist(handler, status: 4, operationId: nil) + } + let reopened = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: reopened, network: .testnet) + XCTAssertEqual(try handler.loadShieldedFundingAttempts(walletId: walletId, outPoint: rawOutPoint), [first, second]) + XCTAssertEqual(try handler.loadCachedAssetLocks(walletId: walletId).first?.statusRaw, 4) + } + + func testMalformedAttemptArchiveFailsRecoveryRead() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + let record = PersistentAssetLock(outPointHex: outPointHex, walletId: walletId, + transactionBytes: Data([1]), fundingTypeRaw: 5, identityIndexRaw: 0, + amountDuffs: 1000, statusRaw: 4, operationId: operationId) + record.shieldedFundingAttemptsBlob = Data([0]) + context.insert(record) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertThrowsError(try handler.loadShieldedFundingAttempts(walletId: walletId, outPoint: rawOutPoint)) + } + + 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 b9aca49a5bc..6176e36f6ec 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift @@ -305,8 +305,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 1ac6248f4cc..6d37a2cece4 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..0e11b2fcaea 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -67,6 +67,8 @@ final class InvitationPersistenceTests: XCTestCase { // `PersistentDashpayPayment` rows, so the sweep's Failed flip // may ride this store's rounds — genuinely attested. | PlatformWalletPersistenceCapabilities.dashpayPayments + | PlatformWalletPersistenceCapabilities.assetLockOperationIds + | PlatformWalletPersistenceCapabilities.shieldedFundingAttempts XCTAssertEqual( capabilities.version, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift new file mode 100644 index 00000000000..6242f62b86b --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift @@ -0,0 +1,82 @@ +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: "501 exceeds 500") + guard case .assetLockTooManyInputs(let detail) = error else { + return XCTFail("input-cap refusal lost its typed case") + } + XCTAssertEqual(detail, "501 exceeds 500") + XCTAssertEqual(PlatformWalletResultCode.errorAssetLockTooManyInputs.rawValue, 59) + } + + @MainActor + func testShouldRejectAnUnconfiguredManagerBeforeExecuting() async { + let manager = PlatformWalletManager() + let outcome = await manager.shieldedFundFromWalletDrain( + walletId: Data(repeating: 1, count: 32), + operationId: UUID(), + recipients: [ShieldedFundFromAssetLockRecipient(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)") + } + } + } +} From 186673eda68d17453ca8f8efe00aba2c16e5b6af Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 13 Oct 2026 11:11:16 +0200 Subject: [PATCH 02/24] fix(platform-wallet): reject conflicting persisted drain operation IDs --- .../src/sqlite/schema/asset_locks.rs | 15 ++++ .../tests/sqlite_asset_locks_filter.rs | 76 +++++++++++++++++++ 2 files changed, 91 insertions(+) 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 53e1e334c15..61c013d2318 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 @@ -192,6 +192,21 @@ pub fn apply( shielded_funding_attempts_blob: row.get(1)?, }), ).optional()?; + // A signed attempt belongs to this immutable operation UUID. + // Reject the whole snapshot before either lifecycle or sidecar + // writes, even when it carries no attempts of its own. + 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", + )); + } + } let old_attempts = decode_attempts( existing .as_ref() diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rs b/packages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rs index e6c2a32b8b4..bade870bb35 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rs +++ b/packages/rs-platform-wallet-storage/tests/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 { @@ -269,6 +270,81 @@ fn should_migrate_legacy_blob_without_reencoding_and_reject_invalid_operation_id .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 the regular upsert and the consumed-row sidecar fallback. + 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]); + original.shielded_funding_attempts = vec![vec![18, 1, 2, 3]]; + 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]); + // The empty prefix used to pass the signed-attempt validation. + assert!(incoming.shielded_funding_attempts.is_empty()); + 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 + ); + } +} + #[test] fn should_restore_append_only_signed_attempts_and_reject_replacement() { let (persister, _tmp, path) = fresh_persister(); From 214b20b3201e89bd4956313c74f0797ae2fb169e Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 13 Oct 2026 17:42:55 +0200 Subject: [PATCH 03/24] fix(platform-wallet): make drain recovery and history durable --- docs/sdk/sdk-parity-manifest.json | 10 + .../rs-platform-wallet-ffi/src/manager.rs | 30 +- .../rs-platform-wallet-ffi/src/persistence.rs | 474 ++++++++++++++ .../src/sqlite/persister.rs | 3 + .../tests/sqlite_shielded_viewing_keys.rs | 17 + .../src/changeset/persistence_capabilities.rs | 22 +- .../src/changeset/shielded_changeset.rs | 71 ++- .../src/wallet/asset_lock/build.rs | 107 +++- .../src/wallet/asset_lock/operation.rs | 16 +- .../src/wallet/shielded/activity.rs | 30 +- .../src/wallet/shielded/coordinator.rs | 32 +- .../src/wallet/shielded/drain_recovery.rs | 597 ++++++++++++++++-- .../src/wallet/shielded/file_store.rs | 7 + .../src/wallet/shielded/store.rs | 14 + .../PlatformWalletManager.swift | 2 + .../PlatformWalletPersistenceHandler.swift | 229 ++++--- .../InvitationPersistenceTests.swift | 3 +- .../ShieldedActivityReconciliationTests.swift | 150 +++++ 18 files changed, 1648 insertions(+), 166 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedActivityReconciliationTests.swift diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index 4309dabf482..5dfa1d506d1 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -9,6 +9,7 @@ "invitations", "pending_contact_crypto", "provider_transactions", + "shielded_activity_reconciliation", "shielded_funding_attempts", "shielded_viewing_keys", "tracked_asset_locks", @@ -1624,6 +1625,7 @@ "asset_lock_funding_indices", "asset_lock_operation_ids", "atomic_changesets", + "shielded_activity_reconciliation", "shielded_funding_attempts", "tracked_asset_locks", "wallet_restore" @@ -1682,6 +1684,14 @@ "id": "testSignedFundingAttemptsAreAppendOnlyAndSurviveConsumedReopen", "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": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedActivityReconciliationTests.swift", + "id": "testReplacementAndCompletionSurviveReopenWithoutTouchingOtherAccountsOrWallets", + "command": "cd packages/swift-sdk && xcodebuild test -scheme SwiftDashSDK -sdk iphonesimulator -destination \"platform=iOS Simulator,name=iPhone 17\" ARCHS=arm64 -only-testing:SwiftDashSDKTests/ShieldedActivityReconciliationTests", + "covers_restart": true } ] } diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 82f147a3077..9c0dd332a34 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -15,7 +15,7 @@ use crate::persistence::{ PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, PersistenceCallbacks, PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, - PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, + RemoveShieldedActivityFn, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; use crate::types::{FFINetwork, Network}; @@ -299,6 +299,7 @@ unsafe fn persistence_extension_callbacks( on_load_shielded_funding_attempts_free_fn, FreeShieldedFundingAttemptsFn ), + remove_shielded_activity: slot!(on_remove_shielded_activity_fn, RemoveShieldedActivityFn), } } @@ -1445,6 +1446,33 @@ mod tests { } } + #[test] + fn should_size_gate_shielded_activity_removal_slot() { + unsafe extern "C" fn remove( + _: *mut c_void, + _: *const u8, + _: u32, + _: *const u8, + _: usize, + ) -> i32 { + 0 + } + let mut ext = PersistenceCallbacksExtension { + on_remove_shielded_activity_fn: Some(remove), + ..Default::default() + }; + assert!(unsafe { persistence_extension_callbacks(&ext) } + .remove_shielded_activity + .is_some()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_remove_shielded_activity_fn + ); + assert!(unsafe { persistence_extension_callbacks(&ext) } + .remove_shielded_activity + .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 1480ec97e47..e144f64b78c 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -157,6 +157,8 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_SWEEP_REMOVAL: u64 = 1 << pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS: u64 = 1 << 12; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS: u64 = 1 << 13; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_SHIELDED_FUNDING_ATTEMPTS: u64 = 1 << 14; +/// Atomic shielded history replacement and strict scan/history restore. +pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_SHIELDED_ACTIVITY_RECONCILIATION: u64 = 1 << 15; /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately /// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by @@ -308,6 +310,16 @@ pub struct ShieldedFundingAttemptFFI { pub bytes_len: usize, } +/// Remove exact activity rows. `entry_ids` borrows `count * 32` bytes for +/// this callback only; host stages deletion in the current atomic round. +pub type RemoveShieldedActivityFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + account_index: u32, + entry_ids: *const u8, + count: usize, +) -> i32; + /// Persist one append-only attempt inside the active atomic changeset. An existing /// index must contain exactly these bytes; an absent index must equal the count. pub type PersistShieldedFundingAttemptFn = unsafe extern "C" fn( @@ -514,6 +526,17 @@ pub struct PersistenceCallbacksExtension { count: usize, ), >, + /// Additive removal half of atomic activity replacement; legacy row strides + /// remain unchanged. IDs are packed as `count` consecutive 32-byte values. + pub on_remove_shielded_activity_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + account_index: u32, + entry_ids: *const u8, + count: usize, + ) -> i32, + >, } impl Default for PersistenceCallbacksExtension { @@ -536,6 +559,7 @@ impl Default for PersistenceCallbacksExtension { on_persist_shielded_funding_attempt_fn: None, on_load_shielded_funding_attempts_fn: None, on_load_shielded_funding_attempts_free_fn: None, + on_remove_shielded_activity_fn: None, } } } @@ -559,6 +583,7 @@ pub struct PersistenceExtensionCallbacks { pub persist_shielded_funding_attempt: Option, pub load_shielded_funding_attempts: Option, pub load_shielded_funding_attempts_free: Option, + pub remove_shielded_activity: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1700,6 +1725,43 @@ impl FFIPersister { { capabilities = capabilities.union(PersistenceCapabilities::SHIELDED_FUNDING_ATTEMPTS); } + #[cfg(feature = "shielded")] + if self.callbacks.on_persist_shielded_activity_fn.is_some() + && self.callbacks.on_load_shielded_activity_fn.is_some() + && self.callbacks.on_load_shielded_activity_free_fn.is_some() + && self + .tracked_masternodes_callbacks + .remove_shielded_activity + .is_some() + && self.callbacks.on_persist_shielded_notes_fn.is_some() + && self + .callbacks + .on_persist_shielded_nullifiers_spent_fn + .is_some() + && self.callbacks.on_load_shielded_notes_fn.is_some() + && self.callbacks.on_load_shielded_notes_free_fn.is_some() + && self + .callbacks + .on_persist_shielded_outgoing_notes_fn + .is_some() + && self.callbacks.on_load_shielded_outgoing_notes_fn.is_some() + && self + .callbacks + .on_load_shielded_outgoing_notes_free_fn + .is_some() + && self + .callbacks + .on_persist_shielded_synced_indices_fn + .is_some() + && self.callbacks.on_load_shielded_sync_states_fn.is_some() + && self + .callbacks + .on_load_shielded_sync_states_free_fn + .is_some() + { + capabilities = + capabilities.union(PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION); + } if self .tracked_masternodes_callbacks .persist_tracked_masternodes @@ -3148,6 +3210,28 @@ impl PlatformWalletPersistence for FFIPersister { } } + // Removals precede upserts in the same host transaction. Missing + // support is an error, never an accepted-but-ignored replacement. + for (id, ids) in &shielded_cs.activity_removed { + if let Some(cb) = self.tracked_masternodes_callbacks.remove_shielded_activity { + let packed: Vec = ids.iter().flat_map(|id| id.iter().copied()).collect(); + let result = unsafe { + cb( + self.callbacks.context, + id.wallet_id.as_ptr(), + id.account_index, + packed.as_ptr(), + ids.len(), + ) + }; + if result != 0 { + outcome.record(result); + } + } else { + outcome.record(-1); + } + } + // 6) activity entries (derived activity log). The variable- // length fields (counterparty / memo / cmx + nullifier // arrays) borrow into `backing`, a Vec of owned byte @@ -3268,6 +3352,11 @@ impl PlatformWalletPersistence for FFIPersister { // `rows` and `entries` drop here, after the callback // has copied everything it needs. drop(rows); + } else if self + .declared_capabilities + .contains(PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION) + { + outcome.record(-1); } } } @@ -3545,6 +3634,10 @@ impl PlatformWalletPersistence for FFIPersister { )); } + let strict_activity = self + .declared_capabilities + .contains(PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION); + // 1) notes if let Some(load_notes) = self.callbacks.on_load_shielded_notes_fn { let mut notes_ptr: *const ShieldedNoteRestoreFFI = std::ptr::null(); @@ -3582,10 +3675,20 @@ impl PlatformWalletPersistence for FFIPersister { entries: notes_ptr, count: notes_count, }; + if strict_activity && notes_ptr.is_null() && notes_count > 0 { + return Err(PersistenceError::backend( + "shielded restore returned a null array with nonzero count", + )); + } if !notes_ptr.is_null() && notes_count > 0 { let slice = unsafe { slice::from_raw_parts(notes_ptr, notes_count) }; for ffi in slice { if ffi.note_data_ptr.is_null() || ffi.note_data_len == 0 { + if strict_activity { + return Err(PersistenceError::backend( + "missing shielded note bytes", + )); + } continue; } let note_data = unsafe { @@ -3650,6 +3753,11 @@ impl PlatformWalletPersistence for FFIPersister { entries: out_ptr, count: out_count, }; + if strict_activity && out_ptr.is_null() && out_count > 0 { + return Err(PersistenceError::backend( + "shielded restore returned a null array with nonzero count", + )); + } if !out_ptr.is_null() && out_count > 0 { let slice = unsafe { slice::from_raw_parts(out_ptr, out_count) }; for ffi in slice { @@ -3714,6 +3822,11 @@ impl PlatformWalletPersistence for FFIPersister { entries: states_ptr, count: states_count, }; + if strict_activity && states_ptr.is_null() && states_count > 0 { + return Err(PersistenceError::backend( + "shielded sync restore returned a null array with nonzero count", + )); + } if !states_ptr.is_null() && states_count > 0 { let slice = unsafe { slice::from_raw_parts(states_ptr, states_count) }; for ffi in slice { @@ -3773,9 +3886,34 @@ impl PlatformWalletPersistence for FFIPersister { entries: act_ptr, count: act_count, }; + if strict_activity && act_ptr.is_null() && act_count > 0 { + return Err(PersistenceError::backend( + "shielded restore returned a null array with nonzero count", + )); + } if !act_ptr.is_null() && act_count > 0 { let slice = unsafe { slice::from_raw_parts(act_ptr, act_count) }; for ffi in slice { + if strict_activity + && (ffi.kind_tag > 9 + || ffi.direction > 2 + || ffi.status > 3 + || ffi.note_cmxs_count > (isize::MAX as usize) / 32 + || ffi.spent_nullifiers_count > (isize::MAX as usize) / 32 + || ffi.has_fee > 1 + || ffi.has_block_height > 1 + || ffi.has_min_note_position > 1 + || ffi.has_identity_id > 1 + || (ffi.note_cmxs_count > 0 && ffi.note_cmxs_ptr.is_null()) + || (ffi.spent_nullifiers_count > 0 + && ffi.spent_nullifiers_ptr.is_null()) + || (ffi.counterparty_len > 0 && ffi.counterparty_ptr.is_null()) + || (ffi.memo_len > 0 && ffi.memo_ptr.is_null())) + { + return Err(PersistenceError::backend( + "malformed shielded activity row", + )); + } let kind = match ffi.kind_tag { 0 => ShieldedActivityKind::Shield, 1 => ShieldedActivityKind::ShieldFromAssetLock, @@ -9584,6 +9722,16 @@ mod tests { PersistenceCallbacksExtension, on_load_shielded_funding_attempts_free_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_remove_shielded_activity_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_remove_shielded_activity_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( @@ -9650,6 +9798,10 @@ mod tests { PLATFORM_WALLET_PERSISTENCE_CAPABILITY_SHIELDED_FUNDING_ATTEMPTS, PersistenceCapabilities::SHIELDED_FUNDING_ATTEMPTS.bits() ); + assert_eq!( + PLATFORM_WALLET_PERSISTENCE_CAPABILITY_SHIELDED_ACTIVITY_RECONCILIATION, + PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION.bits() + ); assert_eq!( PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ACCOUNT_ADDRESS_POOLS, PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_FUNDING_INDICES @@ -9824,6 +9976,328 @@ mod tests { assert_eq!(sink.calls.load(Ordering::SeqCst), 1); } + #[cfg(feature = "shielded")] + #[test] + fn should_require_every_activity_reconciliation_callback() { + unsafe extern "C" fn persist( + _: *mut c_void, + _: *const u8, + _: *const T, + _: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn load(_: *mut c_void, _: *mut *const T, _: *mut usize) -> i32 { + 0 + } + unsafe extern "C" fn free(_: *mut c_void, _: *const T, _: usize) {} + unsafe extern "C" fn remove( + _: *mut c_void, + _: *const u8, + _: u32, + _: *const u8, + _: usize, + ) -> i32 { + 0 + } + let callbacks = || PersistenceCallbacks { + on_persist_shielded_activity_fn: Some(persist), + on_load_shielded_activity_fn: Some(load), + on_load_shielded_activity_free_fn: Some(free), + on_persist_shielded_notes_fn: Some(persist), + on_persist_shielded_nullifiers_spent_fn: Some(persist), + on_load_shielded_notes_fn: Some(load), + on_load_shielded_notes_free_fn: Some(free), + on_persist_shielded_outgoing_notes_fn: Some(persist), + on_load_shielded_outgoing_notes_fn: Some(load), + on_load_shielded_outgoing_notes_free_fn: Some(free), + on_persist_shielded_synced_indices_fn: Some(persist), + on_load_shielded_sync_states_fn: Some(load), + on_load_shielded_sync_states_free_fn: Some(free), + ..Default::default() + }; + let extension = PersistenceExtensionCallbacks { + remove_shielded_activity: Some(remove), + ..Default::default() + }; + let bit = PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION; + let capability = |cb, ext, declaration| { + FFIPersister::new_with_persistence_capabilities_and_extensions(cb, declaration, ext) + .persistence_capabilities() + }; + assert!(capability(callbacks(), extension, bit).contains(bit)); + assert!(!capability(callbacks(), extension, PersistenceCapabilities::NONE).contains(bit)); + assert!( + !capability(callbacks(), PersistenceExtensionCallbacks::default(), bit).contains(bit) + ); + macro_rules! missing { + ($field:ident) => {{ + let mut cb = callbacks(); + cb.$field = None; + assert!( + !capability(cb, extension, bit).contains(bit), + stringify!($field) + ); + }}; + } + missing!(on_persist_shielded_activity_fn); + missing!(on_load_shielded_activity_fn); + missing!(on_load_shielded_activity_free_fn); + missing!(on_persist_shielded_notes_fn); + missing!(on_persist_shielded_nullifiers_spent_fn); + missing!(on_load_shielded_notes_fn); + missing!(on_load_shielded_notes_free_fn); + missing!(on_persist_shielded_outgoing_notes_fn); + missing!(on_load_shielded_outgoing_notes_fn); + missing!(on_load_shielded_outgoing_notes_free_fn); + missing!(on_persist_shielded_synced_indices_fn); + missing!(on_load_shielded_sync_states_fn); + missing!(on_load_shielded_sync_states_free_fn); + } + + #[cfg(feature = "shielded")] + #[test] + fn should_abort_activity_replacement_round_on_either_callback_failure() { + use crate::shielded_persistence::ShieldedActivityFFI; + use platform_wallet::wallet::shielded::{ + ShieldedActivityEntry, ShieldedActivityKind, ShieldedActivityStatus, ShieldedDirection, + SubwalletId, + }; + #[derive(Default)] + struct Host { + calls: Vec<&'static str>, + failure: u8, + committed: bool, + } + unsafe extern "C" fn remove( + ctx: *mut c_void, + wallet: *const u8, + account: u32, + ids: *const u8, + count: usize, + ) -> i32 { + assert_eq!(slice::from_raw_parts(wallet, 32), &[1; 32]); + assert_eq!(account, 7); + assert_eq!(count, 1); + assert_eq!(slice::from_raw_parts(ids, 32), &[2; 32]); + let host = &mut *ctx.cast::(); + host.calls.push("remove"); + if host.failure == 1 { + -1 + } else { + 0 + } + } + unsafe extern "C" fn persist( + ctx: *mut c_void, + _: *const u8, + entries: *const ShieldedActivityFFI, + count: usize, + ) -> i32 { + assert_eq!(count, 1); + assert_eq!((*entries).entry_id, [3; 32]); + let host = &mut *ctx.cast::(); + host.calls.push("upsert"); + if host.failure == 2 { + -1 + } else { + 0 + } + } + unsafe extern "C" fn end(ctx: *mut c_void, _: *const u8, success: bool) -> i32 { + let host = &mut *ctx.cast::(); + host.calls.push("end"); + host.committed = success; + 0 + } + let id = SubwalletId::new([1; 32], 7); + let mut cs = platform_wallet::changeset::ShieldedChangeSet::default(); + cs.remove_activity_entry(id, [2; 32]); + cs.record_activity_entry( + id, + ShieldedActivityEntry { + id: [3; 32], + kind: ShieldedActivityKind::ShieldFromAssetLock, + status: ShieldedActivityStatus::Confirmed, + direction: ShieldedDirection::In, + amount: 1, + fee: None, + counterparty: None, + memo: None, + block_height: None, + created_at_ms: 0, + min_note_position: None, + note_cmxs: vec![[4; 32]], + spent_nullifiers: vec![], + }, + ); + for failure in [0, 1, 2, 3] { + let mut host = Host { + failure, + ..Default::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: (&mut host as *mut Host).cast(), + on_changeset_begin_fn: Some(noop_begin), + on_changeset_end_fn: Some(end), + on_persist_shielded_activity_fn: Some(persist), + ..Default::default() + }, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + remove_shielded_activity: (failure != 3).then_some(remove), + ..Default::default() + }, + ); + let result = persister.store( + [1; 32], + PlatformWalletChangeSet { + shielded: Some(cs.clone()), + ..Default::default() + }, + ); + assert_eq!(result.is_ok(), failure == 0); + assert_eq!(host.committed, failure == 0); + assert_eq!( + host.calls, + if failure == 3 { + vec!["upsert", "end"] + } else { + vec!["remove", "upsert", "end"] + } + ); + } + } + + #[cfg(feature = "shielded")] + #[test] + fn should_keep_legacy_activity_upsert_best_effort_without_attestation() { + use platform_wallet::wallet::shielded::{ + ShieldedActivityEntry, ShieldedActivityKind, ShieldedActivityStatus, ShieldedDirection, + SubwalletId, + }; + let mut cs = platform_wallet::changeset::ShieldedChangeSet::default(); + cs.record_activity_entry( + SubwalletId::new([1; 32], 0), + ShieldedActivityEntry { + id: [3; 32], + kind: ShieldedActivityKind::Received, + status: ShieldedActivityStatus::Confirmed, + direction: ShieldedDirection::In, + amount: 1, + fee: None, + counterparty: None, + memo: None, + block_height: None, + created_at_ms: 0, + min_note_position: None, + note_cmxs: vec![[4; 32]], + spent_nullifiers: vec![], + }, + ); + for (capability, success) in [ + (PersistenceCapabilities::NONE, true), + ( + PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION, + false, + ), + ] { + let persister = declared_persister(PersistenceCallbacks::default(), capability); + assert_eq!( + persister + .store( + [1; 32], + PlatformWalletChangeSet { + shielded: Some(cs.clone()), + ..Default::default() + } + ) + .is_ok(), + success + ); + } + } + + #[cfg(feature = "shielded")] + #[test] + fn should_reject_malformed_activity_restore_for_attesting_hosts_and_free_rows() { + use crate::shielded_persistence::ShieldedActivityRestoreFFI; + struct Host { + mode: u8, + freed: bool, + } + unsafe extern "C" fn wallets( + _: *mut c_void, + entries: *mut *const WalletRestoreEntryFFI, + count: *mut usize, + ) -> i32 { + *entries = Box::into_raw(Box::new(WalletRestoreEntryFFI { + wallet_id: [1; 32], + ..Default::default() + })); + *count = 1; + 0 + } + unsafe extern "C" fn free_wallet( + _: *mut c_void, + entries: *const WalletRestoreEntryFFI, + _: usize, + ) { + drop(Box::from_raw(entries.cast_mut())); + } + unsafe extern "C" fn load( + ctx: *mut c_void, + entries: *mut *const ShieldedActivityRestoreFFI, + count: *mut usize, + ) -> i32 { + let host = &mut *ctx.cast::(); + // The wire row is entirely integer/array/raw-pointer fields. + let mut row: ShieldedActivityRestoreFFI = std::mem::zeroed(); + row.wallet_id = [1; 32]; + row.direction = if host.mode == 1 { 9 } else { 0 }; + if host.mode == 2 { + row.note_cmxs_count = usize::MAX; + } + if host.mode == 3 { + row.note_cmxs_count = 1; + } + *entries = if host.mode == 4 { + std::ptr::null() + } else { + Box::into_raw(Box::new(row)) + }; + *count = 1; + 0 + } + unsafe extern "C" fn free( + ctx: *mut c_void, + entries: *const ShieldedActivityRestoreFFI, + _: usize, + ) { + if !entries.is_null() { + drop(Box::from_raw(entries.cast_mut())); + } + (*ctx.cast::()).freed = true; + } + for mode in 1..=4 { + let mut host = Host { mode, freed: false }; + let persister = FFIPersister::new_with_persistence_capabilities( + PersistenceCallbacks { + context: (&mut host as *mut Host).cast(), + on_load_wallet_list_fn: Some(wallets), + on_load_wallet_list_free_fn: Some(free_wallet), + on_load_shielded_activity_fn: Some(load), + on_load_shielded_activity_free_fn: Some(free), + ..Default::default() + }, + PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION, + ); + assert!(persister.load().is_err(), "corruption mode {mode}"); + assert!(host.freed); + } + } + #[cfg(feature = "shielded")] #[test] fn shielded_viewing_key_capability_requires_complete_callback_triplet() { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 7d40c9e7e7f..48792e320fd 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1312,6 +1312,9 @@ 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. + // Do not attest SHIELDED_ACTIVITY_RECONCILIATION either: this backend + // does not store/remove/restore activity rows. Wallet drains require + // that history checkpoint before marking the asset lock consumed. let capabilities = PersistenceCapabilities::ATOMIC_CHANGESETS .union(PersistenceCapabilities::INVITATIONS) .union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_shielded_viewing_keys.rs b/packages/rs-platform-wallet-storage/tests/sqlite_shielded_viewing_keys.rs index cd69dd9a2f3..62d298c81bc 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_shielded_viewing_keys.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_shielded_viewing_keys.rs @@ -83,6 +83,23 @@ fn sqlite_advertises_shielded_viewing_key_capability() { .contains(PersistenceCapabilities::SHIELDED_VIEWING_KEYS)); } +#[test] +fn sqlite_does_not_attest_wallet_drain_without_durable_activity_history() { + let (persister, _tmp, _path) = fresh_persister(); + let capabilities = persister.persistence_capabilities(); + assert!(!capabilities.contains(PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION)); + assert!(!capabilities.contains(PersistenceCapabilities::WALLET_DRAIN)); + assert!(capabilities + .missing(PersistenceCapabilities::WALLET_DRAIN) + .names() + .contains(&"shielded_activity_reconciliation")); + // 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)); + assert!(capabilities.contains(PersistenceCapabilities::SHIELDED_FUNDING_ATTEMPTS)); +} + #[test] fn delete_wallet_cascades_shielded_viewing_keys() { let (persister, _tmp, path) = fresh_persister(); diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index f0144600728..8e2cc6aba62 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -115,13 +115,18 @@ impl PersistenceCapabilities { /// before dispatch and restored losslessly, including consumed tombstones. pub const SHIELDED_FUNDING_ATTEMPTS: Self = Self(1 << 14); + /// Shielded activity upserts, removals and strict restore are durable and + /// atomic with asset-lock completion. Authenticated scan notes, outgoing + /// notes and sync watermarks also survive restart without silent loss. + pub const SHIELDED_ACTIVITY_RECONCILIATION: Self = Self(1 << 15); + /// 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 = 14; + const HIGHEST_DECLARED_BIT: u32 = 15; /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -145,7 +150,8 @@ impl PersistenceCapabilities { Self::ASSET_LOCK_RECONCILIATION.0 | Self::ASSET_LOCK_FUNDING_INDICES.0 | Self::ASSET_LOCK_OPERATION_IDS.0 - | Self::SHIELDED_FUNDING_ATTEMPTS.0, + | Self::SHIELDED_FUNDING_ATTEMPTS.0 + | Self::SHIELDED_ACTIVITY_RECONCILIATION.0, ); pub const fn from_bits_retain(bits: u64) -> Self { @@ -230,6 +236,10 @@ impl PersistenceCapabilities { PersistenceCapabilities::SHIELDED_FUNDING_ATTEMPTS, "shielded_funding_attempts", ), + ( + PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION, + "shielded_activity_reconciliation", + ), ]; KNOWN @@ -247,7 +257,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::SHIELDED_FUNDING_ATTEMPTS.bits() + PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION.bits() == 1u64 << PersistenceCapabilities::HIGHEST_DECLARED_BIT, "HIGHEST_DECLARED_BIT must name the highest declared capability bit" ); @@ -283,7 +293,11 @@ mod tests { PersistenceCapabilities::SHIELDED_FUNDING_ATTEMPTS.bits(), 0x4000 ); - assert_eq!(PersistenceCapabilities::WALLET_DRAIN.bits(), 0x6285); + assert_eq!( + PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION.bits(), + 0x8000 + ); + assert_eq!(PersistenceCapabilities::WALLET_DRAIN.bits(), 0xe285); assert_eq!( PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), 0x281 diff --git a/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs b/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs index 3ac11cca4b1..ad5f2efdf4b 100644 --- a/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs @@ -15,7 +15,7 @@ //! (already persisted in `ClientPersistentCommitmentTree`'s //! SQLite file at the host-supplied `db_path`). -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use crate::changeset::merge::Merge; use crate::wallet::shielded::{ @@ -51,6 +51,10 @@ pub struct ShieldedChangeSet { /// Defaults empty so every pre-activity flow rides the existing /// changeset path unchanged. pub activity_entries: BTreeMap>, + /// Superseded scan aggregates to remove in the same atomic round as their + /// replacement entries. Removal wins over stale upserts folded in the round. + #[cfg_attr(feature = "serde", serde(default))] + pub activity_removed: BTreeMap>, /// Per-subwallet Orchard viewing keys to persist, as the raw /// 96-byte FVK encoding (stored as `Vec` — serde-derive only /// covers arrays ≤ 32). Emitted once per seed-backed @@ -69,6 +73,7 @@ impl ShieldedChangeSet { && self.outgoing_notes.is_empty() && self.synced_indices.is_empty() && self.activity_entries.is_empty() + && self.activity_removed.is_empty() && self.viewing_keys.is_empty() } @@ -91,7 +96,24 @@ impl ShieldedChangeSet { /// The persister upserts by `entry.id`, so re-recording the same id /// with a refined kind / flipped status replaces the prior row. pub fn record_activity_entry(&mut self, id: SubwalletId, entry: ShieldedActivityEntry) { - self.activity_entries.entry(id).or_default().push(entry); + if !self + .activity_removed + .get(&id) + .is_some_and(|ids| ids.contains(&entry.id)) + { + self.activity_entries.entry(id).or_default().push(entry); + } + } + + /// Retire an aggregate whose outputs have been split into exact operations. + pub fn remove_activity_entry(&mut self, id: SubwalletId, entry_id: [u8; 32]) { + self.activity_removed + .entry(id) + .or_default() + .insert(entry_id); + if let Some(entries) = self.activity_entries.get_mut(&id) { + entries.retain(|entry| entry.id != entry_id); + } } /// Accumulator helper: advance the per-subwallet sync watermark. @@ -126,6 +148,7 @@ impl ShieldedChangeSet { outgoing_notes, synced_indices, activity_entries, + activity_removed, viewing_keys, } = self; let mut out: BTreeMap = @@ -160,6 +183,12 @@ impl ShieldedChangeSet { .activity_entries .insert(id, entries); } + for (id, entries) in activity_removed { + out.entry(id.wallet_id) + .or_default() + .activity_removed + .insert(id, entries); + } for (id, fvk) in viewing_keys { out.entry(id.wallet_id) .or_default() @@ -197,8 +226,15 @@ impl Merge for ShieldedChangeSet { // Activity entries append; the persister upserts by `entry.id`, // so a later flip/refinement of the same id (appended after the // original) wins at persist time without needing to dedupe here. + for (id, entries) in other.activity_removed { + for entry in entries { + self.remove_activity_entry(id, entry); + } + } for (id, entries) in other.activity_entries { - self.activity_entries.entry(id).or_default().extend(entries); + for entry in entries { + self.record_activity_entry(id, entry); + } } // Viewing keys: last write wins (the FVK for a subwallet is // stable on a given network, so any re-emit is identical). @@ -313,4 +349,33 @@ mod activity_changeset_tests { .activity_entries .contains_key(&SubwalletId::new(wallet_b, 0))); } + #[test] + fn activity_removal_wins_stale_fold_and_stays_wallet_scoped() { + let id = SubwalletId::new([1; 32], 0); + let other = SubwalletId::new([2; 32], 0); + let old = entry(1, ShieldedActivityStatus::Confirmed); + let replacement = entry(2, ShieldedActivityStatus::Confirmed); + let mut stale = ShieldedChangeSet::default(); + stale.record_activity_entry(id, old.clone()); + stale.record_activity_entry(other, old.clone()); + let mut repaired = ShieldedChangeSet::default(); + repaired.remove_activity_entry(id, old.id); + repaired.record_activity_entry(id, replacement.clone()); + // Either folding order must retain the removal, including a buffered + // scan round that was built before the canonical replacement. + for (mut first, second) in [(stale.clone(), repaired.clone()), (repaired, stale)] { + first.merge(second); + let split = first.split_by_wallet_id(); + assert_eq!( + split[&id.wallet_id].activity_entries[&id], + vec![replacement.clone()] + ); + assert!(split[&id.wallet_id].activity_removed[&id].contains(&old.id)); + assert_eq!( + split[&other.wallet_id].activity_entries[&other], + vec![old.clone()] + ); + assert!(split[&other.wallet_id].activity_removed.is_empty()); + } + } } 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 ebc28d4528f..cdc3c1632da 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -1080,9 +1080,9 @@ impl AssetLockManager { } } - // Record the exact outpoint BEFORE any persistence callback can fail - // or panic. An uncertain store/flush keeps this context pending even - // though no network call has happened: restart may recover the row. + // Keep the exact outpoint if a lock-row store/flush is uncertain: + // restart may recover that row even before network dispatch. A known + // funding-pool failure below clears it because no row was written. if let Some(operation) = operation { operation.set(AssetLockOperationState::Pending, Some(out_point)); } @@ -1110,14 +1110,13 @@ impl AssetLockManager { }; if let Err(e) = pool_durability { tracing::error!(error = %e, "failed to persist asset-lock funding index"); - if operation.is_some() { - // Do not release an ambiguous durability write or expose its - // row to resume. The call-local context remains fail-closed. - return Err(PlatformWalletError::Persistence(format!( - "drain funding indices could not be made durable before dispatch: {e}" - ))); - } - 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 @@ -1148,6 +1147,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 \ @@ -1573,6 +1577,7 @@ mod tests { 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)>, } @@ -1613,6 +1618,13 @@ mod tests { _wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { + 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")); + } self.stored .lock() .expect("capturing persistence mutex") @@ -4135,11 +4147,21 @@ mod tests { } #[tokio::test] - async fn should_wallet_drain_flush_failure_keep_exact_context_without_exposing_resume() { + 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(StandardAccountType::BIP44Account).await; + funded_wallet_manager_dual_standard(&[700_000, 900_000], &[800_000]).await; let persistence = Arc::new(CapturingPersistence { - fail_flush: true, + 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()); @@ -4149,10 +4171,10 @@ mod tests { wallet_id, Arc::new(Notify::new()), broadcaster.clone(), - WalletPersister::new(wallet_id, persistence), + WalletPersister::new(wallet_id, persistence.clone()), ); let operation = Arc::new(super::AssetLockOperationContext::new([41; 16])); - let _registration = manager.register_wallet_drain_operation(operation.clone()); + 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( @@ -4168,13 +4190,13 @@ mod tests { ) .await; assert!(matches!(result, Err(PlatformWalletError::Persistence(_)))); - drop(_registration); + drop(registration); assert_eq!(broadcaster.calls(), 0); assert!(manager.list_tracked_locks().await.is_empty()); - assert!(operation.snapshot().out_point.is_some()); + assert_eq!(operation.snapshot().out_point, None); assert_eq!( operation.snapshot().state, - super::AssetLockOperationState::Pending + super::AssetLockOperationState::NotSent ); assert_eq!( manager @@ -4183,9 +4205,50 @@ mod tests { .unwrap(), operation.snapshot() ); - let second = Arc::new(super::AssetLockOperationContext::new([42; 16])); - let _second_registration = manager.register_wallet_drain_operation(second.clone()); - assert!(manager.begin_wallet_drain(&second).await.is_err()); + + // 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 + }) + })); } #[tokio::test] diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs index a1bebc5bb1d..4d98e61a134 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs @@ -154,6 +154,7 @@ impl AssetLockManager { &self, out_point: OutPoint, operation_id: [u8; 16], + history: crate::changeset::ShieldedChangeSet, ) -> Result<(), PlatformWalletError> { self.require_wallet_drain_persistence()?; let mut candidate = self @@ -164,7 +165,20 @@ impl AssetLockManager { .ok_or(PlatformWalletError::AssetLockNotTracked(out_point))?; candidate.status = AssetLockStatus::Consumed; candidate.proof = None; - self.persist_drain_lock_snapshot(&candidate)?; + let mut changes = crate::changeset::AssetLockChangeSet::default(); + changes + .asset_locks + .insert(candidate.out_point, (&candidate).into()); + self.persister + .store(crate::changeset::PlatformWalletChangeSet { + asset_locks: Some(changes), + shielded: Some(history), + ..Default::default() + }) + .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; + self.persister + .flush() + .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; let mut manager = self.wallet_manager.write().await; let lock = manager .get_wallet_info_mut(&self.wallet_id) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/activity.rs b/packages/rs-platform-wallet/src/wallet/shielded/activity.rs index 7fb79b2b365..1b81e284cda 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/activity.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/activity.rs @@ -452,7 +452,35 @@ pub fn derive_activity_from_scan_data( input: &ScanDeriveInput, existing_cmxs: &std::collections::BTreeMap<[u8; 32], [u8; 32]>, ) -> DerivedActivity { - let clusters = cluster_events(input); + derive_activity_from_scan_data_inner(input, existing_cmxs, false) +} + +/// Reclassify the unowned remainder of a superseded scan aggregate. Preserve +/// all notes as nullifier/spend context while excluding already-owned outputs +/// from clusters, so another authored row cannot hide an unrelated receipt. +pub(super) fn derive_unowned_activity_from_scan_data( + input: &ScanDeriveInput, + existing_cmxs: &BTreeMap<[u8; 32], [u8; 32]>, +) -> DerivedActivity { + derive_activity_from_scan_data_inner(input, existing_cmxs, true) +} + +fn derive_activity_from_scan_data_inner( + input: &ScanDeriveInput, + existing_cmxs: &BTreeMap<[u8; 32], [u8; 32]>, + exclude_owned_outputs: bool, +) -> DerivedActivity { + let mut clusters = cluster_events(input); + if exclude_owned_outputs { + for cluster in clusters.values_mut() { + cluster + .received + .retain(|note| !existing_cmxs.contains_key(¬e.cmx)); + cluster + .outgoing + .retain(|note| !existing_cmxs.contains_key(¬e.cmx)); + } + } let mut out = DerivedActivity::default(); // Scan-derived entries deliberately carry NO height and NO // timestamp (dashwallet-ios restored-history acceptance criteria): diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 731b35e51ad..3240f8fc199 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -231,6 +231,11 @@ pub struct NetworkShieldedCoordinator { /// other way round. lifecycle: tokio::sync::Mutex<()>, + /// Serialize scan-derived history persistence with exact drain replacement. + /// Held through persistence, so a previously derived aggregate cannot land + /// after its canonical replacement. Never protects viewing keys or network I/O. + pub(super) activity_checkpoint: tokio::sync::Mutex<()>, + /// Wallets whose per-subwallet store state has been successfully /// hydrated from the host's persisted snapshot this session /// (`restore_for_wallet` completed without error after the @@ -399,6 +404,7 @@ impl NetworkShieldedCoordinator { progress_handler: std::sync::Mutex::new(None), tree_progress_handler: std::sync::Mutex::new(None), lifecycle: tokio::sync::Mutex::new(()), + activity_checkpoint: tokio::sync::Mutex::new(()), hydrated: RwLock::new(std::collections::BTreeSet::new()), clear_generation: std::sync::atomic::AtomicU64::new(0), } @@ -1455,6 +1461,7 @@ impl NetworkShieldedCoordinator { // id, so re-running is idempotent. Failures are logged and // swallowed: a derivation miss must never fail a sync pass. let mut notes = notes; + let activity_checkpoint = self.activity_checkpoint.lock().await; if let Err(e) = self .derive_activity_into_changeset(&subwallets, &mut notes.changeset) .await @@ -1462,18 +1469,6 @@ impl NetworkShieldedCoordinator { tracing::warn!(error = %e, "Shielded activity derivation failed; skipping this pass"); } - // Identity-funded shields have no input nullifiers and use an - // outputs-only empty-tree anchor, so the ordinary stale-reservation - // pass can never schedule them. After activity derivation has first - // cleared guards for outputs observed by this scan, drive each still- - // unresolved byte-identical transition once per wallet. Wallet-wide - // enumeration includes guards rehydrated from the durable store. - let wallet_ids: HashSet = subwallets.iter().map(|(id, _)| id.wallet_id).collect(); - for wallet_id in wallet_ids { - super::operations::redrive_pending_identity_shields(&self.sdk, &self.store, wallet_id) - .await; - } - // The note-side changeset already carries saves, synced // indices, AND the scan-detected spends, so split it per // WalletId directly — each per-wallet `WalletPersister.store` @@ -1503,6 +1498,15 @@ impl NetworkShieldedCoordinator { } } } + drop(activity_checkpoint); + + // Re-drive only after history is persisted and its checkpoint guard is + // released: network I/O must not block exact history reconciliation. + let wallet_ids: HashSet = subwallets.iter().map(|(id, _)| id.wallet_id).collect(); + for wallet_id in wallet_ids { + super::operations::redrive_pending_identity_shields(&self.sdk, &self.store, wallet_id) + .await; + } // Cooldown decision based on aggregate activity across // every subwallet — any new commitment scanned or @@ -1764,7 +1768,7 @@ impl NetworkShieldedCoordinator { /// /// All client-side (Option B): no node / DAPI query, only data the /// store already holds. - async fn derive_activity_into_changeset( + pub(super) async fn derive_activity_into_changeset( &self, subwallets: &[(SubwalletId, AccountViewingKeys)], changeset: &mut crate::changeset::ShieldedChangeSet, @@ -2130,7 +2134,7 @@ fn build_per_wallet_summary( /// in `fund_from_asset_lock.rs`). A malformed recipient (wrong length / /// off-curve) returns `false` (treated as external) so a corrupt row /// can't silently mask a real send. -fn is_own_orchard_recipient(views: &AccountViewingKeys, recipient: &[u8]) -> bool { +pub(super) fn is_own_orchard_recipient(views: &AccountViewingKeys, recipient: &[u8]) -> bool { let raw: [u8; 43] = match recipient.try_into() { Ok(b) => b, Err(_) => return false, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs index a8969607991..da86af6e927 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs @@ -18,8 +18,8 @@ use dpp::balances::credits::CREDITS_PER_DUFF; use dpp::identity::state_transition::AssetLockProved; use dpp::serialization::{PlatformDeserializableUntrusted, PlatformSerializable, Signable}; use dpp::state_transition::proof_result::StateTransitionProofResult; -use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; -use dpp::state_transition::StateTransition; +use dpp::state_transition::shield_from_asset_lock_transition::accessors::ShieldFromAssetLockTransitionAccessorsV0; +use dpp::state_transition::{StateTransition, StateTransitionSingleSigned}; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use crate::error::{ @@ -52,10 +52,10 @@ fn attempt_outputs( return Err(invalid("wrong operation, funding role or transaction")); } let st = StateTransition::deserialize_from_bytes_untrusted(bytes).map_err(invalid)?; - let StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(v0)) = &st else { + let StateTransition::ShieldFromAssetLock(transition) = &st else { return Err(invalid("expected a signed Type18 transition")); }; - if v0.asset_lock_proof.out_point() != Some(lock.out_point) { + if transition.asset_lock_proof().out_point() != Some(lock.out_point) { return Err(invalid( "attempt belongs to a different asset-lock outpoint", )); @@ -70,11 +70,11 @@ fn attempt_outputs( .get(lock.out_point.vout as usize) .ok_or_else(|| invalid("asset-lock credit output is missing"))?; if credit_output.value != lock.amount - || v0.value_balance == 0 + || transition.value_balance() == 0 || lock .amount .checked_mul(CREDITS_PER_DUFF) - .is_none_or(|amount| v0.value_balance > amount) + .is_none_or(|amount| transition.value_balance() > amount) { return Err(invalid("attempt value does not match the tracked lock")); } @@ -85,14 +85,14 @@ fn attempt_outputs( let digest = sha256d::Hash::hash(&st.signable_bytes().map_err(invalid)?); dashcore::signer::verify_hash_signature( digest.as_byte_array(), - v0.signature.as_slice(), + transition.signature().as_slice(), public_key_hash, ) .map_err(invalid)?; let mut outputs = BTreeSet::new(); let mut recovered_value = 0u64; - for action in &v0.actions { + for action in transition.actions() { let wire = ShieldedEncryptedNote { cmx: action.cmx.to_vec(), nullifier: action.nullifier.to_vec(), @@ -119,7 +119,7 @@ fn attempt_outputs( } } } - if outputs.is_empty() || recovered_value != v0.value_balance { + if outputs.is_empty() || recovered_value != transition.value_balance() { return Err(invalid( "viewing keys cannot recover the complete funded output set", )); @@ -127,6 +127,115 @@ fn attempt_outputs( Ok(outputs) } +/// Replace only scan-derived aggregates that contain this exact attempt's +/// outputs. Reclassify the remaining scan data so unrelated same-anchor +/// receipts retain their own history row instead of being lost or counted twice. +fn drain_history_changeset( + store: &impl ShieldedStore, + wallet_id: [u8; 32], + keys: &BTreeMap, + actions: &[dpp::shielded::SerializedAction], + outputs: &BTreeSet<(u32, [u8; 32])>, +) -> Result { + use super::activity::{ + compute_activity_id, derive_unowned_activity_from_scan_data, ScanDeriveInput, + ShieldedActivityEntry, ShieldedActivityKind, ShieldedActivityStatus, ShieldedDirection, + }; + let mut history = crate::changeset::ShieldedChangeSet::default(); + for (account, views) in keys { + let cmxs: BTreeSet<_> = outputs + .iter() + .filter_map(|(owner, cmx)| (owner == account).then_some(*cmx)) + .collect(); + if cmxs.is_empty() { + continue; + } + let id = SubwalletId::new(wallet_id, *account); + let entry_id = compute_activity_id(&cmxs.iter().copied().collect::>()); + let mut amount = 0u64; + for action in actions.iter().filter(|action| cmxs.contains(&action.cmx)) { + let wire = ShieldedEncryptedNote { + cmx: action.cmx.to_vec(), + nullifier: action.nullifier.to_vec(), + cv_net: action.cv_net.to_vec(), + encrypted_note: action.encrypted_note.clone(), + }; + let note = try_decrypt_note(&views.prepared_ivk, &wire) + .map(|(note, _)| note) + .or_else(|| { + try_recover_outgoing_note(&views.outgoing_viewing_key, &wire) + .map(|(note, _, _)| note) + }) + .ok_or_else(|| invalid("history output no longer recoverable"))?; + amount = amount + .checked_add(note.value().inner()) + .ok_or_else(|| invalid("history amount overflow"))?; + } + let existing = store.get_activity(id, 0, usize::MAX).map_err(invalid)?; + let notes = store.get_all_notes(id).map_err(invalid)?; + let outgoing = store.get_outgoing_notes(id).map_err(invalid)?; + let mut retained = BTreeMap::new(); + for entry in &existing { + if entry.id != entry_id && entry.note_cmxs.iter().any(|cmx| cmxs.contains(cmx)) { + if entry.created_at_ms != 0 + || entry.block_height.is_some() + || !matches!( + entry.kind, + ShieldedActivityKind::Received + | ShieldedActivityKind::Sent + | ShieldedActivityKind::ShieldedSpend + ) + { + return Err(invalid("exact drain overlaps another authored activity")); + } + history.remove_activity_entry(id, entry.id); + } else { + retained.extend(entry.note_cmxs.iter().map(|cmx| (*cmx, entry.id))); + } + } + let canonical = ShieldedActivityEntry { + id: entry_id, + kind: ShieldedActivityKind::ShieldFromAssetLock, + direction: ShieldedDirection::In, + amount, + fee: None, + counterparty: None, + memo: None, + block_height: None, + status: ShieldedActivityStatus::Confirmed, + // Recovery does not know the original transaction's wall-clock time. + created_at_ms: existing + .iter() + .find(|entry| entry.id == entry_id) + .map_or(0, |entry| entry.created_at_ms), + min_note_position: notes + .iter() + .filter(|note| cmxs.contains(¬e.cmx)) + .map(|note| note.position) + .min(), + note_cmxs: cmxs.iter().copied().collect(), + spent_nullifiers: vec![], + }; + retained.extend(cmxs.iter().map(|cmx| (*cmx, entry_id))); + let own_addresses = outgoing + .iter() + .filter(|note| super::coordinator::is_own_orchard_recipient(views, ¬e.recipient)) + .map(|note| note.recipient.clone()) + .collect(); + let input = ScanDeriveInput { + notes, + outgoing, + own_addresses, + }; + let derived = derive_unowned_activity_from_scan_data(&input, &retained); + for entry in derived.new_entries { + history.record_activity_entry(id, entry); + } + history.record_activity_entry(id, canonical); + } + Ok(history) +} + impl PlatformWallet { /// Replay the newest byte-identical attempt first. A recognized proof /// freshness rejection permits the normal same-outpoint proof rebuild; @@ -161,6 +270,18 @@ impl PlatformWallet { .persist_wallet_drain_attempt(out_point, bytes.clone()) .await?; let st = StateTransition::deserialize_from_bytes_untrusted(bytes).map_err(invalid)?; + let StateTransition::ShieldFromAssetLock(transition) = &st else { + return Err(invalid("expected a signed Type18 transition")); + }; + // Preserve historical evidence, but do not keep replaying a format the + // active protocol no longer accepts. The caller already resolved this + // operation to FromExistingAssetLock, so rebuilding uses the same lock. + if !transition + .verify_protocol_version(self.sdk.version().protocol_version) + .map_err(invalid)? + { + return Ok(false); + } if let Err(error) = st.broadcast(&self.sdk, settings).await { if is_instant_lock_proof_invalid(&error) || as_asset_lock_proof_cl_height_too_low(&error).is_some() @@ -294,7 +415,7 @@ impl PlatformWallet { } observed }; - let Some((_, bytes)) = attempts + let Some((matched_outputs, bytes)) = attempts .iter() .zip(&lock.shielded_funding_attempts) .find(|(outputs, _)| outputs.is_subset(&observed)) @@ -306,16 +427,13 @@ impl PlatformWallet { // Orchard commitments alone do not bind an asset-lock outpoint. let transition = StateTransition::deserialize_from_bytes_untrusted(bytes).map_err(invalid)?; - let StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(attempt)) = - &transition - else { + let StateTransition::ShieldFromAssetLock(attempt) = &transition else { return Err(invalid("expected the matched Type18 attempt")); }; // Every retry randomizes its Orchard bundle. History must describe the // persisted attempt whose exact outputs were observed, not the latest // submitted attempt, which may never have executed. - let actions = attempt.actions.clone(); - let shield_amount = attempt.value_balance; + let actions = attempt.actions().to_vec(); match check_consumption(transition).await { Ok( StateTransitionProofResult::VerifiedAssetLockConsumed( @@ -335,14 +453,28 @@ impl PlatformWallet { let operation_id = lock .operation_id .ok_or_else(|| invalid("operation UUID missing"))?; - // Queue authenticated history before publishing the terminal row. If - // interrupted here, recovery repeats the same natural-key upsert; - // it cannot skip history because a tombstone was already committed. - self.record_shield_from_asset_lock_activity(coordinator, &actions, shield_amount) - .await; + // Serialize with scan derivation through its host persistence round. + // Build a replacement without mutating the memory cache: if the atomic + // history+tombstone write fails, a retry still sees the original rows. + let _checkpoint = coordinator.activity_checkpoint.lock().await; + let history = { + let store = coordinator.store().read().await; + drain_history_changeset(&*store, self.wallet_id(), &keys, &actions, matched_outputs)? + }; self.asset_locks - .complete_wallet_drain(out_point, operation_id) + .complete_wallet_drain(out_point, operation_id, history.clone()) .await?; + let mut store = coordinator.store().write().await; + for (id, removed) in history.activity_removed { + for entry_id in removed { + store.remove_activity(id, &entry_id).map_err(invalid)?; + } + } + for (id, entries) in history.activity_entries { + for entry in entries { + store.save_activity(id, &entry).map_err(invalid)?; + } + } tracing::info!(%out_point, "wallet drain completed from authenticated exact outputs and full original-outpoint consumption"); Ok(true) } @@ -383,7 +515,8 @@ mod tests { use dpp::prelude::AssetLockProof; use dpp::shielded::SerializedAction; use dpp::state_transition::shield_from_asset_lock_transition::methods::ShieldFromAssetLockTransitionMethodsV0; - use dpp::state_transition::shield_from_asset_lock_transition::v0::ShieldFromAssetLockTransitionV0; + use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; + use dpp::version::PlatformVersion; use grovedb_commitment_tree::{ DashMemo, Domain, ExtractedNoteCommitment, Note, NoteValue, Nullifier, OrchardDomain, RandomSeed, Rho, ValueCommitTrapdoor, ValueCommitment, @@ -461,6 +594,16 @@ mod tests { TrackedAssetLock, BTreeMap, StateTransition, + ) { + evidence_fixture_at(PlatformVersion::latest()) + } + + fn evidence_fixture_at( + platform_version: &PlatformVersion, + ) -> ( + TrackedAssetLock, + BTreeMap, + StateTransition, ) { let keys = OrchardKeySet::from_seed(&[0x42; 32], Network::Testnet, 0) .unwrap() @@ -493,7 +636,7 @@ mod tests { let actions = vec![encrypted_action(&keys), encrypted_action(&keys)]; // Actual encrypted outputs and ECDSA signature, with no expensive Halo2 // proof: admission is outside this test; sightings model verified sync. - let st = ShieldFromAssetLockTransitionV0::try_from_asset_lock_with_bundle( + let st = ShieldFromAssetLockTransition::try_from_asset_lock_with_bundle( proof.clone(), &secret.secret_bytes(), actions, @@ -502,7 +645,7 @@ mod tests { vec![], [0; 64], None, - dpp::version::PlatformVersion::latest(), + platform_version, ) .unwrap(); ( @@ -532,10 +675,21 @@ mod tests { fail_flush: AtomicBool, fail_load: AtomicBool, before_store: Mutex>>, + activity: Mutex>, + staged_activity: Mutex>, + fail_activity: AtomicBool, + omit_activity_capability: AtomicBool, } impl PlatformWalletPersistence for RecoveryPersistence { fn persistence_capabilities(&self) -> PersistenceCapabilities { - PersistenceCapabilities::WALLET_DRAIN + let bits = PersistenceCapabilities::WALLET_DRAIN.bits(); + PersistenceCapabilities::from_bits_retain( + if self.omit_activity_capability.load(Ordering::SeqCst) { + bits & !PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION.bits() + } else { + bits + }, + ) } fn store( &self, @@ -548,6 +702,15 @@ mod tests { if self.fail_store.load(Ordering::SeqCst) { return Err(PersistenceError::backend("store failed")); } + if cs + .shielded + .as_ref() + .is_some_and(|cs| !cs.activity_entries.is_empty()) + && self.fail_activity.load(Ordering::SeqCst) + { + return Err(PersistenceError::backend("activity write failed")); + } + *self.staged_activity.lock().unwrap() = cs.shielded; if let Some(cs) = cs.asset_locks { self.staged.lock().unwrap().extend(cs.asset_locks); } @@ -557,6 +720,23 @@ mod tests { if self.fail_flush.load(Ordering::SeqCst) { return Err(PersistenceError::backend("flush failed")); } + if let Some(cs) = self.staged_activity.lock().unwrap().take() { + let mut activity = self.activity.lock().unwrap(); + for (id, ids) in cs.activity_removed { + activity + .entry(id) + .or_default() + .activity + .retain(|entry| !ids.contains(&entry.id)); + } + for (id, entries) in cs.activity_entries { + let rows = &mut activity.entry(id).or_default().activity; + for entry in entries { + rows.retain(|old| old.id != entry.id); + rows.push(entry); + } + } + } self.committed .lock() .unwrap() @@ -591,6 +771,10 @@ mod tests { }) .collect(); Ok(ClientStartState { + shielded: crate::changeset::ShieldedSyncStartState { + per_subwallet: self.activity.lock().unwrap().clone(), + ..Default::default() + }, wallets: BTreeMap::from([( self.wallet.compute_wallet_id(), ClientWalletStartState { @@ -638,8 +822,17 @@ mod tests { fail_flush: AtomicBool::new(false), fail_load: AtomicBool::new(false), before_store: Mutex::new(None), + activity: Mutex::new(BTreeMap::new()), + staged_activity: Mutex::new(None), + fail_activity: AtomicBool::new(false), + omit_activity_capability: AtomicBool::new(false), }); - let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()); + 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![])), @@ -1065,8 +1258,8 @@ mod tests { ) .await; } - // History must be queued before the terminal tombstone, with no - // viewing-key guard held across either persistence callback. + // History and terminal tombstone commit together, before the memory + // cache is updated and with no key/store guard held by callbacks. let activity_store = coordinator.store().clone(); let key_slot = wallet.shielded_keys.clone(); let subwallet = SubwalletId::new(wallet.wallet_id(), 0); @@ -1079,7 +1272,7 @@ mod tests { .get_activity(subwallet, 0, 10) .unwrap() .len(), - 1 + 0 ); })); assert!( @@ -1171,7 +1364,13 @@ mod tests { persistence.committed.lock().unwrap()[&lock.out_point].status, AssetLockStatus::Consumed ); - assert_exact_drain_history(&wallet, &coordinator, &outputs).await; + assert!(coordinator + .store() + .read() + .await + .get_activity(SubwalletId::new(wallet.wallet_id(), 0), 0, 10) + .unwrap() + .is_empty()); persistence.fail_flush.store(false, Ordering::SeqCst); assert!( reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) @@ -1181,6 +1380,332 @@ mod tests { assert_exact_drain_history(&wallet, &coordinator, &outputs).await; } + struct FixedDrainSigner; + #[async_trait::async_trait] + impl key_wallet::signer::Signer for FixedDrainSigner { + type Error = String; + fn supported_methods(&self) -> &[key_wallet::signer::SignerMethod] { + &[key_wallet::signer::SignerMethod::Digest] + } + async fn public_key( + &self, + _: &key_wallet::bip32::DerivationPath, + ) -> Result { + Ok(dashcore::secp256k1::PublicKey::from_secret_key( + &dashcore::secp256k1::Secp256k1::new(), + &dashcore::secp256k1::SecretKey::from_slice(&[0x11; 32]).unwrap(), + )) + } + async fn sign_ecdsa( + &self, + path: &key_wallet::bip32::DerivationPath, + digest: [u8; 32], + ) -> Result< + ( + dashcore::secp256k1::ecdsa::Signature, + dashcore::secp256k1::PublicKey, + ), + String, + > { + let signature = dashcore::secp256k1::Secp256k1::new().sign_ecdsa( + &dashcore::secp256k1::Message::from_digest(digest), + &dashcore::secp256k1::SecretKey::from_slice(&[0x11; 32]).unwrap(), + ); + Ok((signature, self.public_key(path).await?)) + } + } + #[async_trait::async_trait] + impl key_wallet::signer::ExtendedPubKeySigner for FixedDrainSigner { + async fn extended_public_key( + &self, + _: &key_wallet::bip32::DerivationPath, + ) -> Result { + Err("not used by asset-lock signing".into()) + } + } + + #[tokio::test] + async fn should_validate_actual_outer_external_signer_for_each_protocol_generation() { + for version in [PlatformVersion::get(13).unwrap(), PlatformVersion::latest()] { + let (lock, keys, template) = evidence_fixture_at(version); + let StateTransition::ShieldFromAssetLock(attempt) = template else { + panic!("Type18 expected"); + }; + let signed = ShieldFromAssetLockTransition::try_from_asset_lock_with_bundle_and_signer( + attempt.asset_lock_proof().clone(), + &key_wallet::bip32::DerivationPath::default(), + &FixedDrainSigner, + attempt.actions().to_vec(), + attempt.value_balance(), + [0; 32], + vec![], + [0; 64], + None, + version, + ) + .await + .unwrap(); + assert_eq!( + attempt_outputs(&lock, &signed.serialize_to_bytes().unwrap(), &keys) + .unwrap() + .len(), + 2 + ); + assert!(matches!( + (&signed, version.protocol_version), + ( + StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(_)), + 13 + ) | ( + StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V1(_)), + 14 + ) + )); + let mut altered = signed; + if let StateTransition::ShieldFromAssetLock(attempt) = &mut altered { + attempt.set_value_balance(attempt.value_balance() + 1); + } + assert!(attempt_outputs(&lock, &altered.serialize_to_bytes().unwrap(), &keys).is_err()); + } + } + + #[tokio::test] + async fn should_recover_outer_v0_and_v1_and_retire_only_replay_format() { + let v13 = PlatformVersion::get(13).unwrap(); + let (mut lock, keys, old) = evidence_fixture_at(v13); + assert!(matches!( + &old, + StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(_)) + )); + let old_bytes = old.serialize_to_bytes().unwrap(); + let outputs = attempt_outputs(&lock, &old_bytes, &keys).unwrap(); + lock.shielded_funding_attempts.push(old_bytes.clone()); + let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; + let wallet = restart_wallet(wallet, persistence.clone()).await; + // No SDK network expectation: retired V0 is not dispatched to PV14. + assert!(!wallet + .replay_wallet_drain_attempt(lock.out_point, None) + .await + .unwrap()); + let (new_lock, new_keys, current) = evidence_fixture(); + assert_eq!(new_lock.out_point, lock.out_point); + assert!(matches!( + ¤t, + StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V1(_)) + )); + assert!(attempt_outputs(&lock, ¤t.serialize_to_bytes().unwrap(), &new_keys).is_ok()); + wallet + .persist_drain_shielded_attempt(lock.out_point, ¤t) + .await + .unwrap(); + let operation = Arc::new(AssetLockOperationContext::new(lock.operation_id.unwrap())); + let _registration = wallet.register_wallet_drain_operation(operation.clone()); + assert_eq!( + wallet + .asset_locks + .begin_wallet_drain(&operation) + .await + .unwrap(), + Some(lock.out_point) + ); + let committed = persistence.committed.lock().unwrap().clone(); + assert_eq!( + committed.len(), + 1, + "protocol upgrade must never create a second Core lock" + ); + assert_eq!( + committed[&lock.out_point].shielded_funding_attempts, + vec![old_bytes, current.serialize_to_bytes().unwrap()] + ); + // Historical V0 is still usable completion evidence after V1 is appended. + for (index, (account, cmx)) in outputs.iter().enumerate() { + observe( + &coordinator, + wallet.wallet_id(), + *account, + *cmx, + index as u64, + ) + .await; + } + assert!( + reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) + .await + .unwrap() + ); + assert_exact_drain_history(&wallet, &coordinator, &outputs).await; + } + + #[tokio::test] + async fn should_refuse_drain_without_atomic_activity_capability_before_build() { + let (lock, keys, _) = evidence_fixture(); + let (wallet, persistence, _, _dir) = wallet_fixture(&lock, keys).await; + persistence + .omit_activity_capability + .store(true, Ordering::SeqCst); + let operation = Arc::new(AssetLockOperationContext::new([0x98; 16])); + let _registration = wallet.register_wallet_drain_operation(operation.clone()); + let error = wallet + .asset_locks + .begin_wallet_drain(&operation) + .await + .unwrap_err(); + assert!(error + .to_string() + .contains("shielded_activity_reconciliation")); + assert!(operation.snapshot().out_point.is_none()); + assert_eq!(persistence.committed.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn should_atomically_replace_scan_aggregate_preserving_other_receipt_after_restart() { + let (lock, keys, st) = evidence_fixture(); + let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); + let views = keys[&0].clone(); + let (wallet, persistence, coordinator, dir) = wallet_fixture(&lock, keys.clone()).await; + wallet + .persist_drain_shielded_attempt(lock.out_point, &st) + .await + .unwrap(); + let id = SubwalletId::new(wallet.wallet_id(), 0); + for (index, (account, cmx)) in outputs.iter().enumerate() { + observe( + &coordinator, + wallet.wallet_id(), + *account, + *cmx, + index as u64, + ) + .await; + } + let unrelated_cmx = [0x91; 32]; + observe(&coordinator, wallet.wallet_id(), 0, unrelated_cmx, 2).await; + let mut scan = crate::changeset::ShieldedChangeSet::default(); + coordinator + .derive_activity_into_changeset(&[(id, views)], &mut scan) + .await + .unwrap(); + let aggregate = coordinator + .store() + .read() + .await + .get_activity(id, 0, 10) + .unwrap(); + assert_eq!(aggregate.len(), 1); + assert_eq!(aggregate[0].amount, 3_000); + assert_eq!(aggregate[0].note_cmxs.len(), 3); + // A distinct authored C discovered at the same anchor must survive, + // and must not cause residual B to be suppressed by overlap deduping. + let authored_cmx = [0x92; 32]; + observe(&coordinator, wallet.wallet_id(), 0, authored_cmx, 3).await; + let mut authored = aggregate[0].clone(); + authored.id = compute_activity_id(&[authored_cmx]); + authored.note_cmxs = vec![authored_cmx]; + authored.amount = 1_000; + authored.kind = ShieldedActivityKind::Shield; + authored.created_at_ms = 123; + coordinator + .store() + .write() + .await + .save_activity(id, &authored) + .unwrap(); + let before = coordinator + .store() + .read() + .await + .get_activity(id, 0, 10) + .unwrap(); + let snapshot = crate::changeset::ShieldedSubwalletStartState { + activity: before.clone(), + notes: coordinator.store().read().await.get_all_notes(id).unwrap(), + ..Default::default() + }; + persistence.activity.lock().unwrap().insert(id, snapshot); + // Only the activity callback fails; the lock write itself would succeed. + persistence.fail_activity.store(true, Ordering::SeqCst); + assert!( + reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) + .await + .is_err() + ); + let restored = persistence.load().unwrap(); + assert_ne!( + persistence.committed.lock().unwrap()[&lock.out_point].status, + AssetLockStatus::Consumed + ); + assert_eq!(restored.shielded.per_subwallet[&id].activity, before); + // Reopen a fresh coordinator cache entirely from the host snapshot. + drop(coordinator); + let wallet = restart_wallet(wallet, persistence.clone()).await; + let path = dir.0.join("reopened.sqlite"); + let store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + let coordinator = + NetworkShieldedCoordinator::new(wallet.sdk.clone(), Network::Testnet, path, store); + coordinator + .register_wallet(wallet.wallet_id(), keys, wallet.persister().clone()) + .await + .unwrap(); + coordinator + .restore_for_wallet(wallet.wallet_id(), &restored.shielded) + .await + .unwrap(); + persistence.fail_activity.store(false, Ordering::SeqCst); + assert!( + reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) + .await + .unwrap() + ); + let durable = persistence.load().unwrap(); + let rows = &durable.shielded.per_subwallet[&id].activity; + assert_eq!(rows.len(), 3); + assert!(rows.iter().all(|row| row.id != aggregate[0].id)); + assert_eq!(rows.iter().map(|row| row.amount).sum::(), 4_000); + assert!(rows.iter().any( + |row| row.kind == ShieldedActivityKind::ShieldFromAssetLock && row.amount == 2_000 + )); + assert!(rows + .iter() + .any(|row| row.kind == ShieldedActivityKind::Received + && row.note_cmxs == vec![unrelated_cmx] + && row.amount == 1_000)); + assert!(rows.contains(&authored)); + assert_eq!( + persistence.committed.lock().unwrap()[&lock.out_point].status, + AssetLockStatus::Consumed + ); + // A new scan cannot recreate the removed aggregate or double count A. + let mut scan = crate::changeset::ShieldedChangeSet::default(); + coordinator + .derive_activity_into_changeset( + &[( + id, + wallet.shielded_keys.read().await.as_ref().unwrap()[&0].clone(), + )], + &mut scan, + ) + .await + .unwrap(); + // The existing live C may gain a confirmation height; no aggregate + // or duplicate of A/B may be emitted. + assert!(scan + .activity_entries + .values() + .flatten() + .all(|entry| entry.id == authored.id)); + assert_eq!( + coordinator + .store() + .read() + .await + .get_activity(id, 0, 10) + .unwrap() + .len(), + 3 + ); + } + #[test] fn should_reject_foreign_corrupted_unsigned_or_incomplete_drain_evidence() { let (lock, keys, st) = evidence_fixture(); @@ -1204,17 +1729,13 @@ mod tests { )]); assert!(attempt_outputs(&lock, &bytes, &other_keys).is_err()); let mut altered = st.clone(); - if let StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(v0)) = - &mut altered - { - v0.value_balance += 1; + if let StateTransition::ShieldFromAssetLock(transition) = &mut altered { + transition.set_value_balance(transition.value_balance() + 1); } assert!(attempt_outputs(&lock, &altered.serialize_to_bytes().unwrap(), &keys).is_err()); let mut unsigned = st; - if let StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(v0)) = - &mut unsigned - { - v0.signature = Default::default(); + if let StateTransition::ShieldFromAssetLock(transition) = &mut unsigned { + transition.set_signature(Default::default()); } assert!(attempt_outputs(&lock, &unsigned.serialize_to_bytes().unwrap(), &keys).is_err()); } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 0970e75d48a..4d964991d73 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -755,6 +755,13 @@ impl ShieldedStore for FileBackedShieldedStore { Ok(()) } + fn remove_activity(&mut self, id: SubwalletId, entry_id: &[u8; 32]) -> Result<(), Self::Error> { + if let Some(subwallet) = self.subwallets.get_mut(&id) { + subwallet.remove_activity(entry_id); + } + Ok(()) + } + fn get_activity( &self, id: SubwalletId, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 6e20a0ae1fc..c7bfb5e54e8 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -370,6 +370,9 @@ pub trait ShieldedStore: Send + Sync { entry: &super::activity::ShieldedActivityEntry, ) -> Result<(), Self::Error>; + /// Remove a superseded scan aggregate after its durable replacement commits. + fn remove_activity(&mut self, id: SubwalletId, entry_id: &[u8; 32]) -> Result<(), Self::Error>; + /// Return a page of derived activity for `id`, sorted for display /// (pendings first, then by `block_height` desc, tiebreak by /// `created_at_ms` then `id`), sliced by `[offset, offset+limit)`. @@ -795,6 +798,10 @@ impl SubwalletState { self.activity.insert(entry.id, entry.clone()); } + pub(super) fn remove_activity(&mut self, entry_id: &[u8; 32]) { + self.activity.remove(entry_id); + } + /// Display-sorted page of activity entries. pub(super) fn activity_page( &self, @@ -1043,6 +1050,13 @@ impl ShieldedStore for InMemoryShieldedStore { .unwrap_or_default()) } + fn remove_activity(&mut self, id: SubwalletId, entry_id: &[u8; 32]) -> Result<(), Self::Error> { + if let Some(subwallet) = self.subwallets.get_mut(&id) { + subwallet.remove_activity(entry_id); + } + Ok(()) + } + fn get_activity_by_entry_id( &self, id: SubwalletId, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 66c5ad40204..36b5a4901a3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -110,6 +110,8 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// Durable operation UUIDs, including consumed asset-lock tombstones. public static let assetLockOperationIds: UInt64 = 1 << 13 public static let shieldedFundingAttempts: UInt64 = 1 << 14 + /// Atomic activity replacement and strict durable restoration. + public static let shieldedActivityReconciliation: UInt64 = 1 << 15 public let version: UInt32 public let bits: UInt64 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index c7051c7367b..205e9f93693 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3318,6 +3318,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.dashpayPayments | PlatformWalletPersistenceCapabilities.assetLockOperationIds | PlatformWalletPersistenceCapabilities.shieldedFundingAttempts + | PlatformWalletPersistenceCapabilities.shieldedActivityReconciliation ) } @@ -3360,6 +3361,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { extensionCallbacks.on_persist_shielded_funding_attempt_fn = persistShieldedFundingAttemptCallback extensionCallbacks.on_load_shielded_funding_attempts_fn = loadShieldedFundingAttemptsCallback extensionCallbacks.on_load_shielded_funding_attempts_free_fn = loadShieldedFundingAttemptsFreeCallback + extensionCallbacks.on_remove_shielded_activity_fn = removeShieldedActivityCallback return extensionCallbacks } @@ -5729,61 +5731,91 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `entryId` refines the existing row in place: a `Pending` row flips /// to `Confirmed`/`Failed`, and a scan-derived `ShieldedSpend` can be /// upgraded to a richer kind (the Rust side re-emits the same id). - func persistShieldedActivity(walletId: Data, snapshots: [ShieldedActivitySnapshot]) { + @discardableResult + func persistShieldedActivity(walletId: Data, snapshots: [ShieldedActivitySnapshot]) -> Bool { onQueue { - for snap in snapshots { - let wid = snap.walletId - let acct = snap.accountIndex - let eid = snap.entryId - let predicate = #Predicate { - $0.walletId == wid && $0.accountIndex == acct && $0.entryId == eid + do { + for snap in snapshots { + guard snap.walletId == walletId, walletId.count == 32, snap.entryId.count == 32, + snap.noteCmxs.count % 32 == 0, snap.spentNullifiers.count % 32 == 0 else { + throw AssetLockPersistenceError.malformedRecord + } + let wid = snap.walletId + let acct = snap.accountIndex + let eid = snap.entryId + let predicate = #Predicate { + $0.walletId == wid && $0.accountIndex == acct && $0.entryId == eid + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + if let existing = try modelFetcher.fetch(descriptor, in: backgroundContext).first { + existing.kindTag = snap.kindTag + existing.direction = snap.direction + existing.status = snap.status + existing.amount = snap.amount + existing.fee = snap.fee + existing.hasFee = snap.hasFee + existing.blockHeight = snap.blockHeight + existing.hasBlockHeight = snap.hasBlockHeight + existing.createdAtMs = snap.createdAtMs + existing.minNotePosition = snap.minNotePosition + existing.hasMinNotePosition = snap.hasMinNotePosition + existing.identityId = snap.identityId + existing.counterparty = snap.counterparty + existing.memo = snap.memo + existing.noteCmxs = snap.noteCmxs + existing.spentNullifiers = snap.spentNullifiers + existing.lastUpdated = Date() + } else { + let row = PersistentShieldedActivity( + walletId: snap.walletId, + accountIndex: snap.accountIndex, + entryId: snap.entryId, + kindTag: snap.kindTag, + direction: snap.direction, + status: snap.status, + amount: snap.amount, + fee: snap.fee, + hasFee: snap.hasFee, + blockHeight: snap.blockHeight, + hasBlockHeight: snap.hasBlockHeight, + createdAtMs: snap.createdAtMs, + minNotePosition: snap.minNotePosition, + hasMinNotePosition: snap.hasMinNotePosition, + identityId: snap.identityId, + counterparty: snap.counterparty, + memo: snap.memo, + noteCmxs: snap.noteCmxs, + spentNullifiers: snap.spentNullifiers + ) + backgroundContext.insert(row) + } } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - if let existing = try? backgroundContext.fetch(descriptor).first { - existing.kindTag = snap.kindTag - existing.direction = snap.direction - existing.status = snap.status - existing.amount = snap.amount - existing.fee = snap.fee - existing.hasFee = snap.hasFee - existing.blockHeight = snap.blockHeight - existing.hasBlockHeight = snap.hasBlockHeight - existing.createdAtMs = snap.createdAtMs - existing.minNotePosition = snap.minNotePosition - existing.hasMinNotePosition = snap.hasMinNotePosition - existing.identityId = snap.identityId - existing.counterparty = snap.counterparty - existing.memo = snap.memo - existing.noteCmxs = snap.noteCmxs - existing.spentNullifiers = snap.spentNullifiers - existing.lastUpdated = Date() - } else { - let row = PersistentShieldedActivity( - walletId: snap.walletId, - accountIndex: snap.accountIndex, - entryId: snap.entryId, - kindTag: snap.kindTag, - direction: snap.direction, - status: snap.status, - amount: snap.amount, - fee: snap.fee, - hasFee: snap.hasFee, - blockHeight: snap.blockHeight, - hasBlockHeight: snap.hasBlockHeight, - createdAtMs: snap.createdAtMs, - minNotePosition: snap.minNotePosition, - hasMinNotePosition: snap.hasMinNotePosition, - identityId: snap.identityId, - counterparty: snap.counterparty, - memo: snap.memo, - noteCmxs: snap.noteCmxs, - spentNullifiers: snap.spentNullifiers - ) - backgroundContext.insert(row) + if !inChangeset { try backgroundContext.save() } + return true + } catch { + if !inChangeset { backgroundContext.rollback() } + return false + } + } + } + + /// Remove only the rows selected by Rust, within the same atomic round + /// as their replacements and the asset-lock completion marker. + func removeShieldedActivity(walletId: Data, accountIndex: UInt32, entryIds: [Data]) throws { + try onQueue { + guard inChangeset else { throw AssetLockPersistenceError.requiresChangeset } + guard walletId.count == 32, entryIds.allSatisfy({ $0.count == 32 }) else { + throw AssetLockPersistenceError.malformedRecord + } + for entryId in entryIds { + let descriptor = FetchDescriptor(predicate: #Predicate { + $0.walletId == walletId && $0.accountIndex == accountIndex && $0.entryId == entryId + }) + for row in try modelFetcher.fetch(descriptor, in: backgroundContext) { + backgroundContext.delete(row) } } - saveBackgroundContextIfNeeded(operation: "shielded_activity", walletId: walletId) } } @@ -6149,14 +6181,19 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let descriptor = FetchDescriptor() var rows: [PersistentShieldedActivity] do { - rows = try backgroundContext.fetch(descriptor) + rows = try modelFetcher.fetch(descriptor, in: backgroundContext) + if let network = self.network { + let raw = network.rawValue + let wallets = try modelFetcher.fetch(FetchDescriptor( + predicate: #Predicate { $0.networkRaw == raw } + ), in: backgroundContext) + let ids = Set(wallets.map { $0.walletId }) + rows = rows.filter { ids.contains($0.walletId) } + } } catch { resultErrored = true return } - if let inNetworkIds = self.inNetworkWalletIds() { - rows = rows.filter { inNetworkIds.contains($0.walletId) } - } if rows.isEmpty { return } @@ -6168,16 +6205,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { allocation.entriesCount = rows.count var written = 0 for row in rows { - guard row.walletId.count == 32, row.entryId.count == 32 else { continue } - // Exact conversion, not truncating: a stored tag outside - // u8 range (corruption / unmigrated future tag) must NOT - // wrap into a different valid-looking discriminant (256 → - // 0 = Shield/In/Pending) — that would bypass Rust's - // unknown-tag fallback. Drop the row instead. - guard let kindTagU8 = UInt8(exactly: row.kindTag), + // A partial restore cannot establish durable history. Fail + // the whole read on corrupt rows or linkage instead of hiding + // them and allowing recovery to commit a completion marker. + guard row.walletId.count == 32, row.entryId.count == 32, + row.identityId.isEmpty || row.identityId.count == 32, + row.noteCmxs.count % 32 == 0, row.spentNullifiers.count % 32 == 0, + let kindTagU8 = UInt8(exactly: row.kindTag), let directionU8 = UInt8(exactly: row.direction), let statusU8 = UInt8(exactly: row.status) else { - continue + allocation.release() + resultErrored = true + return } // Per-row variable-length buffers: counterparty, memo, @@ -6224,10 +6263,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { counterparty_len: UInt(cpLen), memo_ptr: memoLen > 0 ? UnsafePointer(memoPtr) : nil, memo_len: UInt(memoLen), - // A persisted blob that isn't a whole number of - // 32-byte elements is corrupt — drop the linkage - // (count 0, null ptr) rather than silently truncating - // trailing bytes into a wrong-but-plausible array. + // Linkage lengths were validated before allocation; + // preserve every complete 32-byte element on restore. note_cmxs_ptr: cmxLen > 0 && cmxLen % 32 == 0 ? UnsafePointer(cmxPtr) : nil, note_cmxs_count: cmxLen % 32 == 0 ? UInt(cmxLen / 32) : 0, spent_nullifiers_ptr: nfLen > 0 && nfLen % 32 == 0 ? UnsafePointer(nfPtr) : nil, @@ -10861,7 +10898,8 @@ private func persistShieldedActivityCallback( entriesPtr: UnsafePointer?, count: UInt ) -> Int32 { - guard let context = context, let walletIdPtr = walletIdPtr else { return 0 } + guard let context, let walletIdPtr, count <= UInt(Int.max), + count == 0 || entriesPtr != nil else { return -1 } let handler = Unmanaged .fromOpaque(context) .takeUnretainedValue() @@ -10874,21 +10912,24 @@ private func persistShieldedActivityCallback( let e = entriesPtr[i] // Copy every pointer-backed field out now — the Rust // pointers are only valid for this callback window. + func validBuffer(_ ptr: UnsafePointer?, _ len: UInt) -> Bool { + len <= UInt(Int.max) && (len == 0 || ptr != nil) + } + let (cmxBytes, cmxOverflow) = UInt(e.note_cmxs_count).multipliedReportingOverflow(by: 32) + let (nfBytes, nfOverflow) = UInt(e.spent_nullifiers_count).multipliedReportingOverflow(by: 32) + guard !cmxOverflow, !nfOverflow, + validBuffer(e.counterparty_ptr, UInt(e.counterparty_len)), + validBuffer(e.memo_ptr, UInt(e.memo_len)), + validBuffer(e.note_cmxs_ptr, cmxBytes), + validBuffer(e.spent_nullifiers_ptr, nfBytes) else { return -1 } func data(_ ptr: UnsafePointer?, _ len: UInt) -> Data { - if let ptr = ptr, len > 0 { return Data(bytes: ptr, count: Int(len)) } - return Data() + guard let ptr, len > 0 else { return Data() } + return Data(bytes: ptr, count: Int(len)) } let counterparty = data(e.counterparty_ptr, UInt(e.counterparty_len)) let memo = data(e.memo_ptr, UInt(e.memo_len)) - // The counts are Rust-supplied: use checked multiplication so - // a corrupt row degrades to an empty linkage instead of a - // trapped overflow crashing the callback path. - func byteLen(_ count: UInt) -> UInt { - let (value, overflow) = count.multipliedReportingOverflow(by: 32) - return overflow ? 0 : value - } - let noteCmxs = data(e.note_cmxs_ptr, byteLen(UInt(e.note_cmxs_count))) - let spentNullifiers = data(e.spent_nullifiers_ptr, byteLen(UInt(e.spent_nullifiers_count))) + let noteCmxs = data(e.note_cmxs_ptr, cmxBytes) + let spentNullifiers = data(e.spent_nullifiers_ptr, nfBytes) let identityId = e.has_identity_id != 0 ? dataFromTuple32(e.identity_id) : Data() snapshots.append(.init( walletId: dataFromTuple32(e.wallet_id), @@ -10913,8 +10954,34 @@ private func persistShieldedActivityCallback( )) } } - handler.persistShieldedActivity(walletId: walletId, snapshots: snapshots) - return 0 + return handler.persistShieldedActivity(walletId: walletId, snapshots: snapshots) ? 0 : -1 +} + +private func removeShieldedActivityCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + accountIndex: UInt32, + entryIdsPtr: UnsafePointer?, + count: UInt +) -> Int32 { + guard let context, let walletIdPtr, count <= UInt(Int.max / 32), + count == 0 || entryIdsPtr != nil else { return -1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + let walletId = Data(bytes: walletIdPtr, count: 32) + let ids: [Data] + if let entryIdsPtr { + ids = (0.. PlatformWalletPersistenceHandler.ShieldedActivitySnapshot { + .init(walletId: wallet ?? walletId, accountIndex: account, + entryId: Data(repeating: id, count: 32), kindTag: 6, direction: 0, status: 1, + amount: amount, fee: 0, hasFee: false, blockHeight: 1, hasBlockHeight: true, + createdAtMs: 0, minNotePosition: 0, hasMinNotePosition: true, + identityId: Data(), counterparty: Data(), memo: Data(), + noteCmxs: notes.reduce(into: Data()) { $0.append(Data(repeating: $1, count: 32)) }, + spentNullifiers: Data()) + } + + private func lock(_ status: Int) -> 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: Data(repeating: 1, count: 16)) + } + + private func seed(_ handler: PlatformWalletPersistenceHandler) { + handler.beginChangeset(walletId: walletId) + XCTAssertTrue(handler.persistShieldedActivity(walletId: walletId, + snapshots: [activity(9, notes: [1, 2], amount: 300), activity(9, account: 1)])) + XCTAssertTrue(handler.persistAssetLocks(walletId: walletId, upserts: [lock(0)], removed: [])) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + XCTAssertTrue(handler.persistShieldedActivity(walletId: otherWalletId, + snapshots: [activity(9, wallet: otherWalletId)])) + } + + private func replace(_ handler: PlatformWalletPersistenceHandler) throws { + handler.beginChangeset(walletId: walletId) + let remove = try XCTUnwrap(handler.makePersistenceCallbacksExtension().on_remove_shielded_activity_fn) + let removedIds = Data(repeating: 9, count: 32) + let result = walletId.withUnsafeBytes { walletBytes in + removedIds.withUnsafeBytes { idBytes in + remove(Unmanaged.passUnretained(handler).toOpaque(), + walletBytes.bindMemory(to: UInt8.self).baseAddress, 0, + idBytes.bindMemory(to: UInt8.self).baseAddress, 1) + } + } + XCTAssertEqual(result, 0) + XCTAssertTrue(handler.persistShieldedActivity(walletId: walletId, + snapshots: [activity(1), activity(2, notes: [2], amount: 200)])) + XCTAssertTrue(handler.persistAssetLocks(walletId: walletId, upserts: [lock(4)], removed: [])) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + } + + func testReplacementAndCompletionSurviveReopenWithoutTouchingOtherAccountsOrWallets() 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) + seed(handler) + try replace(handler) + try replace(handler) // An uncertain completion can replay idempotently. + } + let container = try DashModelContainer.create(url: url) + let rows = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(rows.count, 4) + let own = rows.filter { $0.walletId == walletId && $0.accountIndex == 0 } + XCTAssertEqual(Set(own.map(\.entryId)), Set([Data(repeating: 1, count: 32), Data(repeating: 2, count: 32)])) + XCTAssertEqual(own.reduce(0) { $0 + $1.amount }, 300) + XCTAssertEqual(rows.filter { $0.accountIndex == 1 }.count, 1) + XCTAssertEqual(rows.filter { $0.walletId == otherWalletId }.count, 1) + let handler = PlatformWalletPersistenceHandler(modelContainer: container) + XCTAssertEqual(try handler.loadCachedAssetLocks(walletId: walletId).first?.statusRaw, 4) + let restored = handler.loadShieldedActivity() + defer { handler.loadShieldedActivityFree(entries: restored.entries.map(UnsafeRawPointer.init)) } + XCTAssertFalse(restored.errored) + XCTAssertEqual(restored.count, 4) + } + + func testActivityUpsertFailureRollsBackCompletionAndAllowsRetryAfterReopen() throws { + try assertHistoryFailureRollsBack(removal: false) + } + + func testActivityRemovalFailureRollsBackCompletionAndAllowsRetryAfterReopen() throws { + try assertHistoryFailureRollsBack(removal: true) + } + + private func assertHistoryFailureRollsBack(removal: Bool) 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) + seed(PlatformWalletPersistenceHandler(modelContainer: container)) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: nil, + modelFetcher: FetchFaultInjector(faulting: PersistentShieldedActivity.self)) + handler.beginChangeset(walletId: walletId) + XCTAssertTrue(handler.persistAssetLocks(walletId: walletId, upserts: [lock(4)], removed: [])) + if removal { + XCTAssertThrowsError(try handler.removeShieldedActivity(walletId: walletId, accountIndex: 0, + entryIds: [Data(repeating: 9, count: 32)])) + } else { + XCTAssertFalse(handler.persistShieldedActivity(walletId: walletId, snapshots: [activity(1)])) + } + XCTAssertFalse(handler.endChangeset(walletId: walletId, success: false)) + } + let reopened = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: reopened) + XCTAssertEqual(try handler.loadCachedAssetLocks(walletId: walletId).first?.statusRaw, 0) + let rows = try ModelContext(reopened).fetch(FetchDescriptor()) + XCTAssertEqual(rows.count, 3) + XCTAssertEqual(rows.first { $0.walletId == walletId && $0.accountIndex == 0 }?.amount, 300) + try replace(handler) + XCTAssertEqual(try handler.loadCachedAssetLocks(walletId: walletId).first?.statusRaw, 4) + } + + func testActivityRestoreFailsOnUnreadableHistoryAndWalletScope() throws { + let container = try DashModelContainer.createInMemory() + for model in [PersistentShieldedActivity.self, PersistentWallet.self] as [any PersistentModel.Type] { + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet, + modelFetcher: FetchFaultInjector(faulting: model)) + let restored = handler.loadShieldedActivity() + XCTAssertTrue(restored.errored) + XCTAssertNil(restored.entries) + XCTAssertEqual(restored.count, 0) + } + } + + func testActivityRestoreRejectsCorruptLinkageInsteadOfReturningPartialHistory() throws { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container) + seed(handler) + let context = ModelContext(container) + let rows = try context.fetch(FetchDescriptor()) + rows[0].noteCmxs = Data([1]) + try context.save() + let reader = PlatformWalletPersistenceHandler(modelContainer: container) + let restored = reader.loadShieldedActivity() + XCTAssertTrue(restored.errored) + XCTAssertNil(restored.entries) + XCTAssertEqual(restored.count, 0) + } +} From 52d6c5692f3d395fd5c9493e9c40b1ec9dbbc068 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 13 Oct 2026 18:51:19 +0200 Subject: [PATCH 04/24] fix(platform-wallet): close drain cancellation and restore gaps --- .../src/wallet/asset_lock/build.rs | 236 ++++++++++++++---- .../src/wallet/shielded/drain_recovery.rs | 56 +++++ .../wallet/shielded/fund_from_asset_lock.rs | 15 ++ .../PlatformWalletPersistenceHandler.swift | 150 +++++------ .../ShieldedActivityReconciliationTests.swift | 226 +++++++++++++++++ 5 files changed, 536 insertions(+), 147 deletions(-) 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 cdc3c1632da..cb9f83d4349 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -22,7 +22,9 @@ 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, AssetLockChangeSet, PlatformWalletChangeSet}; +use crate::changeset::{ + AccountAddressPoolEntry, AccountRegistrationEntry, AssetLockChangeSet, PlatformWalletChangeSet, +}; use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::PlatformWalletInfo; use crate::ASSET_LOCK_FUNDING_SOURCES; @@ -159,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, @@ -213,6 +215,7 @@ impl AssetLockManager { Option, Vec, crate::wallet::core::InBroadcastPin, + Vec, ), PlatformWalletError, > { @@ -374,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(( @@ -382,6 +389,7 @@ impl AssetLockManager { result.reservation_token, result.funding_accounts, in_broadcast_pin, + funding_pools, )) } @@ -699,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` / @@ -715,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(()); } @@ -966,8 +967,8 @@ impl AssetLockManager { operation: Option<&AssetLockOperationContext>, ) -> Result<(DerivationPath, OutPoint), PlatformWalletError> { // 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 @@ -1005,7 +1006,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, @@ -1080,12 +1081,6 @@ impl AssetLockManager { } } - // Keep the exact outpoint if a lock-row store/flush is uncertain: - // restart may recover that row even before network dispatch. A known - // funding-pool failure below clears it because no row was written. - if let Some(operation) = operation { - operation.set(AssetLockOperationState::Pending, Some(out_point)); - } // Persist the funding account's address pool now that the build marked // its index used. These asset-lock accounts fund OP_RETURN-payload // credit outputs that never appear as on-chain UTXOs, so SPV can never @@ -1099,7 +1094,7 @@ 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 { + let pool_durability = match self.persist_asset_lock_account_pools(funding_pools) { Ok(()) if funding_type == AssetLockFundingType::IdentityInvitation || operation.is_some() => @@ -1172,7 +1167,11 @@ impl AssetLockManager { status: AssetLockStatus::Built, proof: None, }; - if operation.is_some() { + if let Some(operation) = operation { + // 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. @@ -4146,6 +4145,137 @@ mod tests { } } + /// 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" + ); + drop(stored); + 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)); + } + #[tokio::test] async fn should_wallet_drain_pool_store_failure_release_inputs_for_retry() { assert_wallet_drain_pool_failure_can_retry(true).await; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs index da86af6e927..72869c7a594 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs @@ -1537,6 +1537,62 @@ mod tests { assert_exact_drain_history(&wallet, &coordinator, &outputs).await; } + #[tokio::test] + async fn should_refuse_drain_without_viewing_keys_before_core_funding() { + for empty_binding in [false, true] { + let (mut lock, keys, _) = evidence_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::super::prover::CachedOrchardProver::new(); + let operation = Arc::new(AssetLockOperationContext::new([0x97; 16])); + let error = wallet + .shielded_fund_from_asset_lock( + &coordinator, + crate::wallet::asset_lock::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_refuse_drain_without_atomic_activity_capability_before_build() { let (lock, keys, _) = evidence_fixture(); 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 e7e9b1c1c73..f12d12e1079 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 @@ -322,6 +322,21 @@ impl PlatformWallet { operation, .. } => { + // A drain requires owned-output evidence for restart recovery. + // Refuse missing keys 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, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 205e9f93693..21afe248a3e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -25,6 +25,10 @@ private enum AssetLockPersistenceError: Error { case requiresChangeset } +private enum ShieldedRestoreError: Error { + case malformedWalletId +} + struct LiveModelFetcher: ModelFetching { func fetch( _ descriptor: FetchDescriptor, @@ -5928,16 +5932,33 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// network (legacy callers that haven't threaded `network` through), /// signalling "don't filter" so those paths keep their pre-refactor /// cross-network behavior. Caller must be on `onQueue`. - private func inNetworkWalletIds() -> Set? { + private func inNetworkWalletIds() throws -> Set? { guard let network = self.network else { return nil } let raw = network.rawValue let descriptor = FetchDescriptor( predicate: #Predicate { $0.networkRaw == raw } ) - let wallets = (try? backgroundContext.fetch(descriptor)) ?? [] + let wallets = try modelFetcher.fetch(descriptor, in: backgroundContext) + guard wallets.allSatisfy({ $0.walletId.count == 32 }) else { + throw ShieldedRestoreError.malformedWalletId + } return Set(wallets.map { $0.walletId }) } + /// A malformed wallet ID cannot safely be classified as another network. + /// Validate it before filtering, and propagate scope-fetch failures instead + /// of returning a successful empty snapshot. Recovery needs the complete + /// scan snapshot to preserve unrelated receipts when replacing history. + private func shieldedRowsForNetwork( + _ rows: [Row], walletId: (Row) -> Data + ) throws -> [Row] { + guard rows.allSatisfy({ walletId($0).count == 32 }) else { + throw ShieldedRestoreError.malformedWalletId + } + guard let ids = try inNetworkWalletIds() else { return rows } + return rows.filter { ids.contains(walletId($0)) } + } + /// Build the host-allocated `ShieldedNoteRestoreFFI` array Rust /// reads at boot. The allocation is tracked in /// `shieldedLoadAllocations` and freed by @@ -5952,47 +5973,33 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var resultErrored = false onQueue { let descriptor = FetchDescriptor() - var rows: [PersistentShieldedNote] + let rows: [PersistentShieldedNote] do { - rows = try backgroundContext.fetch(descriptor) + rows = try shieldedRowsForNetwork( + modelFetcher.fetch(descriptor, in: backgroundContext), walletId: { $0.walletId } + ) } catch { resultErrored = true return } - // Scope to the handler's bound network so a per-network - // manager never rehydrates another network's shielded notes - // (the commitment tree DB is network-scoped). `nil` ids => - // no in-network wallets => nothing to restore. - if let inNetworkIds = self.inNetworkWalletIds() { - rows = rows.filter { inNetworkIds.contains($0.walletId) } - } if rows.isEmpty { return } + // Validate the complete snapshot before allocating or returning any + // rows. Omitting a corrupt unrelated note would let reconciliation + // replace an aggregate history entry without preserving that note. + guard rows.allSatisfy({ + $0.cmx.count == 32 && $0.nullifier.count == 32 && $0.noteData.count == 115 + }) else { + resultErrored = true + return + } let allocation = ShieldedLoadAllocation() - // Allocate the entries buffer up front; populate slots - // one by one and track `entriesInitialized` so a - // mid-loop bail-out can deinit only the populated - // slots. (Today nothing fails in this loop, but - // matching the existing `LoadAllocation` pattern keeps - // future field additions safe.) let buf = UnsafeMutablePointer.allocate(capacity: rows.count) allocation.entries = buf allocation.entriesCount = rows.count - // `written` is the next free slot in `buf`; we increment it - // only after a row's struct is fully populated, so the - // returned prefix `[0..written)` is contiguous initialized - // memory regardless of how many rows are skipped by the - // length guards below. Indexing by `rows.enumerated()`'s - // `idx` here would leave gaps when an early row is skipped - // and Rust would read uninitialized bytes off the - // `slice::from_raw_parts(ptr, count)` it builds in - // `FFIPersister::load`. var written = 0 for row in rows { - guard row.walletId.count == 32 else { continue } - guard row.cmx.count == 32 else { continue } - guard row.nullifier.count == 32 else { continue } let noteDataBuf = UnsafeMutablePointer.allocate(capacity: row.noteData.count) row.noteData.copyBytes(to: noteDataBuf, count: row.noteData.count) allocation.scalarBuffers.append((noteDataBuf, row.noteData.count)) @@ -6065,55 +6072,30 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var resultErrored = false onQueue { let descriptor = FetchDescriptor() - var rows: [PersistentShieldedOutgoingNote] + let rows: [PersistentShieldedOutgoingNote] do { - rows = try backgroundContext.fetch(descriptor) + rows = try shieldedRowsForNetwork( + modelFetcher.fetch(descriptor, in: backgroundContext), walletId: { $0.walletId } + ) } catch { resultErrored = true return } - // Scope to the handler's bound network so a per-network - // manager never rehydrates another network's send history - // (the commitment tree DB is network-scoped). `nil` ids => - // no in-network wallets => nothing to restore. - if let inNetworkIds = self.inNetworkWalletIds() { - rows = rows.filter { inNetworkIds.contains($0.walletId) } - } if rows.isEmpty { return } + guard rows.allSatisfy({ $0.cmx.count == 32 && $0.recipient.count == 43 }) else { + resultErrored = true + return + } let allocation = ShieldedOutgoingNoteLoadAllocation() let buf = UnsafeMutablePointer.allocate( capacity: rows.count ) allocation.entries = buf allocation.entriesCount = rows.count - // Same `written`-counter discipline as `loadShieldedNotes`: - // increment only after a slot is fully populated so the - // returned prefix `[0..written)` is contiguous initialized - // memory even when malformed rows are skipped. var written = 0 for row in rows { - guard row.walletId.count == 32 else { continue } - guard row.cmx.count == 32 else { continue } - // `recipient` is a fixed 43-byte raw Orchard address. - // A wrong-length blob is a corrupt row — skip it (with - // a log) rather than zero-padding it into a wrong - // address. Mirrors the Rust persist side, which rejects - // non-43-byte recipients before they reach SwiftData. - guard row.recipient.count == 43 else { - SDKLogger.event( - "persistence_shielded_outgoing_row_skipped", - category: .persistence, - severity: .warning, - fields: [ - "actual_length": .integer(Int64(row.recipient.count)), - "expected_length": .integer(43), - "reason": .publicText("malformed_recipient"), - ] - ) - continue - } let memoBuf = UnsafeMutablePointer.allocate(capacity: row.memo.count) if row.memo.count > 0 { row.memo.copyBytes(to: memoBuf, count: row.memo.count) @@ -6181,15 +6163,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let descriptor = FetchDescriptor() var rows: [PersistentShieldedActivity] do { - rows = try modelFetcher.fetch(descriptor, in: backgroundContext) - if let network = self.network { - let raw = network.rawValue - let wallets = try modelFetcher.fetch(FetchDescriptor( - predicate: #Predicate { $0.networkRaw == raw } - ), in: backgroundContext) - let ids = Set(wallets.map { $0.walletId }) - rows = rows.filter { ids.contains($0.walletId) } - } + rows = try shieldedRowsForNetwork( + modelFetcher.fetch(descriptor, in: backgroundContext), walletId: { $0.walletId } + ) } catch { resultErrored = true return @@ -6305,19 +6281,15 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var resultErrored = false onQueue { let descriptor = FetchDescriptor() - var rows: [PersistentShieldedSyncState] + let rows: [PersistentShieldedSyncState] do { - rows = try backgroundContext.fetch(descriptor) + rows = try shieldedRowsForNetwork( + modelFetcher.fetch(descriptor, in: backgroundContext), walletId: { $0.walletId } + ) } catch { resultErrored = true return } - // Same network scoping as `loadShieldedNotes` — keep both - // loaders consistent so a per-network manager doesn't restore - // foreign-network sync watermarks. - if let inNetworkIds = self.inNetworkWalletIds() { - rows = rows.filter { inNetworkIds.contains($0.walletId) } - } if rows.isEmpty { return } @@ -6327,12 +6299,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) allocation.entries = buf allocation.entriesCount = rows.count - // Same `written`-counter pattern as `loadShieldedNotes`: - // skip malformed rows without leaving holes in the - // contiguous prefix Rust will read. var written = 0 for row in rows { - guard row.walletId.count == 32 else { continue } var walletIdTuple: FFIByteTuple32 = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) row.walletId.withUnsafeBytes { src in Swift.withUnsafeMutableBytes(of: &walletIdTuple) { dst in @@ -6380,26 +6348,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var resultErrored = false onQueue { let descriptor = FetchDescriptor() - var rows: [PersistentShieldedViewingKey] + let rows: [PersistentShieldedViewingKey] do { - rows = try backgroundContext.fetch(descriptor) + rows = try shieldedRowsForNetwork( + modelFetcher.fetch(descriptor, in: backgroundContext), walletId: { $0.walletId } + ) } catch { resultErrored = true return } - // Same network scoping as the other shielded loaders — the - // FVK embeds the coin type, so serving another network's - // rows would fail the Rust-side bind, not corrupt it, but - // the loaders stay consistent regardless. - if let inNetworkIds = self.inNetworkWalletIds() { - rows = rows.filter { inNetworkIds.contains($0.walletId) } - } if rows.isEmpty { return } // Fail closed on a present-but-malformed row, BEFORE any - // allocation: silently skipping it (the sync-state - // loader's pattern) would make Rust see the account as + // allocation: silently skipping it would make Rust see the account as // "no persisted key" and fall back to a mnemonic resolve, // masking persistence corruption — the exact opposite of // `bind_shielded_from_persisted`'s documented contract, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedActivityReconciliationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedActivityReconciliationTests.swift index 132e0649ebe..d8029b76f99 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedActivityReconciliationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedActivityReconciliationTests.swift @@ -1,4 +1,5 @@ import Foundation +import DashSDKFFI import SwiftData import XCTest @testable import SwiftDashSDK @@ -147,4 +148,229 @@ final class ShieldedActivityReconciliationTests: XCTestCase { XCTAssertNil(restored.entries) XCTAssertEqual(restored.count, 0) } + + + private enum RestoreKind: CaseIterable { + case notes, outgoing, syncStates, viewingKeys, activity + + var model: any PersistentModel.Type { + switch self { + case .notes: return PersistentShieldedNote.self + case .outgoing: return PersistentShieldedOutgoingNote.self + case .syncStates: return PersistentShieldedSyncState.self + case .viewingKeys: return PersistentShieldedViewingKey.self + case .activity: return PersistentShieldedActivity.self + } + } + } + + /// Exercise the actual callbacks Rust consumes, including their ownership + /// contract: a failed load must expose neither an allocation nor a prefix. + private func restore(_ kind: RestoreKind, with handler: PlatformWalletPersistenceHandler) + -> (code: Int32, count: UInt, hasEntries: Bool) { + let callbacks = handler.makeCallbacks() + defer { callbacks.release_fn?(callbacks.context) } + func load( + _ type: Entry.Type, + read: (UnsafeMutablePointer?>, UnsafeMutablePointer) -> Int32, + free: (UnsafePointer?, UInt) -> Void + ) -> (code: Int32, count: UInt, hasEntries: Bool) { + var entries: UnsafePointer? + var count: UInt = 99 // The callback must clear stale output on failure. + let code = read(&entries, &count) + defer { if let entries { free(entries, count) } } + return (code, count, entries != nil) + } + switch kind { + case .notes: + return load(ShieldedNoteRestoreFFI.self, + read: { callbacks.on_load_shielded_notes_fn!(callbacks.context, $0, $1) }, + free: { callbacks.on_load_shielded_notes_free_fn!(callbacks.context, $0, $1) }) + case .outgoing: + return load(ShieldedOutgoingNoteRestoreFFI.self, + read: { callbacks.on_load_shielded_outgoing_notes_fn!(callbacks.context, $0, $1) }, + free: { callbacks.on_load_shielded_outgoing_notes_free_fn!(callbacks.context, $0, $1) }) + case .syncStates: + return load(ShieldedSubwalletSyncStateFFI.self, + read: { callbacks.on_load_shielded_sync_states_fn!(callbacks.context, $0, $1) }, + free: { callbacks.on_load_shielded_sync_states_free_fn!(callbacks.context, $0, $1) }) + case .viewingKeys: + return load(ShieldedViewingKeyRestoreFFI.self, + read: { callbacks.on_load_shielded_viewing_keys_fn!(callbacks.context, $0, $1) }, + free: { callbacks.on_load_shielded_viewing_keys_free_fn!(callbacks.context, $0, $1) }) + case .activity: + return load(ShieldedActivityRestoreFFI.self, + read: { callbacks.on_load_shielded_activity_fn!(callbacks.context, $0, $1) }, + free: { callbacks.on_load_shielded_activity_free_fn!(callbacks.context, $0, $1) }) + } + } + + private func assertRestoreFails(_ kind: RestoreKind, with handler: PlatformWalletPersistenceHandler, + file: StaticString = #filePath, line: UInt = #line) { + let result = restore(kind, with: handler) + XCTAssertNotEqual(result.code, 0, "\(kind)", file: file, line: line) + XCTAssertEqual(result.count, 0, "\(kind)", file: file, line: line) + XCTAssertFalse(result.hasEntries, "\(kind)", file: file, line: line) + } + + private func note(_ id: UInt8, wallet: Data? = nil) -> PersistentShieldedNote { + PersistentShieldedNote(walletId: wallet ?? walletId, accountIndex: 0, + position: UInt64(id), cmx: Data(repeating: id, count: 32), + nullifier: Data(repeating: id + 50, count: 32), blockHeight: 1, + isSpent: false, value: UInt64(id) * 100, noteData: Data(repeating: id, count: 115)) + } + + func testMalformedUnrelatedNoteRejectsCallbackAndPreservesAggregateAfterReopen() throws { + // A is the drain's output. B is an unrelated receipt in the same scan + // aggregate. No partial snapshot may let A's completion discard B. + let fields: [ReferenceWritableKeyPath] = [ + \.walletId, \.cmx, \.nullifier, \.noteData, + ] + for field in fields { + 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) + seed(PlatformWalletPersistenceHandler(modelContainer: container)) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + context.insert(note(1)) + let unrelated = note(2) + unrelated[keyPath: field] = Data([2]) + context.insert(unrelated) + try context.save() + let reader = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + assertRestoreFails(.notes, with: reader) + } + try autoreleasepool { + let reopened = try DashModelContainer.create(url: url) + let reader = PlatformWalletPersistenceHandler(modelContainer: reopened, network: .testnet) + assertRestoreFails(.notes, with: reader) + let context = ModelContext(reopened) + let histories = try context.fetch(FetchDescriptor()) + let aggregate = try XCTUnwrap(histories.first { $0.walletId == walletId && $0.accountIndex == 0 }) + XCTAssertEqual(aggregate.entryId, Data(repeating: 9, count: 32)) + XCTAssertEqual(aggregate.amount, 300) + XCTAssertEqual(aggregate.noteCmxs, Data(repeating: 1, count: 32) + Data(repeating: 2, count: 32)) + XCTAssertEqual(try reader.loadCachedAssetLocks(walletId: walletId).first?.statusRaw, 0) + let rows = try context.fetch(FetchDescriptor()) + XCTAssertEqual(rows.count, 2) + let unrelated = try XCTUnwrap(rows.first { $0.position == 2 }) + unrelated[keyPath: field] = note(2)[keyPath: field] + try context.save() + let repaired = PlatformWalletPersistenceHandler(modelContainer: reopened, network: .testnet) + let restored = restore(.notes, with: repaired) + XCTAssertEqual(restored.code, 0) + XCTAssertEqual(restored.count, 2) + XCTAssertTrue(restored.hasEntries) + } + } + } + + func testOutgoingRestoreRejectsMalformedRowsWithoutReturningValidPrefix() throws { + let fields: [ReferenceWritableKeyPath] = [ + \.walletId, \.cmx, \.recipient, + ] + for field in fields { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + for id: UInt8 in [1, 2] { + let row = PersistentShieldedOutgoingNote(walletId: walletId, accountIndex: 0, + cmx: Data(repeating: id, count: 32), recipient: Data(repeating: id, count: 43), + value: 100, memo: Data(), blockHeight: 1) + if id == 2 { row[keyPath: field] = Data([2]) } + context.insert(row) + } + try context.save() + assertRestoreFails(.outgoing, with: PlatformWalletPersistenceHandler( + modelContainer: container, network: .testnet)) + } + } + + func testSyncStateRestoreRejectsMalformedWalletBeforeNetworkFiltering() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + context.insert(PersistentShieldedSyncState(walletId: walletId, accountIndex: 0, lastSyncedIndex: 2)) + context.insert(PersistentShieldedSyncState(walletId: Data([2]), accountIndex: 1, lastSyncedIndex: 4)) + try context.save() + assertRestoreFails(.syncStates, with: PlatformWalletPersistenceHandler( + modelContainer: container, network: .testnet)) + } + + func testActivityAndViewingKeyRestoreRejectMalformedWalletBeforeFiltering() throws { + let container = try DashModelContainer.createInMemory() + seed(PlatformWalletPersistenceHandler(modelContainer: container)) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let histories = try context.fetch(FetchDescriptor()) + let history = try XCTUnwrap(histories.first { $0.walletId == walletId }) + history.walletId = Data([2]) + context.insert(PersistentShieldedViewingKey(walletId: walletId, accountIndex: 0, + fvkBytes: Data(repeating: 1, count: 96))) + context.insert(PersistentShieldedViewingKey(walletId: Data([2]), accountIndex: 1, + fvkBytes: Data(repeating: 2, count: 96))) + try context.save() + for kind in [RestoreKind.activity, .viewingKeys] { + assertRestoreFails(kind, with: PlatformWalletPersistenceHandler( + modelContainer: container, network: .testnet)) + } + } + + func testShieldedRestorePropagatesTableAndNetworkScopeFetchFailures() throws { + let container = try DashModelContainer.createInMemory() + for kind in RestoreKind.allCases { + for model in [kind.model, PersistentWallet.self] { + let fetcher = FetchFaultInjector(faulting: model) + assertRestoreFails(kind, with: PlatformWalletPersistenceHandler( + modelContainer: container, network: .testnet, modelFetcher: fetcher)) + XCTAssertTrue(fetcher.observedReads.contains(String(describing: model))) + } + } + } + + func testShieldedRestoreRejectsMalformedWalletScope() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: Data([1]), network: .testnet)) + try context.save() + for kind in RestoreKind.allCases { + assertRestoreFails(kind, with: PlatformWalletPersistenceHandler( + modelContainer: container, network: .testnet)) + } + } + + func testValidScanRestoreStillScopesByNetworkAndAllowsEmptySnapshots() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + context.insert(PersistentWallet(walletId: otherWalletId, network: .mainnet)) + for (id, wallet) in [(UInt8(1), walletId), (UInt8(2), otherWalletId)] { + context.insert(note(id, wallet: wallet)) + context.insert(PersistentShieldedOutgoingNote(walletId: wallet, accountIndex: 0, + cmx: Data(repeating: id, count: 32), recipient: Data(repeating: id, count: 43), + value: 100, memo: Data(), blockHeight: 1)) + context.insert(PersistentShieldedSyncState(walletId: wallet, accountIndex: 0, lastSyncedIndex: 2)) + context.insert(PersistentShieldedViewingKey(walletId: wallet, accountIndex: 0, + fvkBytes: Data(repeating: id, count: 96))) + } + try context.save() + for kind in [RestoreKind.notes, .outgoing, .syncStates, .viewingKeys] { + let scoped = restore(kind, with: PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet)) + XCTAssertEqual(scoped.code, 0) + XCTAssertEqual(scoped.count, 1) + XCTAssertTrue(scoped.hasEntries) + let unscoped = restore(kind, with: PlatformWalletPersistenceHandler(modelContainer: container)) + XCTAssertEqual(unscoped.code, 0) + XCTAssertEqual(unscoped.count, 2) + let empty = restore(kind, with: PlatformWalletPersistenceHandler( + modelContainer: try DashModelContainer.createInMemory(), network: .testnet)) + XCTAssertEqual(empty.code, 0) + XCTAssertEqual(empty.count, 0) + XCTAssertFalse(empty.hasEntries) + } + } } From e3354a6620de50632c01f6405fc586ed060a1c8f Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 13 Oct 2026 20:01:40 +0200 Subject: [PATCH 05/24] fix(platform-wallet): publish drain outcomes without cancellation gaps --- .../src/wallet/asset_lock/build.rs | 79 ++++++- .../src/wallet/asset_lock/operation.rs | 31 ++- .../src/wallet/shielded/drain_recovery.rs | 219 ++++++++++++++++-- 3 files changed, 290 insertions(+), 39 deletions(-) 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 cb9f83d4349..c0332a58431 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -1280,6 +1280,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( @@ -1291,9 +1296,6 @@ impl AssetLockManager { ) .await; in_broadcast_pin.settle_released(); - if let Some(operation) = operation { - operation.set(AssetLockOperationState::NotSent, None); - } } else { // An untrack guard fired: the row advanced past `Built`, or // resume state says the transaction may already be live or @@ -1579,6 +1581,7 @@ mod tests { fail_next_pool_store: std::sync::atomic::AtomicBool, fail_on_flush: Option, restored_wallet: Option<(Wallet, ManagedWalletInfo)>, + before_remove: Mutex>>, } impl CapturingPersistence { @@ -1624,6 +1627,15 @@ mod tests { { return Err(PersistenceError::backend("simulated pool store failure")); } + if changeset + .asset_locks + .as_ref() + .is_some_and(|locks| !locks.removed.is_empty()) + { + if let Some(hook) = self.before_remove.lock().unwrap().as_ref() { + hook(); + } + } self.stored .lock() .expect("capturing persistence mutex") @@ -4381,6 +4393,67 @@ mod tests { })); } + #[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) = diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs index 4d98e61a134..d162d470f07 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs @@ -147,22 +147,27 @@ impl AssetLockManager { self.persist_drain_lock_snapshot(&candidate) } - /// The evidence remains on the terminal row. Commit the tombstone before - /// publishing completion, so a write failure remains safely retryable. + /// Commit the tombstone before publishing completion. The caller owns the + /// activity-store guard and must publish history synchronously afterwards. + /// Refuse a busy manager without waiting: no cancellation or lock-order + /// inversion may separate durable completion from its memory publication. #[cfg(feature = "shielded")] - pub(crate) async fn complete_wallet_drain( + pub(crate) fn complete_wallet_drain( &self, out_point: OutPoint, operation_id: [u8; 16], history: crate::changeset::ShieldedChangeSet, - ) -> Result<(), PlatformWalletError> { + ) -> Result { self.require_wallet_drain_persistence()?; - let mut candidate = self - .list_tracked_locks() - .await - .into_iter() - .find(|lock| lock.out_point == out_point && lock.operation_id == Some(operation_id)) + let Ok(mut manager) = self.wallet_manager.try_write() else { + return Ok(false); + }; + 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))?; + let mut candidate = lock.clone(); candidate.status = AssetLockStatus::Consumed; candidate.proof = None; let mut changes = crate::changeset::AssetLockChangeSet::default(); @@ -179,19 +184,13 @@ impl AssetLockManager { self.persister .flush() .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; - 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))?; lock.status = AssetLockStatus::Consumed; lock.proof = None; self.resume_dispatch_claims .lock() .unwrap_or_else(|e| e.into_inner()) .remove(&out_point); - Ok(()) + Ok(true) } #[cfg(feature = "shielded")] diff --git a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs index 72869c7a594..2cc46282886 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs @@ -402,7 +402,11 @@ impl PlatformWallet { .map(|bytes| attempt_outputs(&lock, bytes, &keys)) .collect::, _>>()?; let observed = { - let store = coordinator.store().read().await; + // A scan owns this lock across its network stream. Status must + // report Pending instead of holding shield_guard behind that I/O. + let Ok(store) = coordinator.store().try_read() else { + return Ok(false); + }; let mut observed = BTreeSet::new(); for account in keys.keys() { let id = SubwalletId::new(self.wallet_id(), *account); @@ -456,15 +460,23 @@ impl PlatformWallet { // Serialize with scan derivation through its host persistence round. // Build a replacement without mutating the memory cache: if the atomic // history+tombstone write fails, a retry still sees the original rows. - let _checkpoint = coordinator.activity_checkpoint.lock().await; - let history = { - let store = coordinator.store().read().await; - drain_history_changeset(&*store, self.wallet_id(), &keys, &actions, matched_outputs)? + let Ok(_checkpoint) = coordinator.activity_checkpoint.try_lock() else { + return Ok(false); }; - self.asset_locks - .complete_wallet_drain(out_point, operation_id, history.clone()) - .await?; - let mut store = coordinator.store().write().await; + let Ok(mut store) = coordinator.store().try_write() else { + return Ok(false); + }; + let history = + drain_history_changeset(&*store, self.wallet_id(), &keys, &actions, matched_outputs)?; + // Acquire every publication guard before persistence. The completion + // helper and all cache updates below are synchronous, so cancellation + // cannot expose a durable tombstone with stale aggregate history. + if !self + .asset_locks + .complete_wallet_drain(out_point, operation_id, history.clone())? + { + return Ok(false); + } for (id, removed) in history.activity_removed { for entry_id in removed { store.remove_activity(id, &entry_id).map_err(invalid)?; @@ -1259,21 +1271,13 @@ mod tests { .await; } // History and terminal tombstone commit together, before the memory - // cache is updated and with no key/store guard held by callbacks. + // cache is updated. The store guard prevents a cancellation gap; + // the unrelated viewing-key slot stays available to callbacks. let activity_store = coordinator.store().clone(); let key_slot = wallet.shielded_keys.clone(); - let subwallet = SubwalletId::new(wallet.wallet_id(), 0); *persistence.before_store.lock().unwrap() = Some(Box::new(move || { assert!(key_slot.try_write().is_ok()); - assert_eq!( - activity_store - .try_read() - .unwrap() - .get_activity(subwallet, 0, 10) - .unwrap() - .len(), - 0 - ); + assert!(activity_store.try_read().is_err()); })); assert!( reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) @@ -1313,6 +1317,181 @@ mod tests { ); } + #[tokio::test] + async fn should_publish_committed_drain_history_without_a_cancellation_gap() { + for block_manager in [true, false] { + let (lock, keys, st) = evidence_fixture(); + let views = keys[&0].clone(); + let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); + let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; + wallet + .persist_drain_shielded_attempt(lock.out_point, &st) + .await + .unwrap(); + for (index, (account, cmx)) in outputs.iter().enumerate() { + observe( + &coordinator, + wallet.wallet_id(), + *account, + *cmx, + index as u64, + ) + .await; + } + let id = SubwalletId::new(wallet.wallet_id(), 0); + let unrelated = [0x93; 32]; + observe(&coordinator, wallet.wallet_id(), 0, unrelated, 2).await; + let mut scan = crate::changeset::ShieldedChangeSet::default(); + coordinator + .derive_activity_into_changeset(&[(id, views.clone())], &mut scan) + .await + .unwrap(); + persistence + .store( + wallet.wallet_id(), + PlatformWalletChangeSet { + shielded: Some(scan), + ..Default::default() + }, + ) + .unwrap(); + persistence.flush(wallet.wallet_id()).unwrap(); + + // On the old path, the callback can take either publication lock: + // the first post-commit poll then suspends and can be cancelled. + let manager_blocker = Arc::new(Mutex::new(None)); + let store_blocker = Arc::new(Mutex::new(None)); + let manager_slot = manager_blocker.clone(); + let store_slot = store_blocker.clone(); + let manager = wallet.wallet_manager.clone(); + let store = coordinator.store().clone(); + *persistence.before_store.lock().unwrap() = Some(Box::new(move || { + if block_manager { + *manager_slot.lock().unwrap() = manager.clone().try_write_owned().ok(); + } else { + *store_slot.lock().unwrap() = store.clone().try_write_owned().ok(); + } + })); + let mut future = Box::pin(reconcile_fully_consumed( + &wallet, + &coordinator, + lock.out_point, + )); + let result = futures::poll!(&mut future); + drop(future); + manager_blocker.lock().unwrap().take(); + store_blocker.lock().unwrap().take(); + assert!(matches!(result, std::task::Poll::Ready(Ok(true))), + "durable commit must publish all caches before returning to the executor: {result:?}"); + *persistence.before_store.lock().unwrap() = None; + let history = coordinator + .store() + .read() + .await + .get_activity(id, 0, 10) + .unwrap(); + assert_eq!(history.len(), 2); + assert!(history + .iter() + .any(|entry| entry.note_cmxs == vec![unrelated] && entry.amount == 1_000)); + assert!(history.iter().any(|entry| entry.kind + == ShieldedActivityKind::ShieldFromAssetLock + && entry.amount == 2_000)); + assert_eq!(persistence.activity.lock().unwrap()[&id].activity.len(), 2); + assert_eq!( + persistence.committed.lock().unwrap()[&lock.out_point].status, + AssetLockStatus::Consumed + ); + assert_eq!( + wallet.asset_locks.list_tracked_locks().await[0].status, + AssetLockStatus::Consumed + ); + let mut next_scan = crate::changeset::ShieldedChangeSet::default(); + coordinator + .derive_activity_into_changeset(&[(id, views)], &mut next_scan) + .await + .unwrap(); + assert!( + next_scan.activity_entries.is_empty(), + "a later scan cannot duplicate/overwrite the canonical drain" + ); + } + } + + #[tokio::test] + async fn should_leave_drain_pending_when_publication_guards_are_busy() { + for block_manager in [true, false] { + let (lock, keys, st) = evidence_fixture(); + let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); + let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; + wallet + .persist_drain_shielded_attempt(lock.out_point, &st) + .await + .unwrap(); + for (index, (account, cmx)) in outputs.iter().enumerate() { + observe( + &coordinator, + wallet.wallet_id(), + *account, + *cmx, + index as u64, + ) + .await; + } + let proof_ready = Notify::new(); + let mut future = Box::pin(wallet.reconcile_wallet_drain_with_consumption_check( + &coordinator, + lock.out_point, + |_| async { + proof_ready.notified().await; + Ok(StateTransitionProofResult::VerifiedAssetLockConsumed( + StoredAssetLockInfo::FullyConsumed, + )) + }, + )); + assert!(futures::poll!(&mut future).is_pending()); + let manager_guard = block_manager.then(|| wallet.wallet_manager.try_read().unwrap()); + let store_guard = (!block_manager).then(|| coordinator.store().try_write().unwrap()); + proof_ready.notify_one(); + let result = futures::poll!(&mut future); + drop(future); + drop(manager_guard); + drop(store_guard); + assert_ne!( + persistence.committed.lock().unwrap()[&lock.out_point].status, + AssetLockStatus::Consumed, + "cancellation/contended publication must not commit terminal state" + ); + assert!(matches!(result, std::task::Poll::Ready(Ok(false)))); + assert!( + reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) + .await + .unwrap() + ); + assert_exact_drain_history(&wallet, &coordinator, &outputs).await; + } + } + + #[tokio::test] + async fn should_return_pending_status_without_waiting_for_a_scan() { + let (lock, keys, st) = evidence_fixture(); + let (wallet, _, coordinator, _dir) = wallet_fixture(&lock, keys).await; + wallet + .persist_drain_shielded_attempt(lock.out_point, &st) + .await + .unwrap(); + let scan = coordinator.store().write().await; + let mut future = Box::pin( + wallet.shielded_wallet_drain_operation_status(&coordinator, lock.operation_id.unwrap()), + ); + 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))); + assert!(wallet.shield_guard.try_lock().is_ok()); + drop(scan); + } + #[tokio::test] async fn should_fail_closed_on_attempt_or_completion_persistence_failure() { let (lock, keys, st) = evidence_fixture(); From 24d9e32fbe1644448b7a073136b83100d7e31664 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 13 Oct 2026 21:31:05 +0200 Subject: [PATCH 06/24] fix(platform-wallet): hydrate durable drains and honor inline commits --- .../src/wallet/asset_lock/build.rs | 128 +++++++++++++++--- .../src/wallet/asset_lock/operation.rs | 67 ++++++++- .../src/wallet/shielded/drain_recovery.rs | 107 ++++++++++++--- 3 files changed, 254 insertions(+), 48 deletions(-) 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 c0332a58431..d16efddf8f1 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -1501,6 +1501,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; @@ -1582,6 +1583,7 @@ mod tests { fail_on_flush: Option, restored_wallet: Option<(Wallet, ManagedWalletInfo)>, before_remove: Mutex>>, + restore_stored_locks: std::sync::atomic::AtomicBool, } impl CapturingPersistence { @@ -1671,6 +1673,48 @@ mod tests { }, ); } + 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() + .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, + shielded_funding_attempts: entry + .shielded_funding_attempts + .clone(), + 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) } } @@ -4248,29 +4292,30 @@ mod tests { .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 + { + let stored = persistence.stored.lock().unwrap(); + assert!( + stored .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" - ); - drop(stored); + .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 @@ -4286,6 +4331,45 @@ mod tests { 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] diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs index d162d470f07..be3c4169edc 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs @@ -181,9 +181,14 @@ impl AssetLockManager { ..Default::default() }) .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; - self.persister - .flush() - .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; + if let Err(error) = self.persister.flush() { + if !self.persister.store_commits_inline() { + return Err(PlatformWalletError::Persistence(error.to_string())); + } + // store already committed history and the tombstone. A failed + // notification must not suppress their live publication. + tracing::warn!(%out_point, %error, "wallet drain committed inline; flush notification failed"); + } lock.status = AssetLockStatus::Consumed; lock.proof = None; self.resume_dispatch_claims @@ -314,6 +319,55 @@ impl AssetLockManager { } } + /// Repair the gap between a durable checkpoint and live tracking when a + /// cancelled call is retried without restarting. Status remains read-only; + /// only an explicit retry hydrates the exact operation's missing row. + #[cfg(any(feature = "shielded", test))] + async fn hydrate_wallet_drain( + &self, + operation_id: [u8; 16], + out_point: OutPoint, + ) -> Result<(), PlatformWalletError> { + { + 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)) + { + return Ok(()); + } + } + let persisted = self.persister.load().map_err(|error| { + PlatformWalletError::Persistence(format!("cannot restore checkpointed drain: {error}")) + })?; + let candidate = persisted + .wallets + .get(&self.wallet_id) + .into_iter() + .flat_map(|wallet| wallet.unused_asset_locks.values()) + .filter_map(|locks| locks.get(&out_point)) + .find(|lock| lock.operation_id == Some(operation_id)) + .cloned() + .ok_or(PlatformWalletError::AssetLockNotTracked(out_point))?; + 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, consumption state or signed recovery attempt. + 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(), + )); + } + 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))] @@ -341,11 +395,14 @@ impl AssetLockManager { } AssetLockOperationState::Pending => { operation.set(previous.state, previous.out_point); - return previous.out_point.map(Some).ok_or_else(|| { + 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 => {} } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs index 2cc46282886..167cc8af43f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs @@ -685,6 +685,7 @@ mod tests { staged: Mutex>, fail_store: AtomicBool, fail_flush: AtomicBool, + commits_inline: AtomicBool, fail_load: AtomicBool, before_store: Mutex>>, activity: Mutex>, @@ -693,6 +694,9 @@ mod tests { omit_activity_capability: AtomicBool, } impl PlatformWalletPersistence for RecoveryPersistence { + fn store_commits_inline(&self) -> bool { + self.commits_inline.load(Ordering::SeqCst) + } fn persistence_capabilities(&self) -> PersistenceCapabilities { let bits = PersistenceCapabilities::WALLET_DRAIN.bits(); PersistenceCapabilities::from_bits_retain( @@ -726,33 +730,16 @@ mod tests { 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")); } - if let Some(cs) = self.staged_activity.lock().unwrap().take() { - let mut activity = self.activity.lock().unwrap(); - for (id, ids) in cs.activity_removed { - activity - .entry(id) - .or_default() - .activity - .retain(|entry| !ids.contains(&entry.id)); - } - for (id, entries) in cs.activity_entries { - let rows = &mut activity.entry(id).or_default().activity; - for entry in entries { - rows.retain(|old| old.id != entry.id); - rows.push(entry); - } - } - } - self.committed - .lock() - .unwrap() - .extend(self.staged.lock().unwrap().clone()); + self.commit_staged(); Ok(()) } fn load(&self) -> Result { @@ -802,6 +789,32 @@ mod tests { } } + impl RecoveryPersistence { + fn commit_staged(&self) { + if let Some(cs) = self.staged_activity.lock().unwrap().take() { + let mut activity = self.activity.lock().unwrap(); + for (id, ids) in cs.activity_removed { + activity + .entry(id) + .or_default() + .activity + .retain(|entry| !ids.contains(&entry.id)); + } + for (id, entries) in cs.activity_entries { + let rows = &mut activity.entry(id).or_default().activity; + for entry in entries { + rows.retain(|old| old.id != entry.id); + rows.push(entry); + } + } + } + self.committed + .lock() + .unwrap() + .extend(self.staged.lock().unwrap().clone()); + } + } + struct TestDirectory(std::path::PathBuf); impl Drop for TestDirectory { fn drop(&mut self) { @@ -832,6 +845,7 @@ mod tests { staged: Mutex::new(BTreeMap::new()), fail_store: AtomicBool::new(false), fail_flush: AtomicBool::new(false), + commits_inline: AtomicBool::new(false), fail_load: AtomicBool::new(false), before_store: Mutex::new(None), activity: Mutex::new(BTreeMap::new()), @@ -1492,6 +1506,57 @@ mod tests { drop(scan); } + #[tokio::test] + async fn should_publish_inline_committed_history_when_flush_notification_fails() { + let (lock, keys, st) = evidence_fixture(); + let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); + let views = keys[&0].clone(); + let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; + wallet + .persist_drain_shielded_attempt(lock.out_point, &st) + .await + .unwrap(); + for (index, (account, cmx)) in outputs.iter().enumerate() { + observe( + &coordinator, + wallet.wallet_id(), + *account, + *cmx, + index as u64, + ) + .await; + } + persistence.commits_inline.store(true, Ordering::SeqCst); + persistence.fail_flush.store(true, Ordering::SeqCst); + assert!( + reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) + .await + .unwrap() + ); + assert_eq!( + persistence.committed.lock().unwrap()[&lock.out_point].status, + AssetLockStatus::Consumed + ); + assert_eq!( + wallet.asset_locks.list_tracked_locks().await[0].status, + AssetLockStatus::Consumed + ); + assert_exact_drain_history(&wallet, &coordinator, &outputs).await; + let id = SubwalletId::new(wallet.wallet_id(), 0); + assert_eq!(persistence.activity.lock().unwrap()[&id].activity.len(), 1); + let mut scan = crate::changeset::ShieldedChangeSet::default(); + coordinator + .derive_activity_into_changeset(&[(id, views)], &mut scan) + .await + .unwrap(); + assert!(scan.activity_entries.is_empty()); + assert!( + reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) + .await + .unwrap() + ); + } + #[tokio::test] async fn should_fail_closed_on_attempt_or_completion_persistence_failure() { let (lock, keys, st) = evidence_fixture(); From 0ae730de14ef8089f563ead28756a0634e6e7047 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 13 Oct 2026 23:00:34 +0200 Subject: [PATCH 07/24] fix(platform-wallet): release committed rejections and bound drain retries --- .../src/wallet/asset_lock/build.rs | 242 +++++++++++++++++- .../src/wallet/asset_lock/operation.rs | 23 ++ 2 files changed, 253 insertions(+), 12 deletions(-) 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 d16efddf8f1..0e4581b59dc 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -1248,14 +1248,10 @@ impl AssetLockManager { // re-spendable. let removed_built_row = cs_untrack.removed.contains(&out_point); if operation.is_some() && removed_built_row { - if let Err(error) = self - .persister - .store(PlatformWalletChangeSet { - asset_locks: Some(cs_untrack), - ..Default::default() - }) - .and_then(|()| self.persister.flush()) - { + 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 @@ -1264,6 +1260,16 @@ impl AssetLockManager { "could not durably remove rejected drain asset lock: {error}" ))); } + if let Err(error) = self.persister.flush() { + if !self.persister.store_commits_inline() { + return Err(PlatformWalletError::Persistence(format!( + "could not durably remove rejected drain asset lock: {error}" + ))); + } + // The removal is already committed. A failed host + // notification must not strand its context or inputs. + tracing::warn!(%out_point, %error, "rejected drain removed inline; flush notification failed"); + } } else { self.queue_asset_lock_changeset(cs_untrack); } @@ -1584,6 +1590,9 @@ mod tests { 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, } impl CapturingPersistence { @@ -1613,6 +1622,10 @@ 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 } @@ -1634,14 +1647,19 @@ mod tests { .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(); } } - self.stored - .lock() - .expect("capturing persistence mutex") - .push(changeset); + 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(()) } @@ -1653,6 +1671,10 @@ mod tests { 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(()) } @@ -1683,6 +1705,10 @@ mod tests { .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() { @@ -4592,6 +4618,198 @@ mod tests { assert_eq!(manager.begin_wallet_drain(&second).await.unwrap(), None); } + #[tokio::test] + async fn should_release_rejected_drain_only_when_removal_committed() { + for commits_inline in [false, true] { + for fail_remove_store in [false, 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_bound_drain_attempt_archive_without_discarding_evidence() { + use super::super::operation::{MAX_DRAIN_ATTEMPTS, MAX_DRAIN_ATTEMPT_BYTES}; + + // Count boundary, cumulative byte boundary, and one oversized attempt. + for mode in 0..3 { + 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(CountingOkBroadcaster::default()), + ); + persistence + .restore_stored_locks + .store(true, std::sync::atomic::Ordering::SeqCst); + let operation = Arc::new(super::AssetLockOperationContext::new([55; 16])); + let _registration = manager.register_wallet_drain_operation(operation.clone()); + manager.begin_wallet_drain(&operation).await.unwrap(); + 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 point = operation.snapshot().out_point.unwrap(); + let attempts: Vec> = match mode { + 0 => (0..MAX_DRAIN_ATTEMPTS) + .map(|i| i.to_le_bytes().to_vec()) + .collect(), + 1 => vec![ + vec![1; MAX_DRAIN_ATTEMPT_BYTES / 2], + vec![2; MAX_DRAIN_ATTEMPT_BYTES / 2], + ], + _ => vec![], + }; + for attempt in &attempts { + manager + .persist_wallet_drain_attempt(point, attempt.clone()) + .await + .unwrap(); + } + let rejected = if mode == 2 { + vec![3; MAX_DRAIN_ATTEMPT_BYTES + 1] + } else { + vec![3] + }; + let stores_before = persistence.stored.lock().unwrap().len(); + let error = manager + .persist_wallet_drain_attempt(point, rejected) + .await + .unwrap_err(); + assert!(error.to_string().contains("recovery budget exhausted")); + assert_eq!(persistence.stored.lock().unwrap().len(), stores_before); + assert_eq!( + manager.list_tracked_locks().await[0].shielded_funding_attempts, + attempts + ); + 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.shielded_funding_attempts, attempts); + assert_eq!( + manager + .wallet_drain_operation_status([55; 16]) + .await + .unwrap() + .state, + super::AssetLockOperationState::Pending + ); + // Byte-identical replay/persistence retries remain possible at the + // limit, without appending or throwing away older recovery evidence. + if let Some(first) = attempts.first() { + manager + .persist_wallet_drain_attempt(point, first.clone()) + .await + .unwrap(); + assert_eq!( + manager.list_tracked_locks().await[0].shielded_funding_attempts, + attempts + ); + } + assert_eq!(manager.broadcaster.calls(), 1); + } + } + #[tokio::test] async fn should_wallet_drain_failed_row_flush_never_publish_resumable_lock() { let (wallet_manager, wallet_id, _, signer) = diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs index be3c4169edc..28fa764b076 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs @@ -17,6 +17,15 @@ use crate::error::PlatformWalletError; use super::manager::AssetLockManager; use super::tracked::{AssetLockStatus, TrackedAssetLock}; +// Local recovery resource budgets, not protocol validity limits. Keep every +// admitted attempt as evidence; exhaustion stops new dispatches, never recovery +// or replay of an already recorded attempt. 64 attempts allow repeated freshness +// retries, while 4 MiB bounds the archive even if individual transitions grow. +#[cfg(feature = "shielded")] +pub(super) const MAX_DRAIN_ATTEMPTS: usize = 64; +#[cfg(feature = "shielded")] +pub(super) const MAX_DRAIN_ATTEMPT_BYTES: usize = 4 * 1024 * 1024; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AssetLockOperationState { NotSent, @@ -140,6 +149,20 @@ impl AssetLockManager { return Err(PlatformWalletError::AssetLockAlreadyConsumed(out_point)); } if !lock.shielded_funding_attempts.contains(&signed_attempt) { + let total_bytes = lock + .shielded_funding_attempts + .iter() + .try_fold(signed_attempt.len(), |total, attempt| { + total.checked_add(attempt.len()) + }); + if lock.shielded_funding_attempts.len() >= MAX_DRAIN_ATTEMPTS + || total_bytes.is_none_or(|total| total > MAX_DRAIN_ATTEMPT_BYTES) + { + return Err(PlatformWalletError::Persistence( + "drain signed-attempt recovery budget exhausted; operation remains pending" + .into(), + )); + } lock.shielded_funding_attempts.push(signed_attempt); } lock.clone() From 7201b08ec2a7c345e248715e26e5e38c521e3f74 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 14 Oct 2026 02:00:38 +0200 Subject: [PATCH 08/24] fix(platform-wallet): retry unpublished drain checkpoints --- .../src/wallet/asset_lock/build.rs | 174 ++++++++++++++++++ .../src/wallet/asset_lock/operation.rs | 81 ++++++-- .../src/wallet/shielded/balance_tests.rs | 9 +- 3 files changed, 250 insertions(+), 14 deletions(-) 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 0e4581b59dc..f08efc4cd57 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -1168,6 +1168,7 @@ impl AssetLockManager { 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. @@ -1189,6 +1190,7 @@ impl AssetLockManager { )) })?; 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); @@ -1593,6 +1595,8 @@ mod tests { 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 { @@ -1642,6 +1646,17 @@ mod tests { { 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() @@ -4712,6 +4727,149 @@ mod tests { } } + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_retry_rolled_back_drain_checkpoint_without_rebuilding() { + for inline in [false, true] { + let (wallet_manager, wallet_id, _, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let persistence = Arc::new(CapturingPersistence { + commits_inline: inline, + 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" + ); + } + } + #[cfg(feature = "shielded")] #[tokio::test] async fn should_bound_drain_attempt_archive_without_discarding_evidence() { @@ -4816,6 +4974,7 @@ mod tests { 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()); @@ -4871,6 +5030,21 @@ mod tests { .state, super::AssetLockOperationState::Pending ); + // A failed buffered flush leaves only the unpublished checkpoint. + // Explicit retry must make that exact row durable before tracking it. + assert!(persistence.load().unwrap().wallets[&wallet_id] + .unused_asset_locks + .is_empty()); + 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 { diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs index 28fa764b076..6381f111b95 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs @@ -49,6 +49,10 @@ pub struct AssetLockOperationContext { 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 { @@ -62,6 +66,7 @@ impl AssetLockOperationContext { state: AssetLockOperationState::Pending, out_point: None, }), + checkpoint: Mutex::new(None), } } @@ -79,6 +84,17 @@ impl AssetLockOperationContext { .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. @@ -221,7 +237,7 @@ impl AssetLockManager { Ok(true) } - #[cfg(feature = "shielded")] + #[cfg(any(feature = "shielded", test))] fn persist_drain_lock_snapshot( &self, lock: &TrackedAssetLock, @@ -342,15 +358,22 @@ impl AssetLockManager { } } - /// Repair the gap between a durable checkpoint and live tracking when a - /// cancelled call is retried without restarting. Status remains read-only; - /// only an explicit retry hydrates the exact operation's missing row. + /// 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 @@ -358,21 +381,50 @@ impl AssetLockManager { .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 candidate = persisted - .wallets - .get(&self.wallet_id) - .into_iter() - .flat_map(|wallet| wallet.unused_asset_locks.values()) - .filter_map(|locks| locks.get(&out_point)) - .find(|lock| lock.operation_id == Some(operation_id)) - .cloned() - .ok_or(PlatformWalletError::AssetLockNotTracked(out_point))?; + 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) @@ -388,6 +440,9 @@ impl AssetLockManager { "checkpointed drain conflicts with the live operation UUID".into(), )); } + if let Some(context) = &context { + context.clear_checkpoint(); + } Ok(()) } 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 { From 89a9423ce8f072f27e57a7cc2d592a183ab14189 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 14 Oct 2026 09:02:43 +0200 Subject: [PATCH 09/24] fix(platform-wallet): gate drain completion on a dedicated proven status A generic Consumed tombstone on an operation-tagged lock was treated as drain completion, bypassing the authenticated-output and FullyConsumed proof gate. Drain completion now has its own appended AssetLockStatus discriminant, DrainCompleted, which only complete_wallet_drain writes. Generic consumption refuses operation-tagged locks, legacy Consumed drain rows stay Pending, and changeset merge, apply, SQLite and Swift persistence never regress DrainCompleted. SQL projects it to `consumed`, so no migration is needed. Also enforce the 64-attempt / 4 MiB drain-attempt budget on every restore path (FFI callback, SQLite decoder, Swift archive) before allocating, hold Swift shutdown admission across wallet-drain FFI calls, and evict completed operation contexts once their last registration is dropped. Co-Authored-By: Claude Opus 5.5 --- .../src/asset_lock/manager.rs | 4 +- .../src/asset_lock_persistence.rs | 4 +- .../rs-platform-wallet-ffi/src/persistence.rs | 74 ++++- .../src/sqlite/schema/asset_locks.rs | 144 ++++++++- .../src/changeset/changeset.rs | 19 +- .../src/manager/accessors.rs | 1 + .../rs-platform-wallet/src/wallet/apply.rs | 44 ++- .../src/wallet/asset_lock/build.rs | 7 +- .../src/wallet/asset_lock/mod.rs | 2 +- .../src/wallet/asset_lock/operation.rs | 50 ++- .../src/wallet/asset_lock/orchestration.rs | 2 +- .../src/wallet/asset_lock/sync/recovery.rs | 8 +- .../src/wallet/asset_lock/sync/tracking.rs | 27 +- .../src/wallet/asset_lock/tracked.rs | 26 +- .../src/wallet/shielded/drain_recovery.rs | 304 +++++++++++++----- .../Models/PersistentAssetLock.swift | 3 +- .../AssetLock/ManagedAssetLockManager.swift | 2 + .../PlatformWalletManagerWalletDrain.swift | 22 +- .../PlatformWalletPersistenceHandler.swift | 53 ++- .../Utils/PersistentAssetLockDisplay.swift | 1 + .../AssetLockOperationPersistenceTests.swift | 48 +++ .../PlatformWalletShutdownTests.swift | 40 +++ 22 files changed, 730 insertions(+), 155 deletions(-) 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 512a89af316..6aa0fda0b85 100644 --- a/packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs @@ -57,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 @@ -172,6 +173,7 @@ fn status_to_u8(status: &AssetLockStatus) -> u8 { AssetLockStatus::ChainLocked => 3, AssetLockStatus::Consumed => 4, AssetLockStatus::RecoveredFromChain => 5, + AssetLockStatus::DrainCompleted => 6, } } diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index e144f64b78c..9c96f8beae8 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -36,6 +36,7 @@ use platform_wallet::changeset::{ PlatformWalletChangeSet, PlatformWalletPersistence, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, PERSISTENCE_CAPABILITIES_VERSION, }; +use platform_wallet::wallet::asset_lock::{MAX_DRAIN_ATTEMPTS, MAX_DRAIN_ATTEMPT_BYTES}; use platform_wallet::wallet::platform_wallet::WalletId; #[cfg(feature = "shielded")] use platform_wallet::wallet::shielded::ShieldedActivityStatus; @@ -6357,7 +6358,7 @@ fn load_shielded_funding_attempts( entries, count, }; - if count > isize::MAX as usize / std::mem::size_of::() + if count > MAX_DRAIN_ATTEMPTS || (count != 0 && (entries.is_null() || lock.operation_id.is_none())) { return Err(PersistenceError::backend( @@ -6369,13 +6370,25 @@ fn load_shielded_funding_attempts( } else { unsafe { slice::from_raw_parts(entries, count) } }; - let mut attempts = Vec::with_capacity(count); + let mut total_bytes = 0usize; + // Validate all lengths before constructing any payload slice or copying bytes. for row in rows { + total_bytes = total_bytes.checked_add(row.bytes_len).ok_or_else(|| { + PersistenceError::backend("shielded funding attempt bytes overflow") + })?; + if total_bytes > MAX_DRAIN_ATTEMPT_BYTES { + return Err(PersistenceError::backend( + "shielded funding attempt archive exceeds budget", + )); + } if row.bytes.is_null() || row.bytes_len == 0 || row.bytes_len > isize::MAX as usize { return Err(PersistenceError::backend( "invalid signed shielded funding attempt", )); } + } + let mut attempts = Vec::with_capacity(count); + for row in rows { attempts.push(unsafe { slice::from_raw_parts(row.bytes, row.bytes_len) }.to_vec()); } lock.shielded_funding_attempts = attempts; @@ -6530,6 +6543,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 {}", @@ -8992,7 +9006,14 @@ mod tests { } 0 } - for operation_id in [None, Some([0xA5; 16])] { + 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]), @@ -9009,7 +9030,7 @@ mod tests { funding_type: AssetLockFundingType::AssetLockShieldedAddressTopUp, identity_index: 0, amount_duffs: 1000, - status: platform_wallet::AssetLockStatus::Consumed, + status: status.clone(), proof: None, operation_id, shielded_funding_attempts: Vec::new(), @@ -9033,10 +9054,7 @@ mod tests { ) .expect("valid UUID sidecar"); assert_eq!(restored[&7][&entry.out_point].operation_id, operation_id); - assert_eq!( - restored[&7][&entry.out_point].status, - platform_wallet::AssetLockStatus::Consumed - ); + 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(), @@ -9411,6 +9429,46 @@ mod tests { ) .is_err()); assert!(host.freed); + // Invalid addresses are deliberate: budget validation must finish before + // a payload pointer is dereferenced, including cumulative overflow. + for lengths in [ + vec![1; MAX_DRAIN_ATTEMPTS + 1], + vec![MAX_DRAIN_ATTEMPT_BYTES + 1], + vec![MAX_DRAIN_ATTEMPT_BYTES, 1], + vec![usize::MAX, 1], + ] { + host.rows = lengths + .into_iter() + .map(|bytes_len| ShieldedFundingAttemptFFI { + bytes: std::ptr::dangling(), + bytes_len, + }) + .collect(); + host.freed = false; + assert!(load_shielded_funding_attempts( + load, + free, + std::ptr::addr_of_mut!(host).cast(), + &[42; 32], + &mut locks + ) + .is_err()); + assert!(host.freed); + assert_eq!(locks[&0][&out_point].shielded_funding_attempts, attempts); + } + } + + #[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] 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 61c013d2318..087d4fbe26d 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 @@ -17,6 +17,7 @@ use dpp::prelude::AssetLockProof; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use platform_wallet::changeset::AssetLockChangeSet; use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; +use platform_wallet::wallet::asset_lock::{MAX_DRAIN_ATTEMPTS, MAX_DRAIN_ATTEMPT_BYTES}; use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; @@ -70,6 +71,7 @@ impl_persistable_blob!(ShieldedFundingAttemptsWire); /// Nullable recovery columns read independently of the frozen lifecycle blob. struct AssetLockRecoveryRow { operation_id: Option>, + lifecycle_blob: Vec, shielded_funding_attempts_blob: Option>, } @@ -77,13 +79,45 @@ fn decode_attempts(bytes: Option<&[u8]>) -> Result>, WalletStorageEr let Some(bytes) = bytes else { return Ok(Vec::new()); }; - let wire: ShieldedFundingAttemptsWire = blob::decode(bytes)?; - if wire.version != 1 || wire.attempts.iter().any(Vec::is_empty) { + // Read the frozen bincode sequence framing before allocating any attempts. + // The regular serde Vec decoder can reserve attacker-controlled lengths. + fn read>(bytes: &mut &[u8]) -> Result { + let (value, consumed) = bincode::decode_from_slice(bytes, blob::bounded_config())?; + *bytes = &bytes[consumed..]; + Ok(value) + } + let mut remaining = bytes; + let version: u32 = read(&mut remaining)?; + let count: u64 = read(&mut remaining)?; + if version != 1 || count > MAX_DRAIN_ATTEMPTS as u64 { return Err(WalletStorageError::blob_decode( - "invalid shielded funding attempts", + "invalid shielded funding attempt count", )); } - Ok(wire.attempts) + let mut payloads = Vec::with_capacity(count as usize); + let mut total = 0usize; + for _ in 0..count { + let len: u64 = read(&mut remaining)?; + let len = usize::try_from(len) + .map_err(|_| WalletStorageError::blob_decode("attempt length overflow"))?; + total = total + .checked_add(len) + .ok_or_else(|| WalletStorageError::blob_decode("attempt bytes overflow"))?; + if len == 0 || total > MAX_DRAIN_ATTEMPT_BYTES || len > remaining.len() { + return Err(WalletStorageError::blob_decode( + "invalid shielded funding attempt bytes", + )); + } + let (payload, rest) = remaining.split_at(len); + payloads.push(payload); + remaining = rest; + } + if !remaining.is_empty() { + return Err(WalletStorageError::blob_decode( + "trailing shielded funding attempt bytes", + )); + } + Ok(payloads.into_iter().map(<[u8]>::to_vec).collect()) } /// Encode an [`AssetLockEntry`] to its on-disk `lifecycle_blob` form via the @@ -185,11 +219,12 @@ pub fn apply( 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, shielded_funding_attempts_blob FROM asset_locks WHERE wallet_id = ?1 AND outpoint = ?2", + "SELECT operation_id, shielded_funding_attempts_blob, 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)?, shielded_funding_attempts_blob: row.get(1)?, + lifecycle_blob: row.get(2)?, }), ).optional()?; // A signed attempt belongs to this immutable operation UUID. @@ -207,13 +242,28 @@ pub fn apply( )); } } + // 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; + } + } let old_attempts = decode_attempts( existing .as_ref() .and_then(|row| row.shielded_funding_attempts_blob.as_deref()), )?; let incoming = &entry.shielded_funding_attempts; - if incoming.iter().any(Vec::is_empty) + let incoming_bytes = incoming + .iter() + .try_fold(0usize, |total, bytes| total.checked_add(bytes.len())); + if incoming.len() > MAX_DRAIN_ATTEMPTS + || incoming_bytes.is_none_or(|total| total > MAX_DRAIN_ATTEMPT_BYTES) + || incoming.iter().any(Vec::is_empty) || (!incoming.starts_with(&old_attempts) && !old_attempts.starts_with(incoming)) { return Err(WalletStorageError::blob_decode( @@ -341,7 +391,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", } } @@ -397,7 +447,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 { @@ -409,7 +459,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; } } @@ -509,7 +559,7 @@ pub fn load_unconsumed( attempts_blob, ctx, )?; - if tracked.status == AssetLockStatus::Consumed && tracked.operation_id.is_none() { + if tracked.status.is_consumed() && tracked.operation_id.is_none() { continue; } out.entry(acct).or_default().insert(outpoint, tracked); @@ -617,6 +667,76 @@ mod tests { .expect("stored asset lock must load back") } + #[test] + fn should_bound_attempt_archive_before_payload_allocation() { + for attempts in [ + vec![vec![1]; MAX_DRAIN_ATTEMPTS], + vec![vec![2; MAX_DRAIN_ATTEMPT_BYTES]], + ] { + let bytes = blob::encode(&ShieldedFundingAttemptsWire { + version: 1, + attempts: attempts.clone(), + }) + .unwrap(); + assert_eq!(decode_attempts(Some(&bytes)).unwrap(), attempts); + } + for attempts in [ + vec![vec![1]; MAX_DRAIN_ATTEMPTS + 1], + vec![vec![1; MAX_DRAIN_ATTEMPT_BYTES], vec![2]], + ] { + let bytes = blob::encode(&ShieldedFundingAttemptsWire { + version: 1, + attempts, + }) + .unwrap(); + assert!(decode_attempts(Some(&bytes)).is_err()); + } + // Only the length prefix is present: reject it without allocating. + let bytes = bincode::encode_to_vec((1u32, u64::MAX), blob::bounded_config()).unwrap(); + assert!(decode_attempts(Some(&bytes)).is_err()); + let bytes = bincode::encode_to_vec((1u32, 1u64, u64::MAX), blob::bounded_config()).unwrap(); + assert!(decode_attempts(Some(&bytes)).is_err()); + } + + #[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 @@ -817,6 +937,7 @@ mod tests { AssetLockStatus::ChainLocked, AssetLockStatus::Consumed, AssetLockStatus::RecoveredFromChain, + AssetLockStatus::DrainCompleted, ]; for v in &variants { match v { @@ -825,7 +946,8 @@ mod tests { | AssetLockStatus::InstantSendLocked | AssetLockStatus::ChainLocked | AssetLockStatus::Consumed - | AssetLockStatus::RecoveredFromChain => {} + | AssetLockStatus::RecoveredFromChain + | AssetLockStatus::DrainCompleted => {} } } variants diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index fd088ea0c4d..91a32a933f2 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1517,8 +1517,8 @@ impl Merge for AssetLockChangeSet { ) { continue; } - if existing.status == AssetLockStatus::Consumed - && entry.status != AssetLockStatus::Consumed + if existing.status == AssetLockStatus::DrainCompleted + || (existing.status.is_consumed() && !entry.status.is_consumed()) { // Terminality guards lifecycle state, never discards // exact signed evidence carried by a delayed snapshot. @@ -1558,7 +1558,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); @@ -2591,6 +2591,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)); diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 1f9ed240c84..4bd49c72967 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -960,6 +960,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 37594d2913e..7df4b5a1dbd 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -363,8 +363,8 @@ impl PlatformWalletInfo { ) { continue; } - if existing.status == AssetLockStatus::Consumed - && entry.status != AssetLockStatus::Consumed + if existing.status == AssetLockStatus::DrainCompleted + || (existing.status.is_consumed() && !entry.status.is_consumed()) { existing.operation_id = entry.operation_id; existing.shielded_funding_attempts = entry.shielded_funding_attempts; @@ -393,7 +393,7 @@ impl PlatformWalletInfo { if self .tracked_asset_locks .get(&out_point) - .is_some_and(|entry| entry.status == AssetLockStatus::Consumed) + .is_some_and(|entry| entry.status.is_consumed()) { continue; } @@ -918,6 +918,44 @@ mod tests { 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]), vec![vec![18, 1]]), + ); + 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(); 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 f08efc4cd57..426e05653a2 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -4144,16 +4144,15 @@ mod tests { super::AssetLockOperationState::NotSent ); assert_eq!(manager.broadcaster.calls(), 1); - // Authenticated consumption, unlike an already-consumed report, is - // terminal and may be reported Completed by operation-ID lookup. - manager.consume_asset_lock(&outpoint).await.unwrap(); + // 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::Completed + super::AssetLockOperationState::Pending ); } 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 6557e4b797b..fcd39097acc 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs @@ -7,7 +7,7 @@ mod build; mod operation; pub use operation::{ AssetLockOperationContext, AssetLockOperationRegistration, AssetLockOperationSnapshot, - AssetLockOperationState, + AssetLockOperationState, MAX_DRAIN_ATTEMPTS, MAX_DRAIN_ATTEMPT_BYTES, }; pub mod lock_notify_handler; pub mod manager; diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs index 6381f111b95..fee5f681a91 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs @@ -21,10 +21,8 @@ use super::tracked::{AssetLockStatus, TrackedAssetLock}; // admitted attempt as evidence; exhaustion stops new dispatches, never recovery // or replay of an already recorded attempt. 64 attempts allow repeated freshness // retries, while 4 MiB bounds the archive even if individual transitions grow. -#[cfg(feature = "shielded")] -pub(super) const MAX_DRAIN_ATTEMPTS: usize = 64; -#[cfg(feature = "shielded")] -pub(super) const MAX_DRAIN_ATTEMPT_BYTES: usize = 4 * 1024 * 1024; +pub const MAX_DRAIN_ATTEMPTS: usize = 64; +pub const MAX_DRAIN_ATTEMPT_BYTES: usize = 4 * 1024 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AssetLockOperationState { @@ -115,8 +113,10 @@ impl Drop for AssetLockOperationRegistration { .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 - && self.operation.snapshot().out_point.is_none() + && (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)) @@ -130,7 +130,7 @@ impl Drop for AssetLockOperationRegistration { fn snapshot_for_lock(lock: &TrackedAssetLock) -> AssetLockOperationSnapshot { AssetLockOperationSnapshot { - state: if lock.status == AssetLockStatus::Consumed { + state: if lock.status == AssetLockStatus::DrainCompleted { AssetLockOperationState::Completed } else { AssetLockOperationState::Pending @@ -140,6 +140,22 @@ fn snapshot_for_lock(lock: &TrackedAssetLock) -> AssetLockOperationSnapshot { } 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); + } + } + } + /// Retain every exact signed attempt before dispatch. Failed writes retain /// the candidate in memory too: a backend may have committed before its /// flush failed, so the next attempt must never reuse its append index. @@ -161,7 +177,7 @@ impl AssetLockManager { "drain attempt has no operation UUID".into(), )); } - if lock.status == AssetLockStatus::Consumed { + if lock.status.is_consumed() { return Err(PlatformWalletError::AssetLockAlreadyConsumed(out_point)); } if !lock.shielded_funding_attempts.contains(&signed_attempt) { @@ -207,7 +223,7 @@ impl AssetLockManager { .filter(|lock| lock.operation_id == Some(operation_id)) .ok_or(PlatformWalletError::AssetLockNotTracked(out_point))?; let mut candidate = lock.clone(); - candidate.status = AssetLockStatus::Consumed; + candidate.status = AssetLockStatus::DrainCompleted; candidate.proof = None; let mut changes = crate::changeset::AssetLockChangeSet::default(); changes @@ -228,8 +244,9 @@ impl AssetLockManager { // notification must not suppress their live publication. tracing::warn!(%out_point, %error, "wallet drain committed inline; flush notification failed"); } - lock.status = AssetLockStatus::Consumed; + 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()) @@ -321,6 +338,9 @@ impl AssetLockManager { // 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); } } @@ -513,11 +533,13 @@ impl AssetLockManager { .unused_asset_locks .values() .flat_map(|locks| locks.values()) - .any(|lock| lock.operation_id.is_some() && lock.status != AssetLockStatus::Consumed); + .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::Consumed); + let live_pending = live_locks.iter().any(|lock| { + lock.operation_id.is_some() && lock.status != AssetLockStatus::DrainCompleted + }); let completed = live_locks .iter() .chain( @@ -528,7 +550,7 @@ impl AssetLockManager { .flat_map(|wallet| wallet.unused_asset_locks.values()) .flat_map(|locks| locks.values()), ) - .filter(|lock| lock.status == AssetLockStatus::Consumed) + .filter(|lock| lock.status == AssetLockStatus::DrainCompleted) .filter_map(|lock| lock.operation_id) .collect::>(); let context_pending = self 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 806e4ce948d..49ab03d83a1 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -714,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 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 5219c522df7..1c0bf1326fb 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 @@ -993,7 +993,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 @@ -1072,7 +1073,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 @@ -1743,7 +1745,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 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..bb8e50229d4 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 @@ -268,7 +268,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 +446,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 +455,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 +516,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 9256b18ae57..0ee24dbea4a 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,6 +107,19 @@ pub enum AssetLockStatus { /// Platform is the arbiter and rejects an already-spent outpoint /// with a typed error. RecoveredFromChain, + /// A wallet drain completed only after authenticated exact owned outputs + /// and proof-verified full consumption of the original outpoint. Committed + /// atomically with canonical shielded history by `complete_wallet_drain`. + /// 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 diff --git a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs index 167cc8af43f..f4a008ee666 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs @@ -384,7 +384,7 @@ impl PlatformWallet { else { return Ok(false); }; - if lock.status == AssetLockStatus::Consumed { + if lock.status == AssetLockStatus::DrainCompleted { return Ok(true); } if lock.shielded_funding_attempts.is_empty() { @@ -496,7 +496,7 @@ impl PlatformWallet { coordinator: &NetworkShieldedCoordinator, ) -> Result<(), PlatformWalletError> { for lock in self.asset_locks.list_tracked_locks().await { - if lock.operation_id.is_some() && lock.status != AssetLockStatus::Consumed { + if lock.operation_id.is_some() && lock.status != AssetLockStatus::DrainCompleted { self.reconcile_wallet_drain(coordinator, lock.out_point) .await?; } @@ -1076,93 +1076,225 @@ mod tests { #[tokio::test] async fn should_require_both_exact_outputs_and_fully_consumed_original_outpoint() { - let (lock, keys, st) = evidence_fixture(); - let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); + for initial_status in [ + AssetLockStatus::RecoveredFromChain, + AssetLockStatus::Consumed, + ] { + let (mut lock, keys, st) = evidence_fixture(); + lock.status = initial_status; + lock.shielded_funding_attempts = vec![st.serialize_to_bytes().unwrap()]; + let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); + let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; + assert_eq!( + wallet + .asset_locks + .wallet_drain_operation_status([0x33; 16]) + .await + .unwrap() + .state, + AssetLockOperationState::Pending + ); + // A successful affected-state response alone cannot complete a fresh + // submit or a recovery: its exact outputs must have been scanned too. + assert!( + !reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) + .await + .unwrap() + ); + for (index, (account, cmx)) in outputs.iter().enumerate() { + observe( + &coordinator, + wallet.wallet_id(), + *account, + *cmx, + index as u64, + ) + .await; + } + let partial = dpp::asset_lock::reduced_asset_lock_value::AssetLockValue::new( + 1_000_000, + vec![], + 500_000, + vec![], + dpp::version::PlatformVersion::latest(), + ) + .unwrap(); + // The bundle can be replayed with another lock: sightings do not prove + // that this original lock is fully consumed. Absence, partial use, + // unrelated response variants and unavailable proofs stay pending. + for result in [ + Ok(StateTransitionProofResult::VerifiedAssetLockConsumed( + StoredAssetLockInfo::NotPresent, + )), + Ok(StateTransitionProofResult::VerifiedAssetLockConsumed( + StoredAssetLockInfo::PartiallyConsumed(partial), + )), + Ok(StateTransitionProofResult::VerifiedShieldedNullifiers( + vec![], + )), + Err(dash_sdk::Error::Generic( + "consumption proof unavailable".into(), + )), + ] { + assert!(!wallet + .reconcile_wallet_drain_with_consumption_check( + &coordinator, + lock.out_point, + |transition| async move { + let StateTransition::ShieldFromAssetLock(transition) = transition + else { + panic!("expected Type18") + }; + assert_eq!( + transition.asset_lock_proof().out_point(), + Some(lock.out_point) + ); + result + }, + ) + .await + .unwrap()); + assert_ne!( + persistence.committed.lock().unwrap()[&lock.out_point].status, + AssetLockStatus::DrainCompleted + ); + assert!( + coordinator + .store() + .read() + .await + .get_activity(SubwalletId::new(wallet.wallet_id(), 0), 0, 10) + .unwrap() + .is_empty(), + "one-sided evidence must not create confirmed history" + ); + } + assert!( + reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) + .await + .unwrap() + ); + assert_exact_drain_history(&wallet, &coordinator, &outputs).await; + assert_eq!( + persistence.committed.lock().unwrap()[&lock.out_point].status, + AssetLockStatus::DrainCompleted + ); + let wallet = restart_wallet(wallet, persistence).await; + assert_eq!( + wallet + .asset_locks + .wallet_drain_operation_status([0x33; 16]) + .await + .unwrap() + .state, + AssetLockOperationState::Completed + ); + } + } + + #[tokio::test] + async fn should_not_trust_legacy_consumed_without_signed_attempts() { + let (mut lock, keys, _) = evidence_fixture(); + lock.status = AssetLockStatus::Consumed; let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; - wallet - .persist_drain_shielded_attempt(lock.out_point, &st) + assert!(wallet + .asset_locks + .consume_asset_lock(&lock.out_point) .await - .unwrap(); - // A successful affected-state response alone cannot complete a fresh - // submit or a recovery: its exact outputs must have been scanned too. - assert!( - !reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) + .is_err()); + assert!(!wallet + .reconcile_wallet_drain_with_consumption_check( + &coordinator, + lock.out_point, + |_| async { + panic!("without signed evidence no consumption proof should be requested"); + } + ) + .await + .unwrap()); + let wallet = restart_wallet(wallet, persistence.clone()).await; + assert_eq!( + wallet + .shielded_wallet_drain_operation_status(&coordinator, [0x33; 16]) .await .unwrap() + .state, + AssetLockOperationState::Pending ); - for (index, (account, cmx)) in outputs.iter().enumerate() { - observe( - &coordinator, - wallet.wallet_id(), - *account, - *cmx, - index as u64, - ) - .await; - } - let partial = dpp::asset_lock::reduced_asset_lock_value::AssetLockValue::new( - 1_000_000, - vec![], - 500_000, - vec![], - dpp::version::PlatformVersion::latest(), - ) - .unwrap(); - // The bundle can be replayed with another lock: sightings do not prove - // that this original lock is fully consumed. Absence, partial use, - // unrelated response variants and unavailable proofs stay pending. - for result in [ - Ok(StateTransitionProofResult::VerifiedAssetLockConsumed( - StoredAssetLockInfo::NotPresent, - )), - Ok(StateTransitionProofResult::VerifiedAssetLockConsumed( - StoredAssetLockInfo::PartiallyConsumed(partial), - )), - Ok(StateTransitionProofResult::VerifiedShieldedNullifiers( - vec![], - )), - Err(dash_sdk::Error::Generic( - "consumption proof unavailable".into(), - )), - ] { - assert!(!wallet - .reconcile_wallet_drain_with_consumption_check( + } + + #[tokio::test] + async fn should_evict_completed_context_after_last_lease_and_keep_pending_context() { + for complete_while_registered in [false, true] { + let (mut lock, keys, st) = evidence_fixture(); + lock.shielded_funding_attempts = vec![st.serialize_to_bytes().unwrap()]; + let outputs = + attempt_outputs(&lock, &lock.shielded_funding_attempts[0], &keys).unwrap(); + let (wallet, _, coordinator, _dir) = wallet_fixture(&lock, keys).await; + let operation = Arc::new(AssetLockOperationContext::new([0x33; 16])); + 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([0x33; 16]), + )); + 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 + }; + for (index, (account, cmx)) in outputs.iter().enumerate() { + observe( &coordinator, - lock.out_point, - |transition| async move { - let StateTransition::ShieldFromAssetLock(transition) = transition else { - panic!("expected Type18") - }; - assert_eq!( - transition.asset_lock_proof().out_point(), - Some(lock.out_point) - ); - result - }, + wallet.wallet_id(), + *account, + *cmx, + index as u64, ) - .await - .unwrap()); - assert_ne!( - persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::Consumed + .await; + } + assert!( + reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) + .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!( - coordinator - .store() - .read() + weak.upgrade().is_none(), + "completed context must be evicted" + ); + // A later status read of the durable tombstone also releases a newly + // registered context once that caller's final lease leaves. + let restored = Arc::new(AssetLockOperationContext::new([0x33; 16])); + let weak = Arc::downgrade(&restored); + let lease = wallet.register_wallet_drain_operation(restored.clone()); + drop(restored); + assert_eq!( + wallet + .asset_locks + .wallet_drain_operation_status([0x33; 16]) .await - .get_activity(SubwalletId::new(wallet.wallet_id(), 0), 0, 10) .unwrap() - .is_empty(), - "one-sided evidence must not create confirmed history" + .state, + AssetLockOperationState::Completed ); + drop(lease); + assert!(weak.upgrade().is_none()); } - assert!( - reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) - .await - .unwrap() - ); - assert_exact_drain_history(&wallet, &coordinator, &outputs).await; } #[tokio::test] @@ -1307,7 +1439,7 @@ mod tests { assert_eq!(status.out_point, Some(lock.out_point)); assert_eq!( persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::Consumed + AssetLockStatus::DrainCompleted ); let wallet = restart_wallet(wallet, persistence).await; assert_eq!( @@ -1414,11 +1546,11 @@ mod tests { assert_eq!(persistence.activity.lock().unwrap()[&id].activity.len(), 2); assert_eq!( persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::Consumed + AssetLockStatus::DrainCompleted ); assert_eq!( wallet.asset_locks.list_tracked_locks().await[0].status, - AssetLockStatus::Consumed + AssetLockStatus::DrainCompleted ); let mut next_scan = crate::changeset::ShieldedChangeSet::default(); coordinator @@ -1473,7 +1605,7 @@ mod tests { drop(store_guard); assert_ne!( persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::Consumed, + AssetLockStatus::DrainCompleted, "cancellation/contended publication must not commit terminal state" ); assert!(matches!(result, std::task::Poll::Ready(Ok(false)))); @@ -1535,11 +1667,11 @@ mod tests { ); assert_eq!( persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::Consumed + AssetLockStatus::DrainCompleted ); assert_eq!( wallet.asset_locks.list_tracked_locks().await[0].status, - AssetLockStatus::Consumed + AssetLockStatus::DrainCompleted ); assert_exact_drain_history(&wallet, &coordinator, &outputs).await; let id = SubwalletId::new(wallet.wallet_id(), 0); @@ -1602,11 +1734,11 @@ mod tests { ); assert_ne!( wallet.asset_locks.list_tracked_locks().await[0].status, - AssetLockStatus::Consumed + AssetLockStatus::DrainCompleted ); assert_ne!( persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::Consumed + AssetLockStatus::DrainCompleted ); assert!(coordinator .store() @@ -1933,7 +2065,7 @@ mod tests { let restored = persistence.load().unwrap(); assert_ne!( persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::Consumed + AssetLockStatus::DrainCompleted ); assert_eq!(restored.shielded.per_subwallet[&id].activity, before); // Reopen a fresh coordinator cache entirely from the host snapshot. @@ -1973,7 +2105,7 @@ mod tests { assert!(rows.contains(&authored)); assert_eq!( persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::Consumed + AssetLockStatus::DrainCompleted ); // A new scan cannot recreate the removed aggregate or double count A. let mut scan = crate::changeset::ShieldedChangeSet::default(); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift index 05392e03d96..585019150ac 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). 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/PlatformWalletManagerWalletDrain.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerWalletDrain.swift index de3436b743b..98b41998d84 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerWalletDrain.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerWalletDrain.swift @@ -119,8 +119,7 @@ extension PlatformWalletManager { fundingAccountIndex: UInt32 = 0 ) async throws -> WalletDrainPreview { try walletDrainReadPreflight(walletId: walletId) - let handle = self.handle - return try await Task.detached(priority: .userInitiated) { + return try await withWalletDrainNativeOperation("shieldedWalletDrainPreview") { handle in try Self.withWalletDrainID(walletId) { walletPtr in var preview = WalletDrainPreviewFFI() try platform_wallet_manager_shielded_wallet_drain_preview( @@ -128,7 +127,7 @@ extension PlatformWalletManager { ).check() return try WalletDrainPreview(ffi: preview) } - }.value + } } /// Drain the permitted transparent accounts into a single Orchard recipient. @@ -143,6 +142,7 @@ extension PlatformWalletManager { ) async -> WalletDrainOutcome { do { try shieldedFundFromAssetLockPreflight(walletId: walletId, recipients: recipients) + try admitNativeOp("shieldedFundFromWalletDrain") } catch { // The ID may belong to an earlier call. Local validation cannot // establish that the persisted operation was never dispatched. @@ -150,6 +150,7 @@ extension PlatformWalletManager { state: .pending, outpoint: nil, error: Self.walletDrainError(error)) } + defer { finishNativeOp() } let handle = self.handle let callIdentity = WalletDrainCallIdentity( manager: handle, walletId: walletId, operationId: operationId) @@ -210,7 +211,7 @@ extension PlatformWalletManager { if Self.walletDrainCalls[callIdentity] != nil { return WalletDrainOutcome(state: .pending, outpoint: nil) } - return try await Task.detached(priority: .userInitiated) { + return try await withWalletDrainNativeOperation("shieldedWalletDrainStatus") { handle in try Self.withWalletDrainIDs(walletId, operationId: operationId) { walletPtr, operationPtr in var outcome = WalletDrainOutcomeFFI() outcome.state = WalletDrainOutcome.State.pending.rawValue @@ -219,6 +220,19 @@ extension PlatformWalletManager { ).check() return WalletDrainOutcome(ffi: outcome) } + } + } + + /// 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 } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 21afe248a3e..eb649a430a3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -578,7 +578,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 @@ -662,7 +663,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) @@ -718,13 +719,51 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + // Mirrors the native wallet's MAX_DRAIN_ATTEMPTS / MAX_DRAIN_ATTEMPT_BYTES. + fileprivate static let maxDrainAttempts = 64 + fileprivate static let maxDrainAttemptBytes = 4 * 1024 * 1024 + private struct ShieldedFundingAttemptsArchive: Codable { let version: UInt32 var attempts: [Data] + enum CodingKeys: CodingKey { case version, attempts } + + init(version: UInt32, attempts: [Data]) { + self.version = version + self.attempts = attempts + } + + init(from decoder: Decoder) throws { + let fields = try decoder.container(keyedBy: CodingKeys.self) + version = try fields.decode(UInt32.self, forKey: .version) + var rows = try fields.nestedUnkeyedContainer(forKey: .attempts) + guard version == 1, (rows.count ?? 0) <= maxDrainAttempts else { + throw AssetLockPersistenceError.malformedRecord + } + attempts = [] + var total = 0 + while !rows.isAtEnd { + guard attempts.count < maxDrainAttempts else { + throw AssetLockPersistenceError.malformedRecord + } + let data = try rows.decode(Data.self) + let (next, overflow) = total.addingReportingOverflow(data.count) + guard !data.isEmpty, !overflow, next <= maxDrainAttemptBytes else { + throw AssetLockPersistenceError.malformedRecord + } + total = next + attempts.append(data) + } + } } private static func decodeShieldedFundingAttempts(_ record: PersistentAssetLock) throws -> [Data] { guard let blob = record.shieldedFundingAttemptsBlob else { return [] } + // Bound the plist parser itself before it materializes its object graph. + // Allow XML base64 expansion plus framing; writers use compact binary plists. + guard blob.count <= maxDrainAttemptBytes * 2 + 64 * 1024 else { + throw AssetLockPersistenceError.malformedRecord + } let archive = try PropertyListDecoder().decode(ShieldedFundingAttemptsArchive.self, from: blob) guard archive.version == 1, !archive.attempts.contains(where: { $0.isEmpty }), archive.attempts.isEmpty || record.operationId?.count == 16 else { @@ -756,6 +795,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return } guard index == attempts.count else { throw AssetLockPersistenceError.malformedRecord } + guard attempts.count < Self.maxDrainAttempts, + bytes.count <= Self.maxDrainAttemptBytes - attempts.reduce(0, { $0 + $1.count }) else { + throw AssetLockPersistenceError.malformedRecord + } attempts.append(bytes) let encoder = PropertyListEncoder() encoder.outputFormat = .binary @@ -1105,7 +1148,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { guard decodeOutPointHex(record.outPointHex) != nil, !record.transactionBytes.isEmpty, (0...5).contains(record.fundingTypeRaw), - (0...5).contains(record.statusRaw) else { + (0...6).contains(record.statusRaw) else { throw AssetLockPersistenceError.malformedRecord } if let operationId = record.operationId { @@ -11853,7 +11896,9 @@ private func persistShieldedFundingAttemptCallback( attemptIndex: UInt32, bytes: UnsafePointer?, bytesLen: UInt ) -> Int32 { guard let context, let walletId, let outPoint, let operationId, let bytes, - let count = Int(exactly: bytesLen), count > 0 else { return -1 } + let count = Int(exactly: bytesLen), count > 0, + count <= PlatformWalletPersistenceHandler.maxDrainAttemptBytes, + Int(attemptIndex) < PlatformWalletPersistenceHandler.maxDrainAttempts else { return -1 } let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() do { try handler.persistShieldedFundingAttempt( 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 index 94a61f7941e..755fb7f7e5e 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift @@ -148,6 +148,54 @@ final class AssetLockOperationPersistenceTests: XCTestCase { XCTAssertThrowsError(try handler.loadShieldedFundingAttempts(walletId: walletId, outPoint: rawOutPoint)) } + func testShouldBoundAttemptArchivesBeforeRecovery() throws { + struct Archive: Encodable { let version = 1; let attempts: [Data] } + let limit = 4 * 1024 * 1024 + for (attempts, valid) in [ + (Array(repeating: Data([1]), count: 64), true), + ([Data(repeating: 1, count: limit)], true), + (Array(repeating: Data([1]), count: 65), false), + ([Data(repeating: 1, count: limit), Data([2])], false), + ([Data(repeating: 1, count: limit + 1)], false) + ] { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + let record = PersistentAssetLock(outPointHex: outPointHex, walletId: walletId, + transactionBytes: Data([1]), fundingTypeRaw: 5, identityIndexRaw: 0, + amountDuffs: 1000, statusRaw: 4, operationId: operationId) + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + record.shieldedFundingAttemptsBlob = try encoder.encode(Archive(attempts: attempts)) + context.insert(record) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + if valid { + XCTAssertEqual(try handler.loadShieldedFundingAttempts(walletId: walletId, outPoint: rawOutPoint), attempts) + } else { + XCTAssertThrowsError(try handler.loadShieldedFundingAttempts(walletId: walletId, outPoint: rawOutPoint)) + } + } + } + + 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 } 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 { From 999c2b48d1f658b406433bd7815be12568c6bb4f Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 14 Oct 2026 09:59:13 +0200 Subject: [PATCH 10/24] fix(platform-wallet): finish rejected-drain cleanup and republish deferred completion A definitively rejected drain whose row removal was not confirmed durable kept a Pending context and dropped its input fence for good. The reservation and fence now move to the operation context. A status read that proves the row gone finishes the cleanup in memory. A same-UUID retry re-issues the removal, releases the inputs and reports the definite rejection. A buffered completion committed by a later, unrelated flush left the live lock and activity cache stale, because status saw durable completion and skipped reconciliation. Status now re-runs the completion gate when the live lock is not DrainCompleted. Shielded restore now skips a snapshot activity row that claims a note already owned by a different live row. A bind snapshot loaded before a drain replaced a scan aggregate can no longer resurrect that aggregate. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/asset_lock/build.rs | 119 +++++++++--- .../src/wallet/asset_lock/operation.rs | 178 +++++++++++++++++- .../src/wallet/shielded/coordinator.rs | 19 ++ .../src/wallet/shielded/drain_recovery.rs | 115 ++++++++--- .../src/wallet/shielded/wallet_drain.rs | 47 ++++- 5 files changed, 426 insertions(+), 52 deletions(-) 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 426e05653a2..f2f15704a74 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -30,6 +30,7 @@ use crate::wallet::platform_wallet::PlatformWalletInfo; use crate::ASSET_LOCK_FUNDING_SOURCES; use super::manager::{AssetLockManager, DEFAULT_FEE_PER_KB}; +use super::operation::RejectedDrainCleanup; use super::tracked::{AssetLockStatus, TrackedAssetLock}; use super::{AssetLockOperationContext, AssetLockOperationState}; @@ -1249,29 +1250,44 @@ impl AssetLockManager { // still-tracked row would be resumable while its inputs are // re-spendable. let removed_built_row = cs_untrack.removed.contains(&out_point); - 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. + if let Some(operation) = operation.filter(|_| removed_built_row) { + let removal = self + .persister + .store(PlatformWalletChangeSet { + asset_locks: Some(cs_untrack), + ..Default::default() + }) + .map(|()| self.persister.flush()); + let uncertain = match removal { + Err(error) => Some(error), + Ok(Err(error)) if !self.persister.store_commits_inline() => Some(error), + Ok(Err(error)) => { + // The removal is already committed. A failed host + // notification must not strand its context or inputs. + tracing::warn!(%out_point, %error, "rejected drain removed inline; flush notification failed"); + None + } + Ok(Ok(())) => None, + }; + if let Some(error) = uncertain { + // The durable removal is unknown; a buffered one may + // still commit with a later flush. Keep the context and + // input fence pending, but do NOT re-expose the row: a + // generic resume must never dispatch a row with + // uncertain durability. Hand the reservation and fence + // to the operation so an explicit retry, or a status + // read proving the row gone, can finish the cleanup. + operation.retain_rejected_cleanup(RejectedDrainCleanup { + out_point, + transaction: tx, + funding_accounts, + reservation_token, + pin: in_broadcast_pin, + }); return Err(PlatformWalletError::Persistence(format!( "could not durably remove rejected drain asset lock: {error}" ))); } - if let Err(error) = self.persister.flush() { - if !self.persister.store_commits_inline() { - return Err(PlatformWalletError::Persistence(format!( - "could not durably remove rejected drain asset lock: {error}" - ))); - } - // The removal is already committed. A failed host - // notification must not strand its context or inputs. - tracing::warn!(%out_point, %error, "rejected drain removed inline; flush notification failed"); - } } else { self.queue_asset_lock_changeset(cs_untrack); } @@ -1593,7 +1609,7 @@ mod tests { before_remove: Mutex>>, restore_stored_locks: std::sync::atomic::AtomicBool, commits_inline: bool, - fail_remove_store: bool, + fail_remove_store: std::sync::atomic::AtomicBool, committed_stores: std::sync::atomic::AtomicUsize, fail_lock_store: std::sync::atomic::AtomicBool, failed_lock_stores: Mutex>, @@ -1662,7 +1678,10 @@ mod tests { .as_ref() .is_some_and(|locks| !locks.removed.is_empty()) { - if self.fail_remove_store { + if self + .fail_remove_store + .load(std::sync::atomic::Ordering::SeqCst) + { return Err(PersistenceError::backend("simulated removal store failure")); } if let Some(hook) = self.before_remove.lock().unwrap().as_ref() { @@ -4640,8 +4659,9 @@ mod tests { funded_wallet_manager(StandardAccountType::BIP44Account).await; let persistence = Arc::new(CapturingPersistence { commits_inline, - fail_remove_store, - fail_on_flush: Some(3), + fail_remove_store: std::sync::atomic::AtomicBool::new(fail_remove_store), + // A failed removal store never reaches its flush. + fail_on_flush: (!fail_remove_store).then_some(3), restore_stored_locks: std::sync::atomic::AtomicBool::new(true), ..CapturingPersistence::for_wallet(&wallet_manager, wallet_id) }); @@ -4709,6 +4729,57 @@ mod tests { } else { let other = Arc::new(super::AssetLockOperationContext::new([54; 16])); assert!(manager.begin_wallet_drain(&other).await.is_err()); + let blocked = manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await; + assert!(blocked.is_err(), "{blocked:?}"); + // An unrelated later flush commits a buffered removal; a + // removal store that failed outright never reached it. + persistence + .fail_remove_store + .store(false, std::sync::atomic::Ordering::SeqCst); + persistence.flush(wallet_id).unwrap(); + let buffered_removal_committed = !fail_remove_store; + // With the row provably gone, a status read alone finishes + // the in-memory cleanup. + assert_eq!( + manager + .wallet_drain_operation_status([53; 16]) + .await + .unwrap() + .state, + if buffered_removal_committed { + super::AssetLockOperationState::NotSent + } else { + super::AssetLockOperationState::Pending + } + ); + if !buffered_removal_committed { + // A same-UUID retry re-issues the removal, releases the + // inputs and reports the definite rejection. + let retry = Arc::new(super::AssetLockOperationContext::new([53; 16])); + let _registration = manager.register_wallet_drain_operation(retry.clone()); + assert!(matches!( + manager.begin_wallet_drain(&retry).await, + Err(PlatformWalletError::TransactionBroadcast(_)) + )); + } + let restored = persistence.load().unwrap(); + assert!(restored.wallets[&wallet_id] + .unused_asset_locks + .values() + .all(|locks| locks.is_empty())); + 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); + } } // Test actual input selection too: clearing only the operation // snapshot would still leave the non-expiring spend fence. @@ -4721,7 +4792,7 @@ mod tests { &signer, ) .await; - assert_eq!(rebuild.is_ok(), released, "{rebuild:?}"); + assert!(rebuild.is_ok(), "{rebuild:?}"); } } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs index fee5f681a91..a8fb91555fd 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs @@ -8,11 +8,16 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; -use dashcore::OutPoint; +use dashcore::{OutPoint, Transaction}; +use key_wallet::account::AccountType; use crate::broadcaster::TransactionBroadcaster; use crate::changeset::PersistenceCapabilities; +#[cfg(any(feature = "shielded", test))] +use crate::changeset::{AssetLockChangeSet, PlatformWalletChangeSet}; use crate::error::PlatformWalletError; +use crate::wallet::core::InBroadcastPin; +use crate::wallet::reservations::release_reservation_after_rejected_broadcast; use super::manager::AssetLockManager; use super::tracked::{AssetLockStatus, TrackedAssetLock}; @@ -51,6 +56,26 @@ pub struct AssetLockOperationContext { // could dispatch it before durability. Retain it here so explicit retries // can repeat the checkpoint after a rolled-back or uncertain write. checkpoint: Mutex>, + rejected_cleanup: Mutex>, +} + +/// Cleanup owed by a definitively rejected drain whose row removal was not +/// confirmed durable. Its inputs stay reserved and fenced until the removal is +/// known committed; only then is the operation published as NotSent. +pub(crate) struct RejectedDrainCleanup { + pub(crate) out_point: OutPoint, + pub(crate) transaction: Transaction, + pub(crate) funding_accounts: Vec, + pub(crate) reservation_token: Option, + pub(crate) pin: InBroadcastPin, +} + +impl std::fmt::Debug for RejectedDrainCleanup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RejectedDrainCleanup") + .field("out_point", &self.out_point) + .finish_non_exhaustive() + } } impl AssetLockOperationContext { @@ -65,9 +90,44 @@ impl AssetLockOperationContext { out_point: None, }), checkpoint: Mutex::new(None), + rejected_cleanup: Mutex::new(None), } } + pub(crate) fn retain_rejected_cleanup(&self, cleanup: RejectedDrainCleanup) { + *self + .rejected_cleanup + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(cleanup); + } + + fn take_rejected_cleanup(&self) -> Option { + let snapshot = self.snapshot(); + let mut cleanup = self + .rejected_cleanup + .lock() + .unwrap_or_else(|e| e.into_inner()); + // Only the operation's own pending outpoint can still owe this cleanup. + let owed = cleanup.as_ref().is_some_and(|cleanup| { + snapshot.state == AssetLockOperationState::Pending + && snapshot.out_point == Some(cleanup.out_point) + }); + if owed { + cleanup.take() + } else { + None + } + } + + #[cfg(any(feature = "shielded", test))] + fn rejected_cleanup_out_point(&self) -> Option { + self.rejected_cleanup + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .map(|cleanup| cleanup.out_point) + } + pub fn snapshot(&self) -> AssetLockOperationSnapshot { *self .snapshot @@ -156,6 +216,97 @@ impl AssetLockManager { } } + /// The rejected row's removal is durable: nothing was sent and nothing can + /// be resumed, so publish NotSent and release the inputs it still holds. + async fn finish_rejected_drain_cleanup( + &self, + context: &AssetLockOperationContext, + cleanup: RejectedDrainCleanup, + ) { + // Publish before the cancellable reservation cleanup; a cancellation + // inside it settles the fence released through `settle_released_on_drop`. + context.set(AssetLockOperationState::NotSent, None); + { + // No outpoint is left to retain. Without a live lease, nothing else + // would evict the context, and it would block same-UUID retries. + let mut operations = self + .drain_operations + .lock() + .unwrap_or_else(|e| e.into_inner()); + if context.registrations.load(Ordering::SeqCst) == 0 + && operations + .get(&context.operation_id) + .is_some_and(|current| std::ptr::eq(Arc::as_ptr(current), context)) + { + operations.remove(&context.operation_id); + } + } + let RejectedDrainCleanup { + transaction, + funding_accounts, + reservation_token, + mut pin, + .. + } = cleanup; + pin.settle_released_on_drop(); + release_reservation_after_rejected_broadcast( + &self.wallet_manager, + &self.wallet_id, + &funding_accounts, + &transaction, + reservation_token, + ) + .await; + pin.settle_released(); + } + + /// Explicit-retry half of the rejected-drain cleanup: re-issue the row + /// removal (idempotent, possibly still buffered from the failed attempt), + /// then finish the cleanup. Returns true when a cleanup was finished. + #[cfg(any(feature = "shielded", test))] + async fn settle_rejected_drain( + &self, + operation_id: [u8; 16], + ) -> Result { + let Some(context) = self + .drain_operations + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&operation_id) + .cloned() + else { + return Ok(false); + }; + let Some(out_point) = context.rejected_cleanup_out_point() else { + return Ok(false); + }; + let mut changes = AssetLockChangeSet::default(); + changes.removed.insert(out_point); + self.persister + .store(PlatformWalletChangeSet { + asset_locks: Some(changes), + ..Default::default() + }) + .map_err(|error| { + PlatformWalletError::Persistence(format!( + "could not durably remove rejected drain asset lock: {error}" + )) + })?; + if let Err(error) = self.persister.flush() { + if !self.persister.store_commits_inline() { + return Err(PlatformWalletError::Persistence(format!( + "could not durably remove rejected drain asset lock: {error}" + ))); + } + tracing::warn!(%out_point, %error, "rejected drain removed inline; flush notification failed"); + } + let Some(cleanup) = context.take_rejected_cleanup() else { + return Ok(false); + }; + self.finish_rejected_drain_cleanup(&context, cleanup).await; + Ok(true) + } + /// Retain every exact signed attempt before dispatch. Failed writes retain /// the candidate in memory too: a backend may have committed before its /// flush failed, so the next attempt must never reuse its append index. @@ -333,7 +484,19 @@ impl AssetLockManager { .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()); + .cloned(); + // A rejected drain whose buffered removal has since been committed by + // another flush has no durable or live row left: this complete read + // proves nothing can be resumed, so finish its in-memory cleanup. + if let Some(context) = context + .as_ref() + .filter(|_| durable.is_none() && live.is_none()) + { + if let Some(cleanup) = context.take_rejected_cleanup() { + self.finish_rejected_drain_cleanup(context, cleanup).await; + } + } + let context = context.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() { @@ -473,6 +636,17 @@ impl AssetLockManager { &self, operation: &Arc, ) -> Result, PlatformWalletError> { + // An earlier attempt under this UUID was rejected before broadcast but + // could not confirm its row removal. Finish that cleanup first and + // report the rejection: nothing was sent and its inputs are free again. + if self.settle_rejected_drain(operation.operation_id).await? { + operation.set(AssetLockOperationState::NotSent, None); + return Err(PlatformWalletError::TransactionBroadcast( + "previous drain transaction was rejected before broadcast; its inputs are \ + released and nothing was sent" + .into(), + )); + } let first_attempt = !operation.started.swap(true, Ordering::SeqCst); let previous = self .wallet_drain_operation_status_inner( diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 3240f8fc199..4231fa90652 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -1113,7 +1113,26 @@ impl NetworkShieldedCoordinator { .filter(|record| record.identity_user_abandoned) .map(|record| record.activity_id) .collect(); + // A note belongs to at most one activity row, and the live cache + // is at least as fresh as any snapshot. A snapshot row claiming a + // note that a different live row already owns is therefore stale, + // e.g. a scan aggregate that a completed drain replaced after this + // snapshot was loaded. Restoring it would count that note twice. + let mut claimed = BTreeMap::new(); + for live in store + .get_activity(*id, 0, usize::MAX) + .map_err(|e| crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()))? + { + claimed.extend(live.note_cmxs.iter().map(|cmx| (*cmx, live.id))); + } for entry in &sub.activity { + if entry + .note_cmxs + .iter() + .any(|cmx| claimed.get(cmx).is_some_and(|owner| *owner != entry.id)) + { + continue; + } let mut entry = entry.clone(); if abandoned.contains(&entry.id) && entry.status != ShieldedActivityStatus::Confirmed diff --git a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs index f4a008ee666..94eb321e3f8 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs @@ -339,33 +339,34 @@ impl PlatformWallet { coordinator: &NetworkShieldedCoordinator, out_point: OutPoint, ) -> Result { - self.reconcile_wallet_drain_with_consumption_check( - coordinator, - out_point, - |transition| async move { - // Status is read-only and must not inherit the long submission - // deadline. Bound the streaming wait as well as each request. - let timeout = Duration::from_secs(3); - let mut settings = PutSettings::default(); - settings.request_settings.connect_timeout = Some(timeout); - settings.request_settings.timeout = Some(timeout); - settings.request_settings.retries = Some(0); - settings.wait_timeout = Some(timeout); - tokio::time::timeout( - timeout, - transition.wait_for_affected_state::( - &self.sdk, - Some(settings), - ), - ) - .await - .map_err(|_| dash_sdk::Error::Generic("drain consumption proof timed out".into()))? - }, + self.reconcile_wallet_drain_with_consumption_check(coordinator, out_point, |transition| { + self.bounded_drain_consumption_proof(transition) + }) + .await + } + + pub(super) async fn bounded_drain_consumption_proof( + &self, + transition: StateTransition, + ) -> Result { + // Status is read-only and must not inherit the long submission + // deadline. Bound the streaming wait as well as each request. + let timeout = Duration::from_secs(3); + let mut settings = PutSettings::default(); + settings.request_settings.connect_timeout = Some(timeout); + settings.request_settings.timeout = Some(timeout); + settings.request_settings.retries = Some(0); + settings.wait_timeout = Some(timeout); + tokio::time::timeout( + timeout, + transition + .wait_for_affected_state::(&self.sdk, Some(settings)), ) .await + .map_err(|_| dash_sdk::Error::Generic("drain consumption proof timed out".into()))? } - async fn reconcile_wallet_drain_with_consumption_check( + pub(super) async fn reconcile_wallet_drain_with_consumption_check( &self, coordinator: &NetworkShieldedCoordinator, out_point: OutPoint, @@ -2136,6 +2137,74 @@ mod tests { .len(), 3 ); + // A bind snapshot loaded before completion and restored afterwards + // must not resurrect the replaced aggregate beside the canonical rows. + coordinator + .restore_for_wallet(wallet.wallet_id(), &restored.shielded) + .await + .unwrap(); + let live = coordinator + .store() + .read() + .await + .get_activity(id, 0, 10) + .unwrap(); + assert_eq!(live.len(), 3); + assert!(live.iter().all(|row| row.id != aggregate[0].id)); + assert_eq!(live.iter().map(|row| row.amount).sum::(), 4_000); + } + + #[tokio::test] + async fn should_publish_deferred_completion_committed_by_a_later_flush() { + let (lock, keys, st) = evidence_fixture(); + let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); + let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; + wallet + .persist_drain_shielded_attempt(lock.out_point, &st) + .await + .unwrap(); + for (index, (account, cmx)) in outputs.iter().enumerate() { + observe( + &coordinator, + wallet.wallet_id(), + *account, + *cmx, + index as u64, + ) + .await; + } + // The buffered completion store succeeds but its own flush fails, so + // nothing is published live; an unrelated flush then commits it. + persistence.fail_flush.store(true, Ordering::SeqCst); + assert!( + reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) + .await + .is_err() + ); + persistence.fail_flush.store(false, Ordering::SeqCst); + persistence.flush(wallet.wallet_id()).unwrap(); + assert_eq!( + persistence.committed.lock().unwrap()[&lock.out_point].status, + AssetLockStatus::DrainCompleted + ); + assert_ne!( + wallet.asset_locks.list_tracked_locks().await[0].status, + AssetLockStatus::DrainCompleted + ); + let status = wallet + .shielded_wallet_drain_operation_status_with(&coordinator, [0x33; 16], |_| async { + Ok(StateTransitionProofResult::VerifiedAssetLockConsumed( + StoredAssetLockInfo::FullyConsumed, + )) + }) + .await + .unwrap(); + assert_eq!(status.state, AssetLockOperationState::Completed); + assert_eq!( + wallet.asset_locks.list_tracked_locks().await[0].status, + AssetLockStatus::DrainCompleted + ); + assert_exact_drain_history(&wallet, &coordinator, &outputs).await; } #[test] diff --git a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs index 92749e15a3d..a0f849fcb05 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs @@ -1,6 +1,7 @@ //! Read-only quote for pooled Core → Shielded drains. use std::collections::HashSet; +use std::future::Future; use std::sync::Arc; use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; @@ -8,6 +9,8 @@ use dashcore::blockdata::transaction::special_transaction::TransactionPayload; use dashcore::hashes::Hash; use dashcore::{PubkeyHash, ScriptBuf, TxOut}; use dpp::balances::credits::CREDITS_PER_DUFF; +use dpp::state_transition::proof_result::StateTransitionProofResult; +use dpp::state_transition::StateTransition; 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::{ @@ -17,8 +20,10 @@ use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoIn use crate::error::PlatformWalletError; use crate::wallet::asset_lock::manager::DEFAULT_FEE_PER_KB; +use crate::wallet::asset_lock::tracked::AssetLockStatus; use crate::wallet::asset_lock::{ AssetLockOperationContext, AssetLockOperationRegistration, AssetLockOperationSnapshot, + AssetLockOperationState, }; use crate::wallet::core::resolved_funding_accounts; use crate::wallet::PlatformWallet; @@ -60,6 +65,22 @@ impl PlatformWallet { coordinator: &super::NetworkShieldedCoordinator, operation_id: [u8; 16], ) -> Result { + self.shielded_wallet_drain_operation_status_with(coordinator, operation_id, |transition| { + self.bounded_drain_consumption_proof(transition) + }) + .await + } + + pub(super) async fn shielded_wallet_drain_operation_status_with( + &self, + coordinator: &super::NetworkShieldedCoordinator, + operation_id: [u8; 16], + check_consumption: F, + ) -> Result + where + F: FnOnce(StateTransition) -> Fut, + Fut: Future>, + { let Ok(_shield_guard) = self.shield_guard.try_lock() else { // A status read must not wait behind a network submission. The // active operation's context already provides a fail-closed view. @@ -72,9 +93,29 @@ impl PlatformWallet { .asset_locks .wallet_drain_operation_status(operation_id) .await?; - if snapshot.state == crate::wallet::asset_lock::AssetLockOperationState::Pending { - if let Some(out_point) = snapshot.out_point { - self.reconcile_wallet_drain(coordinator, out_point).await?; + if let Some(out_point) = snapshot.out_point { + // A buffered completion whose own flush failed can be committed by + // a later, unrelated flush. Durable status then says Completed, but + // the live lock and activity cache were never published; re-running + // the completion gate republishes both. + let unpublished = snapshot.state == AssetLockOperationState::Completed + && self + .asset_locks + .list_tracked_locks() + .await + .iter() + .any(|lock| { + lock.out_point == out_point + && lock.operation_id == Some(operation_id) + && lock.status != AssetLockStatus::DrainCompleted + }); + if snapshot.state == AssetLockOperationState::Pending || unpublished { + self.reconcile_wallet_drain_with_consumption_check( + coordinator, + out_point, + check_consumption, + ) + .await?; } } self.asset_locks From 9fad6e360811e10c91b23b9dc98e0ac23f65ea64 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 14 Oct 2026 10:31:35 +0200 Subject: [PATCH 11/24] fix(platform-wallet): confirm rejected-drain absence freshly and republish without a proof Status could finish a rejected-drain cleanup from a durable read taken before the rejection retained it, releasing inputs while the durable Built row still existed. It now confirms absence with a fresh committed read after observing the cleanup. Durable DrainCompleted is written only after the full completion gate, so republishing a completion that a later flush committed no longer requires another network consumption proof. Status and the pre-drain reconciliation both use it. A stale live lock for a durably completed drain no longer blocks a new drain. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/asset_lock/build.rs | 42 ++++++++++- .../src/wallet/asset_lock/operation.rs | 69 +++++++++++++++---- .../src/wallet/shielded/drain_recovery.rs | 54 +++++++++++++-- .../src/wallet/shielded/wallet_drain.rs | 13 +++- 4 files changed, 156 insertions(+), 22 deletions(-) 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 f2f15704a74..dce8ad0c0bd 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -1613,6 +1613,9 @@ mod tests { committed_stores: std::sync::atomic::AtomicUsize, fail_lock_store: std::sync::atomic::AtomicBool, failed_lock_stores: Mutex>, + /// Loads that still return the pre-checkpoint snapshot (no stored + /// lock rows), modelling a read that predates a concurrent write. + stale_loads: std::sync::atomic::AtomicUsize, } impl CapturingPersistence { @@ -1729,9 +1732,18 @@ mod tests { }, ); } - if self - .restore_stored_locks - .load(std::sync::atomic::Ordering::SeqCst) + let stale = self + .stale_loads + .fetch_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |loads| loads.checked_sub(1), + ) + .is_ok(); + if !stale + && self + .restore_stored_locks + .load(std::sync::atomic::Ordering::SeqCst) { let wallet = state.wallets.values_mut().next().unwrap(); for cs in self @@ -4739,6 +4751,30 @@ mod tests { ) .await; assert!(blocked.is_err(), "{blocked:?}"); + // An absence observed before the cleanup was retained is + // stale: the durable Built row still exists, so status must + // stay Pending and keep the inputs fenced. + persistence + .stale_loads + .store(1, std::sync::atomic::Ordering::SeqCst); + assert_eq!( + manager + .wallet_drain_operation_status([53; 16]) + .await + .unwrap() + .state, + super::AssetLockOperationState::Pending + ); + let still_blocked = manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await; + assert!(still_blocked.is_err(), "{still_blocked:?}"); // An unrelated later flush commits a buffered removal; a // removal store that failed outright never reached it. persistence diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs index a8fb91555fd..6d032770878 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs @@ -119,7 +119,6 @@ impl AssetLockOperationContext { } } - #[cfg(any(feature = "shielded", test))] fn rejected_cleanup_out_point(&self) -> Option { self.rejected_cleanup .lock() @@ -435,6 +434,46 @@ impl AssetLockManager { Ok(()) } + /// A fresh committed read without this operation's row. An unreadable + /// store or a missing wallet snapshot never counts as absence. + fn drain_row_durably_absent(&self, operation_id: [u8; 16]) -> bool { + self.persister.load().ok().is_some_and(|persisted| { + persisted + .wallets + .get(&self.wallet_id) + .is_some_and(|wallet| { + !wallet + .unused_asset_locks + .values() + .flat_map(|locks| locks.values()) + .any(|lock| lock.operation_id == Some(operation_id)) + }) + }) + } + + /// Drain operations whose committed row says `DrainCompleted`, which only + /// the full completion gate writes. + #[cfg(any(feature = "shielded", test))] + pub(crate) fn durably_completed_drains( + &self, + ) -> Result, PlatformWalletError> { + let persisted = self.persister.load().map_err(|error| { + PlatformWalletError::Persistence(format!("cannot read drain operations: {error}")) + })?; + let wallet = persisted.wallets.get(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::Persistence( + "cannot read drain operations: wallet missing from durable snapshot".into(), + ) + })?; + Ok(wallet + .unused_asset_locks + .values() + .flat_map(|locks| locks.values()) + .filter(|lock| lock.status == AssetLockStatus::DrainCompleted) + .filter_map(|lock| lock.operation_id) + .collect()) + } + /// 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( @@ -486,14 +525,16 @@ impl AssetLockManager { .filter(|context| !ignore_context.is_some_and(|ignored| Arc::ptr_eq(context, ignored))) .cloned(); // A rejected drain whose buffered removal has since been committed by - // another flush has no durable or live row left: this complete read - // proves nothing can be resumed, so finish its in-memory cleanup. - if let Some(context) = context - .as_ref() - .filter(|_| durable.is_none() && live.is_none()) - { - if let Some(cleanup) = context.take_rejected_cleanup() { - self.finish_rejected_drain_cleanup(context, cleanup).await; + // another flush has no durable or live row left. The read above may + // predate the rejection that retained this cleanup, so confirm durable + // absence with a fresh committed read before releasing its inputs. + if let Some(context) = context.as_ref().filter(|_| live.is_none()) { + if context.rejected_cleanup_out_point().is_some() + && self.drain_row_durably_absent(operation_id) + { + if let Some(cleanup) = context.take_rejected_cleanup() { + self.finish_rejected_drain_cleanup(context, cleanup).await; + } } } let context = context.map(|context| context.snapshot()); @@ -711,9 +752,6 @@ impl AssetLockManager { 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( @@ -727,6 +765,13 @@ impl AssetLockManager { .filter(|lock| lock.status == AssetLockStatus::DrainCompleted) .filter_map(|lock| lock.operation_id) .collect::>(); + // A durable completion may not have been published to the live lock + // yet; its stale live status must not block a new drain. + let live_pending = live_locks.iter().any(|lock| { + lock.operation_id + .is_some_and(|operation_id| !completed.contains(&operation_id)) + && lock.status != AssetLockStatus::DrainCompleted + }); let context_pending = self .drain_operations .lock() diff --git a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs index 94eb321e3f8..dcee00ec518 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs @@ -492,12 +492,48 @@ impl PlatformWallet { Ok(true) } + /// Durable `DrainCompleted` is written only after the full completion gate, + /// including the consumption proof. When a later flush committed it without + /// live publication, it stands in for that proof while the live lock and + /// canonical history are republished. + pub(super) async fn republish_durable_drain_completion( + &self, + coordinator: &NetworkShieldedCoordinator, + out_point: OutPoint, + ) -> Result { + self.reconcile_wallet_drain_with_consumption_check(coordinator, out_point, |_| async { + Ok(StateTransitionProofResult::VerifiedAssetLockConsumed( + StoredAssetLockInfo::FullyConsumed, + )) + }) + .await + } + pub(super) async fn reconcile_wallet_drains( &self, coordinator: &NetworkShieldedCoordinator, ) -> Result<(), PlatformWalletError> { - for lock in self.asset_locks.list_tracked_locks().await { - if lock.operation_id.is_some() && lock.status != AssetLockStatus::DrainCompleted { + let pending: Vec<_> = self + .asset_locks + .list_tracked_locks() + .await + .into_iter() + .filter(|lock| { + lock.operation_id.is_some() && lock.status != AssetLockStatus::DrainCompleted + }) + .collect(); + if pending.is_empty() { + return Ok(()); + } + let completed = self.asset_locks.durably_completed_drains()?; + for lock in pending { + if lock + .operation_id + .is_some_and(|operation_id| completed.contains(&operation_id)) + { + self.republish_durable_drain_completion(coordinator, lock.out_point) + .await?; + } else { self.reconcile_wallet_drain(coordinator, lock.out_point) .await?; } @@ -2191,10 +2227,20 @@ mod tests { wallet.asset_locks.list_tracked_locks().await[0].status, AssetLockStatus::DrainCompleted ); + // The stale live lock must not block a new drain meanwhile. + let other = Arc::new(AssetLockOperationContext::new([0x44; 16])); + let other_registration = wallet.register_wallet_drain_operation(other.clone()); + assert_eq!( + wallet.asset_locks.begin_wallet_drain(&other).await.unwrap(), + None + ); + drop(other_registration); + // Durable DrainCompleted already proves consumption: republication + // must not depend on a fresh, possibly unavailable, network proof. let status = wallet .shielded_wallet_drain_operation_status_with(&coordinator, [0x33; 16], |_| async { - Ok(StateTransitionProofResult::VerifiedAssetLockConsumed( - StoredAssetLockInfo::FullyConsumed, + Err(dash_sdk::Error::Generic( + "consumption proof unavailable".into(), )) }) .await diff --git a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs index a0f849fcb05..52296abc1ea 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs @@ -96,8 +96,7 @@ impl PlatformWallet { if let Some(out_point) = snapshot.out_point { // A buffered completion whose own flush failed can be committed by // a later, unrelated flush. Durable status then says Completed, but - // the live lock and activity cache were never published; re-running - // the completion gate republishes both. + // the live lock and activity cache were never published. let unpublished = snapshot.state == AssetLockOperationState::Completed && self .asset_locks @@ -109,13 +108,21 @@ impl PlatformWallet { && lock.operation_id == Some(operation_id) && lock.status != AssetLockStatus::DrainCompleted }); - if snapshot.state == AssetLockOperationState::Pending || unpublished { + if snapshot.state == AssetLockOperationState::Pending { self.reconcile_wallet_drain_with_consumption_check( coordinator, out_point, check_consumption, ) .await?; + } else if unpublished + && self + .asset_locks + .durably_completed_drains()? + .contains(&operation_id) + { + self.republish_durable_drain_completion(coordinator, out_point) + .await?; } } self.asset_locks From d64e7db9ee8a38e00fb1173852f22f5410bc9094 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 14 Oct 2026 16:49:55 +0200 Subject: [PATCH 12/24] revert(platform-wallet): drop buffered-backend-only drain recovery handling Reverts 999c2b48d1 and 9fad6e3608 except the shielded restore filter. The removed code handled races that need a buffered persistence backend whose flush fails and a later unrelated flush that commits the retained changeset: rejected-drain cleanup handed to the operation, a fresh durable read before releasing it, and republishing a completion that a later flush committed. The iOS FFI persister and the SQLite persister in immediate mode commit inline, so production clients never reach these paths. The extra state was not worth its complexity. Kept: shielded restore skips a snapshot activity row that claims a note owned by a different live row, so a bind snapshot loaded before a drain replaced a scan aggregate cannot resurrect that aggregate. That race is reachable with inline backends too. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/asset_lock/build.rs | 161 +++--------- .../src/wallet/asset_lock/operation.rs | 229 +----------------- .../src/wallet/shielded/drain_recovery.rs | 150 ++---------- .../src/wallet/shielded/wallet_drain.rs | 54 +---- 4 files changed, 60 insertions(+), 534 deletions(-) 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 dce8ad0c0bd..426e05653a2 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -30,7 +30,6 @@ use crate::wallet::platform_wallet::PlatformWalletInfo; use crate::ASSET_LOCK_FUNDING_SOURCES; use super::manager::{AssetLockManager, DEFAULT_FEE_PER_KB}; -use super::operation::RejectedDrainCleanup; use super::tracked::{AssetLockStatus, TrackedAssetLock}; use super::{AssetLockOperationContext, AssetLockOperationState}; @@ -1250,44 +1249,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); - if let Some(operation) = operation.filter(|_| removed_built_row) { - let removal = self - .persister - .store(PlatformWalletChangeSet { - asset_locks: Some(cs_untrack), - ..Default::default() - }) - .map(|()| self.persister.flush()); - let uncertain = match removal { - Err(error) => Some(error), - Ok(Err(error)) if !self.persister.store_commits_inline() => Some(error), - Ok(Err(error)) => { - // The removal is already committed. A failed host - // notification must not strand its context or inputs. - tracing::warn!(%out_point, %error, "rejected drain removed inline; flush notification failed"); - None - } - Ok(Ok(())) => None, - }; - if let Some(error) = uncertain { - // The durable removal is unknown; a buffered one may - // still commit with a later flush. Keep the context and - // input fence pending, but do NOT re-expose the row: a - // generic resume must never dispatch a row with - // uncertain durability. Hand the reservation and fence - // to the operation so an explicit retry, or a status - // read proving the row gone, can finish the cleanup. - operation.retain_rejected_cleanup(RejectedDrainCleanup { - out_point, - transaction: tx, - funding_accounts, - reservation_token, - pin: in_broadcast_pin, - }); + 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}" ))); } + if let Err(error) = self.persister.flush() { + if !self.persister.store_commits_inline() { + return Err(PlatformWalletError::Persistence(format!( + "could not durably remove rejected drain asset lock: {error}" + ))); + } + // The removal is already committed. A failed host + // notification must not strand its context or inputs. + tracing::warn!(%out_point, %error, "rejected drain removed inline; flush notification failed"); + } } else { self.queue_asset_lock_changeset(cs_untrack); } @@ -1609,13 +1593,10 @@ mod tests { before_remove: Mutex>>, restore_stored_locks: std::sync::atomic::AtomicBool, commits_inline: bool, - fail_remove_store: std::sync::atomic::AtomicBool, + fail_remove_store: bool, committed_stores: std::sync::atomic::AtomicUsize, fail_lock_store: std::sync::atomic::AtomicBool, failed_lock_stores: Mutex>, - /// Loads that still return the pre-checkpoint snapshot (no stored - /// lock rows), modelling a read that predates a concurrent write. - stale_loads: std::sync::atomic::AtomicUsize, } impl CapturingPersistence { @@ -1681,10 +1662,7 @@ mod tests { .as_ref() .is_some_and(|locks| !locks.removed.is_empty()) { - if self - .fail_remove_store - .load(std::sync::atomic::Ordering::SeqCst) - { + if self.fail_remove_store { return Err(PersistenceError::backend("simulated removal store failure")); } if let Some(hook) = self.before_remove.lock().unwrap().as_ref() { @@ -1732,18 +1710,9 @@ mod tests { }, ); } - let stale = self - .stale_loads - .fetch_update( - std::sync::atomic::Ordering::SeqCst, - std::sync::atomic::Ordering::SeqCst, - |loads| loads.checked_sub(1), - ) - .is_ok(); - if !stale - && self - .restore_stored_locks - .load(std::sync::atomic::Ordering::SeqCst) + if self + .restore_stored_locks + .load(std::sync::atomic::Ordering::SeqCst) { let wallet = state.wallets.values_mut().next().unwrap(); for cs in self @@ -4671,9 +4640,8 @@ mod tests { funded_wallet_manager(StandardAccountType::BIP44Account).await; let persistence = Arc::new(CapturingPersistence { commits_inline, - fail_remove_store: std::sync::atomic::AtomicBool::new(fail_remove_store), - // A failed removal store never reaches its flush. - fail_on_flush: (!fail_remove_store).then_some(3), + fail_remove_store, + fail_on_flush: Some(3), restore_stored_locks: std::sync::atomic::AtomicBool::new(true), ..CapturingPersistence::for_wallet(&wallet_manager, wallet_id) }); @@ -4741,81 +4709,6 @@ mod tests { } else { let other = Arc::new(super::AssetLockOperationContext::new([54; 16])); assert!(manager.begin_wallet_drain(&other).await.is_err()); - let blocked = manager - .build_asset_lock_transaction( - 1_000_000, - 0, - AssetLockFundingType::IdentityRegistration, - 0, - &signer, - ) - .await; - assert!(blocked.is_err(), "{blocked:?}"); - // An absence observed before the cleanup was retained is - // stale: the durable Built row still exists, so status must - // stay Pending and keep the inputs fenced. - persistence - .stale_loads - .store(1, std::sync::atomic::Ordering::SeqCst); - assert_eq!( - manager - .wallet_drain_operation_status([53; 16]) - .await - .unwrap() - .state, - super::AssetLockOperationState::Pending - ); - let still_blocked = manager - .build_asset_lock_transaction( - 1_000_000, - 0, - AssetLockFundingType::IdentityRegistration, - 0, - &signer, - ) - .await; - assert!(still_blocked.is_err(), "{still_blocked:?}"); - // An unrelated later flush commits a buffered removal; a - // removal store that failed outright never reached it. - persistence - .fail_remove_store - .store(false, std::sync::atomic::Ordering::SeqCst); - persistence.flush(wallet_id).unwrap(); - let buffered_removal_committed = !fail_remove_store; - // With the row provably gone, a status read alone finishes - // the in-memory cleanup. - assert_eq!( - manager - .wallet_drain_operation_status([53; 16]) - .await - .unwrap() - .state, - if buffered_removal_committed { - super::AssetLockOperationState::NotSent - } else { - super::AssetLockOperationState::Pending - } - ); - if !buffered_removal_committed { - // A same-UUID retry re-issues the removal, releases the - // inputs and reports the definite rejection. - let retry = Arc::new(super::AssetLockOperationContext::new([53; 16])); - let _registration = manager.register_wallet_drain_operation(retry.clone()); - assert!(matches!( - manager.begin_wallet_drain(&retry).await, - Err(PlatformWalletError::TransactionBroadcast(_)) - )); - } - let restored = persistence.load().unwrap(); - assert!(restored.wallets[&wallet_id] - .unused_asset_locks - .values() - .all(|locks| locks.is_empty())); - 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); - } } // Test actual input selection too: clearing only the operation // snapshot would still leave the non-expiring spend fence. @@ -4828,7 +4721,7 @@ mod tests { &signer, ) .await; - assert!(rebuild.is_ok(), "{rebuild:?}"); + assert_eq!(rebuild.is_ok(), released, "{rebuild:?}"); } } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs index 6d032770878..fee5f681a91 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs @@ -8,16 +8,11 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; -use dashcore::{OutPoint, Transaction}; -use key_wallet::account::AccountType; +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 crate::wallet::core::InBroadcastPin; -use crate::wallet::reservations::release_reservation_after_rejected_broadcast; use super::manager::AssetLockManager; use super::tracked::{AssetLockStatus, TrackedAssetLock}; @@ -56,26 +51,6 @@ pub struct AssetLockOperationContext { // could dispatch it before durability. Retain it here so explicit retries // can repeat the checkpoint after a rolled-back or uncertain write. checkpoint: Mutex>, - rejected_cleanup: Mutex>, -} - -/// Cleanup owed by a definitively rejected drain whose row removal was not -/// confirmed durable. Its inputs stay reserved and fenced until the removal is -/// known committed; only then is the operation published as NotSent. -pub(crate) struct RejectedDrainCleanup { - pub(crate) out_point: OutPoint, - pub(crate) transaction: Transaction, - pub(crate) funding_accounts: Vec, - pub(crate) reservation_token: Option, - pub(crate) pin: InBroadcastPin, -} - -impl std::fmt::Debug for RejectedDrainCleanup { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("RejectedDrainCleanup") - .field("out_point", &self.out_point) - .finish_non_exhaustive() - } } impl AssetLockOperationContext { @@ -90,43 +65,9 @@ impl AssetLockOperationContext { out_point: None, }), checkpoint: Mutex::new(None), - rejected_cleanup: Mutex::new(None), } } - pub(crate) fn retain_rejected_cleanup(&self, cleanup: RejectedDrainCleanup) { - *self - .rejected_cleanup - .lock() - .unwrap_or_else(|e| e.into_inner()) = Some(cleanup); - } - - fn take_rejected_cleanup(&self) -> Option { - let snapshot = self.snapshot(); - let mut cleanup = self - .rejected_cleanup - .lock() - .unwrap_or_else(|e| e.into_inner()); - // Only the operation's own pending outpoint can still owe this cleanup. - let owed = cleanup.as_ref().is_some_and(|cleanup| { - snapshot.state == AssetLockOperationState::Pending - && snapshot.out_point == Some(cleanup.out_point) - }); - if owed { - cleanup.take() - } else { - None - } - } - - fn rejected_cleanup_out_point(&self) -> Option { - self.rejected_cleanup - .lock() - .unwrap_or_else(|e| e.into_inner()) - .as_ref() - .map(|cleanup| cleanup.out_point) - } - pub fn snapshot(&self) -> AssetLockOperationSnapshot { *self .snapshot @@ -215,97 +156,6 @@ impl AssetLockManager { } } - /// The rejected row's removal is durable: nothing was sent and nothing can - /// be resumed, so publish NotSent and release the inputs it still holds. - async fn finish_rejected_drain_cleanup( - &self, - context: &AssetLockOperationContext, - cleanup: RejectedDrainCleanup, - ) { - // Publish before the cancellable reservation cleanup; a cancellation - // inside it settles the fence released through `settle_released_on_drop`. - context.set(AssetLockOperationState::NotSent, None); - { - // No outpoint is left to retain. Without a live lease, nothing else - // would evict the context, and it would block same-UUID retries. - let mut operations = self - .drain_operations - .lock() - .unwrap_or_else(|e| e.into_inner()); - if context.registrations.load(Ordering::SeqCst) == 0 - && operations - .get(&context.operation_id) - .is_some_and(|current| std::ptr::eq(Arc::as_ptr(current), context)) - { - operations.remove(&context.operation_id); - } - } - let RejectedDrainCleanup { - transaction, - funding_accounts, - reservation_token, - mut pin, - .. - } = cleanup; - pin.settle_released_on_drop(); - release_reservation_after_rejected_broadcast( - &self.wallet_manager, - &self.wallet_id, - &funding_accounts, - &transaction, - reservation_token, - ) - .await; - pin.settle_released(); - } - - /// Explicit-retry half of the rejected-drain cleanup: re-issue the row - /// removal (idempotent, possibly still buffered from the failed attempt), - /// then finish the cleanup. Returns true when a cleanup was finished. - #[cfg(any(feature = "shielded", test))] - async fn settle_rejected_drain( - &self, - operation_id: [u8; 16], - ) -> Result { - let Some(context) = self - .drain_operations - .lock() - .unwrap_or_else(|e| e.into_inner()) - .get(&operation_id) - .cloned() - else { - return Ok(false); - }; - let Some(out_point) = context.rejected_cleanup_out_point() else { - return Ok(false); - }; - let mut changes = AssetLockChangeSet::default(); - changes.removed.insert(out_point); - self.persister - .store(PlatformWalletChangeSet { - asset_locks: Some(changes), - ..Default::default() - }) - .map_err(|error| { - PlatformWalletError::Persistence(format!( - "could not durably remove rejected drain asset lock: {error}" - )) - })?; - if let Err(error) = self.persister.flush() { - if !self.persister.store_commits_inline() { - return Err(PlatformWalletError::Persistence(format!( - "could not durably remove rejected drain asset lock: {error}" - ))); - } - tracing::warn!(%out_point, %error, "rejected drain removed inline; flush notification failed"); - } - let Some(cleanup) = context.take_rejected_cleanup() else { - return Ok(false); - }; - self.finish_rejected_drain_cleanup(&context, cleanup).await; - Ok(true) - } - /// Retain every exact signed attempt before dispatch. Failed writes retain /// the candidate in memory too: a backend may have committed before its /// flush failed, so the next attempt must never reuse its append index. @@ -434,46 +284,6 @@ impl AssetLockManager { Ok(()) } - /// A fresh committed read without this operation's row. An unreadable - /// store or a missing wallet snapshot never counts as absence. - fn drain_row_durably_absent(&self, operation_id: [u8; 16]) -> bool { - self.persister.load().ok().is_some_and(|persisted| { - persisted - .wallets - .get(&self.wallet_id) - .is_some_and(|wallet| { - !wallet - .unused_asset_locks - .values() - .flat_map(|locks| locks.values()) - .any(|lock| lock.operation_id == Some(operation_id)) - }) - }) - } - - /// Drain operations whose committed row says `DrainCompleted`, which only - /// the full completion gate writes. - #[cfg(any(feature = "shielded", test))] - pub(crate) fn durably_completed_drains( - &self, - ) -> Result, PlatformWalletError> { - let persisted = self.persister.load().map_err(|error| { - PlatformWalletError::Persistence(format!("cannot read drain operations: {error}")) - })?; - let wallet = persisted.wallets.get(&self.wallet_id).ok_or_else(|| { - PlatformWalletError::Persistence( - "cannot read drain operations: wallet missing from durable snapshot".into(), - ) - })?; - Ok(wallet - .unused_asset_locks - .values() - .flat_map(|locks| locks.values()) - .filter(|lock| lock.status == AssetLockStatus::DrainCompleted) - .filter_map(|lock| lock.operation_id) - .collect()) - } - /// 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( @@ -523,21 +333,7 @@ impl AssetLockManager { .unwrap_or_else(|error| error.into_inner()) .get(&operation_id) .filter(|context| !ignore_context.is_some_and(|ignored| Arc::ptr_eq(context, ignored))) - .cloned(); - // A rejected drain whose buffered removal has since been committed by - // another flush has no durable or live row left. The read above may - // predate the rejection that retained this cleanup, so confirm durable - // absence with a fresh committed read before releasing its inputs. - if let Some(context) = context.as_ref().filter(|_| live.is_none()) { - if context.rejected_cleanup_out_point().is_some() - && self.drain_row_durably_absent(operation_id) - { - if let Some(cleanup) = context.take_rejected_cleanup() { - self.finish_rejected_drain_cleanup(context, cleanup).await; - } - } - } - let context = context.map(|context| context.snapshot()); + .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() { @@ -677,17 +473,6 @@ impl AssetLockManager { &self, operation: &Arc, ) -> Result, PlatformWalletError> { - // An earlier attempt under this UUID was rejected before broadcast but - // could not confirm its row removal. Finish that cleanup first and - // report the rejection: nothing was sent and its inputs are free again. - if self.settle_rejected_drain(operation.operation_id).await? { - operation.set(AssetLockOperationState::NotSent, None); - return Err(PlatformWalletError::TransactionBroadcast( - "previous drain transaction was rejected before broadcast; its inputs are \ - released and nothing was sent" - .into(), - )); - } let first_attempt = !operation.started.swap(true, Ordering::SeqCst); let previous = self .wallet_drain_operation_status_inner( @@ -752,6 +537,9 @@ impl AssetLockManager { 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( @@ -765,13 +553,6 @@ impl AssetLockManager { .filter(|lock| lock.status == AssetLockStatus::DrainCompleted) .filter_map(|lock| lock.operation_id) .collect::>(); - // A durable completion may not have been published to the live lock - // yet; its stale live status must not block a new drain. - let live_pending = live_locks.iter().any(|lock| { - lock.operation_id - .is_some_and(|operation_id| !completed.contains(&operation_id)) - && lock.status != AssetLockStatus::DrainCompleted - }); let context_pending = self .drain_operations .lock() diff --git a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs index dcee00ec518..2880d8c88b0 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs @@ -339,34 +339,33 @@ impl PlatformWallet { coordinator: &NetworkShieldedCoordinator, out_point: OutPoint, ) -> Result { - self.reconcile_wallet_drain_with_consumption_check(coordinator, out_point, |transition| { - self.bounded_drain_consumption_proof(transition) - }) - .await - } - - pub(super) async fn bounded_drain_consumption_proof( - &self, - transition: StateTransition, - ) -> Result { - // Status is read-only and must not inherit the long submission - // deadline. Bound the streaming wait as well as each request. - let timeout = Duration::from_secs(3); - let mut settings = PutSettings::default(); - settings.request_settings.connect_timeout = Some(timeout); - settings.request_settings.timeout = Some(timeout); - settings.request_settings.retries = Some(0); - settings.wait_timeout = Some(timeout); - tokio::time::timeout( - timeout, - transition - .wait_for_affected_state::(&self.sdk, Some(settings)), + self.reconcile_wallet_drain_with_consumption_check( + coordinator, + out_point, + |transition| async move { + // Status is read-only and must not inherit the long submission + // deadline. Bound the streaming wait as well as each request. + let timeout = Duration::from_secs(3); + let mut settings = PutSettings::default(); + settings.request_settings.connect_timeout = Some(timeout); + settings.request_settings.timeout = Some(timeout); + settings.request_settings.retries = Some(0); + settings.wait_timeout = Some(timeout); + tokio::time::timeout( + timeout, + transition.wait_for_affected_state::( + &self.sdk, + Some(settings), + ), + ) + .await + .map_err(|_| dash_sdk::Error::Generic("drain consumption proof timed out".into()))? + }, ) .await - .map_err(|_| dash_sdk::Error::Generic("drain consumption proof timed out".into()))? } - pub(super) async fn reconcile_wallet_drain_with_consumption_check( + async fn reconcile_wallet_drain_with_consumption_check( &self, coordinator: &NetworkShieldedCoordinator, out_point: OutPoint, @@ -492,48 +491,12 @@ impl PlatformWallet { Ok(true) } - /// Durable `DrainCompleted` is written only after the full completion gate, - /// including the consumption proof. When a later flush committed it without - /// live publication, it stands in for that proof while the live lock and - /// canonical history are republished. - pub(super) async fn republish_durable_drain_completion( - &self, - coordinator: &NetworkShieldedCoordinator, - out_point: OutPoint, - ) -> Result { - self.reconcile_wallet_drain_with_consumption_check(coordinator, out_point, |_| async { - Ok(StateTransitionProofResult::VerifiedAssetLockConsumed( - StoredAssetLockInfo::FullyConsumed, - )) - }) - .await - } - pub(super) async fn reconcile_wallet_drains( &self, coordinator: &NetworkShieldedCoordinator, ) -> Result<(), PlatformWalletError> { - let pending: Vec<_> = self - .asset_locks - .list_tracked_locks() - .await - .into_iter() - .filter(|lock| { - lock.operation_id.is_some() && lock.status != AssetLockStatus::DrainCompleted - }) - .collect(); - if pending.is_empty() { - return Ok(()); - } - let completed = self.asset_locks.durably_completed_drains()?; - for lock in pending { - if lock - .operation_id - .is_some_and(|operation_id| completed.contains(&operation_id)) - { - self.republish_durable_drain_completion(coordinator, lock.out_point) - .await?; - } else { + for lock in self.asset_locks.list_tracked_locks().await { + if lock.operation_id.is_some() && lock.status != AssetLockStatus::DrainCompleted { self.reconcile_wallet_drain(coordinator, lock.out_point) .await?; } @@ -2190,69 +2153,6 @@ mod tests { assert_eq!(live.iter().map(|row| row.amount).sum::(), 4_000); } - #[tokio::test] - async fn should_publish_deferred_completion_committed_by_a_later_flush() { - let (lock, keys, st) = evidence_fixture(); - let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); - let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; - wallet - .persist_drain_shielded_attempt(lock.out_point, &st) - .await - .unwrap(); - for (index, (account, cmx)) in outputs.iter().enumerate() { - observe( - &coordinator, - wallet.wallet_id(), - *account, - *cmx, - index as u64, - ) - .await; - } - // The buffered completion store succeeds but its own flush fails, so - // nothing is published live; an unrelated flush then commits it. - persistence.fail_flush.store(true, Ordering::SeqCst); - assert!( - reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) - .await - .is_err() - ); - persistence.fail_flush.store(false, Ordering::SeqCst); - persistence.flush(wallet.wallet_id()).unwrap(); - assert_eq!( - persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::DrainCompleted - ); - assert_ne!( - wallet.asset_locks.list_tracked_locks().await[0].status, - AssetLockStatus::DrainCompleted - ); - // The stale live lock must not block a new drain meanwhile. - let other = Arc::new(AssetLockOperationContext::new([0x44; 16])); - let other_registration = wallet.register_wallet_drain_operation(other.clone()); - assert_eq!( - wallet.asset_locks.begin_wallet_drain(&other).await.unwrap(), - None - ); - drop(other_registration); - // Durable DrainCompleted already proves consumption: republication - // must not depend on a fresh, possibly unavailable, network proof. - let status = wallet - .shielded_wallet_drain_operation_status_with(&coordinator, [0x33; 16], |_| async { - Err(dash_sdk::Error::Generic( - "consumption proof unavailable".into(), - )) - }) - .await - .unwrap(); - assert_eq!(status.state, AssetLockOperationState::Completed); - assert_eq!( - wallet.asset_locks.list_tracked_locks().await[0].status, - AssetLockStatus::DrainCompleted - ); - assert_exact_drain_history(&wallet, &coordinator, &outputs).await; - } - #[test] fn should_reject_foreign_corrupted_unsigned_or_incomplete_drain_evidence() { let (lock, keys, st) = evidence_fixture(); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs index 52296abc1ea..92749e15a3d 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs @@ -1,7 +1,6 @@ //! Read-only quote for pooled Core → Shielded drains. use std::collections::HashSet; -use std::future::Future; use std::sync::Arc; use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; @@ -9,8 +8,6 @@ use dashcore::blockdata::transaction::special_transaction::TransactionPayload; use dashcore::hashes::Hash; use dashcore::{PubkeyHash, ScriptBuf, TxOut}; use dpp::balances::credits::CREDITS_PER_DUFF; -use dpp::state_transition::proof_result::StateTransitionProofResult; -use dpp::state_transition::StateTransition; 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::{ @@ -20,10 +17,8 @@ use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoIn use crate::error::PlatformWalletError; use crate::wallet::asset_lock::manager::DEFAULT_FEE_PER_KB; -use crate::wallet::asset_lock::tracked::AssetLockStatus; use crate::wallet::asset_lock::{ AssetLockOperationContext, AssetLockOperationRegistration, AssetLockOperationSnapshot, - AssetLockOperationState, }; use crate::wallet::core::resolved_funding_accounts; use crate::wallet::PlatformWallet; @@ -65,22 +60,6 @@ impl PlatformWallet { coordinator: &super::NetworkShieldedCoordinator, operation_id: [u8; 16], ) -> Result { - self.shielded_wallet_drain_operation_status_with(coordinator, operation_id, |transition| { - self.bounded_drain_consumption_proof(transition) - }) - .await - } - - pub(super) async fn shielded_wallet_drain_operation_status_with( - &self, - coordinator: &super::NetworkShieldedCoordinator, - operation_id: [u8; 16], - check_consumption: F, - ) -> Result - where - F: FnOnce(StateTransition) -> Fut, - Fut: Future>, - { let Ok(_shield_guard) = self.shield_guard.try_lock() else { // A status read must not wait behind a network submission. The // active operation's context already provides a fail-closed view. @@ -93,36 +72,9 @@ impl PlatformWallet { .asset_locks .wallet_drain_operation_status(operation_id) .await?; - if let Some(out_point) = snapshot.out_point { - // A buffered completion whose own flush failed can be committed by - // a later, unrelated flush. Durable status then says Completed, but - // the live lock and activity cache were never published. - let unpublished = snapshot.state == AssetLockOperationState::Completed - && self - .asset_locks - .list_tracked_locks() - .await - .iter() - .any(|lock| { - lock.out_point == out_point - && lock.operation_id == Some(operation_id) - && lock.status != AssetLockStatus::DrainCompleted - }); - if snapshot.state == AssetLockOperationState::Pending { - self.reconcile_wallet_drain_with_consumption_check( - coordinator, - out_point, - check_consumption, - ) - .await?; - } else if unpublished - && self - .asset_locks - .durably_completed_drains()? - .contains(&operation_id) - { - self.republish_durable_drain_completion(coordinator, out_point) - .await?; + if snapshot.state == crate::wallet::asset_lock::AssetLockOperationState::Pending { + if let Some(out_point) = snapshot.out_point { + self.reconcile_wallet_drain(coordinator, out_point).await?; } } self.asset_locks From 9040417b3bb709b66f0682eca6c2cb9fc39857b8 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 15 Oct 2026 09:22:06 +0200 Subject: [PATCH 13/24] refactor(platform-wallet)!: complete drains from Platform's verdict instead of a signed-attempt archive A drain's asset lock is single-use and only this wallet holds its key, so Platform's answer about the outpoint already decides the outcome. The Type18 proof verifier looks the lock up by outpoint, and Drive consumes a Type18 lock in full. Matching every persisted signed attempt against scanned notes added no safety and cost a second persistence contract. Completion now happens in one place, after the Type18 submit: an accepted submit writes `DrainCompleted`; a resumed submit that Platform rejects because this exact outpoint is already consumed also writes it (an earlier attempt landed, the scan surfaces the note); anything else stays Pending. This applies to a fresh drain, a same-UUID resume and the generic exact-outpoint resume the iOS app uses. Status reads only the durable row, the live row and the active context; it never touches the network. Removed: the signed-attempt archive (`shielded_funding_attempts`, its 64-attempt / 4 MiB budget, V0/V1 replay and retired-format handling, SQLite migration V020, the persist/load/free FFI callbacks), shielded history reconciliation (`activity_removed`, the activity-removal callback, `activity_checkpoint`, strict host restore gated on it) and the `SHIELDED_FUNDING_ATTEMPTS` / `SHIELDED_ACTIVITY_RECONCILIATION` capability bits. `WALLET_DRAIN` is now `ASSET_LOCK_RECONCILIATION | ASSET_LOCK_FUNDING_INDICES | ASSET_LOCK_OPERATION_IDS`. Kept: operation UUIDs (V019), the operation registry with its checkpoint and cancellation handling, `DrainCompleted` as a terminal status that generic consumption cannot write, and the viewing-key check before Core funding. Co-Authored-By: Claude Fable 5.1 --- .../rs-platform-wallet-ffi/src/manager.rs | 129 +- .../rs-platform-wallet-ffi/src/persistence.rs | 1009 -------- .../src/shielded_wallet_drain.rs | 8 +- packages/rs-platform-wallet-storage/SCHEMA.md | 1 + .../V020__shielded_funding_attempts.rs | 5 - .../src/sqlite/persister.rs | 5 +- .../src/sqlite/schema/asset_locks.rs | 185 +- .../integration/sqlite_asset_locks_filter.rs | 82 +- .../sqlite_blob_roundtrip_coverage.rs | 1 - .../integration/sqlite_load_reconstruction.rs | 2 - .../integration/sqlite_persist_roundtrip.rs | 3 - .../tests/integration/sqlite_recovery_mode.rs | 1 - .../integration/sqlite_schema_pinning.rs | 4 +- .../sqlite_shielded_viewing_keys.rs | 6 +- .../sqlite_structural_hardening.rs | 1 - .../tests/integration/sqlite_version_bump.rs | 1 - .../src/changeset/changeset.rs | 66 +- .../src/changeset/core_bridge.rs | 1 - .../src/changeset/persistence_capabilities.rs | 35 +- .../src/changeset/shielded_changeset.rs | 71 +- .../rs-platform-wallet/src/wallet/apply.rs | 56 +- .../src/wallet/asset_lock/build.rs | 102 - .../src/wallet/asset_lock/mod.rs | 2 +- .../src/wallet/asset_lock/operation.rs | 127 +- .../src/wallet/asset_lock/orchestration.rs | 38 +- .../src/wallet/asset_lock/sync/proof.rs | 2 - .../wallet/asset_lock/sync/reconstruction.rs | 3 - .../src/wallet/asset_lock/sync/recovery.rs | 12 - .../src/wallet/asset_lock/sync/tracking.rs | 9 + .../src/wallet/asset_lock/tracked.rs | 12 +- .../src/wallet/shielded/activity.rs | 30 +- .../src/wallet/shielded/coordinator.rs | 51 +- .../src/wallet/shielded/drain_recovery.rs | 2218 ----------------- .../src/wallet/shielded/file_store.rs | 7 - .../wallet/shielded/fund_from_asset_lock.rs | 490 +++- .../src/wallet/shielded/mod.rs | 3 +- .../src/wallet/shielded/store.rs | 14 - .../src/wallet/shielded/wallet_drain.rs | 20 +- .../src/wallet/shielded/wallet_drain_tests.rs | 712 ++++++ 39 files changed, 1254 insertions(+), 4270 deletions(-) delete mode 100644 packages/rs-platform-wallet-storage/migrations/V020__shielded_funding_attempts.rs delete mode 100644 packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs create mode 100644 packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 9c0dd332a34..e0472260490 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,14 +8,13 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, FreeShieldedFundingAttemptsFn, FreeTrackedMasternodesFn, - LoadAssetLockOperationIdFn, LoadIdentityBalanceBlockTimeFn, LoadShieldedFundingAttemptsFn, - LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, PersistDpnsNameStatesFn, - PersistIdentityBalanceBlockTimeFn, PersistShieldedFundingAttemptFn, - PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, - PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, PersistenceCallbacks, - PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, - RemoveShieldedActivityFn, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, + FFIPersister, FreeTrackedMasternodesFn, LoadAssetLockOperationIdFn, + LoadIdentityBalanceBlockTimeFn, LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, + PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, + PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn, + PersistWalletChangesetUtxoVerdictsFn, PersistenceCallbacks, PersistenceCallbacksExtension, + PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, + PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; use crate::types::{FFINetwork, Network}; @@ -287,19 +286,6 @@ unsafe fn persistence_extension_callbacks( on_load_asset_lock_operation_id_fn, LoadAssetLockOperationIdFn ), - persist_shielded_funding_attempt: slot!( - on_persist_shielded_funding_attempt_fn, - PersistShieldedFundingAttemptFn - ), - load_shielded_funding_attempts: slot!( - on_load_shielded_funding_attempts_fn, - LoadShieldedFundingAttemptsFn - ), - load_shielded_funding_attempts_free: slot!( - on_load_shielded_funding_attempts_free_fn, - FreeShieldedFundingAttemptsFn - ), - remove_shielded_activity: slot!(on_remove_shielded_activity_fn, RemoveShieldedActivityFn), } } @@ -1372,107 +1358,6 @@ mod tests { assert!(parsed.load_asset_lock_operation_id.is_none()); } - #[test] - fn should_size_gate_shielded_funding_attempt_slots_independently() { - unsafe extern "C" fn persist( - _: *mut c_void, - _: *const u8, - _: *const u8, - _: *const u8, - _: u32, - _: *const u8, - _: usize, - ) -> i32 { - 0 - } - unsafe extern "C" fn load( - _: *mut c_void, - _: *const u8, - _: *const u8, - _: *mut *mut crate::persistence::ShieldedFundingAttemptFFI, - _: *mut usize, - ) -> i32 { - 0 - } - unsafe extern "C" fn free( - _: *mut c_void, - _: *mut crate::persistence::ShieldedFundingAttemptFFI, - _: usize, - ) { - } - let mut ext = PersistenceCallbacksExtension { - on_persist_shielded_funding_attempt_fn: Some(persist), - on_load_shielded_funding_attempts_fn: Some(load), - on_load_shielded_funding_attempts_free_fn: Some(free), - ..Default::default() - }; - for (size, expected) in [ - ( - std::mem::size_of::(), - [true, true, true], - ), - ( - std::mem::offset_of!( - PersistenceCallbacksExtension, - on_load_shielded_funding_attempts_free_fn - ), - [true, true, false], - ), - ( - std::mem::offset_of!( - PersistenceCallbacksExtension, - on_load_shielded_funding_attempts_fn - ), - [true, false, false], - ), - ( - std::mem::offset_of!( - PersistenceCallbacksExtension, - on_persist_shielded_funding_attempt_fn - ), - [false, false, false], - ), - ] { - ext.struct_size = size; - let parsed = unsafe { persistence_extension_callbacks(&ext) }; - assert_eq!( - [ - parsed.persist_shielded_funding_attempt.is_some(), - parsed.load_shielded_funding_attempts.is_some(), - parsed.load_shielded_funding_attempts_free.is_some() - ], - expected - ); - } - } - - #[test] - fn should_size_gate_shielded_activity_removal_slot() { - unsafe extern "C" fn remove( - _: *mut c_void, - _: *const u8, - _: u32, - _: *const u8, - _: usize, - ) -> i32 { - 0 - } - let mut ext = PersistenceCallbacksExtension { - on_remove_shielded_activity_fn: Some(remove), - ..Default::default() - }; - assert!(unsafe { persistence_extension_callbacks(&ext) } - .remove_shielded_activity - .is_some()); - ext.struct_size = std::mem::offset_of!( - PersistenceCallbacksExtension, - on_remove_shielded_activity_fn - ); - assert!(unsafe { persistence_extension_callbacks(&ext) } - .remove_shielded_activity - .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 9c96f8beae8..c3fe7a8b55e 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -36,7 +36,6 @@ use platform_wallet::changeset::{ PlatformWalletChangeSet, PlatformWalletPersistence, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, PERSISTENCE_CAPABILITIES_VERSION, }; -use platform_wallet::wallet::asset_lock::{MAX_DRAIN_ATTEMPTS, MAX_DRAIN_ATTEMPT_BYTES}; use platform_wallet::wallet::platform_wallet::WalletId; #[cfg(feature = "shielded")] use platform_wallet::wallet::shielded::ShieldedActivityStatus; @@ -157,9 +156,6 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_MASTERNODES: u64 = 1 << 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; -pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_SHIELDED_FUNDING_ATTEMPTS: u64 = 1 << 14; -/// Atomic shielded history replacement and strict scan/history restore. -pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_SHIELDED_ACTIVITY_RECONCILIATION: u64 = 1 << 15; /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately /// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by @@ -303,53 +299,6 @@ pub type LoadAssetLockOperationIdFn = unsafe extern "C" fn( out_operation_id: *mut u8, ) -> i32; -/// Host-owned exact signed transition bytes. The array order is the attempt index. -#[repr(C)] -#[derive(Clone, Copy)] -pub struct ShieldedFundingAttemptFFI { - pub bytes: *const u8, - pub bytes_len: usize, -} - -/// Remove exact activity rows. `entry_ids` borrows `count * 32` bytes for -/// this callback only; host stages deletion in the current atomic round. -pub type RemoveShieldedActivityFn = unsafe extern "C" fn( - context: *mut c_void, - wallet_id: *const u8, - account_index: u32, - entry_ids: *const u8, - count: usize, -) -> i32; - -/// Persist one append-only attempt inside the active atomic changeset. An existing -/// index must contain exactly these bytes; an absent index must equal the count. -pub type PersistShieldedFundingAttemptFn = unsafe extern "C" fn( - context: *mut c_void, - wallet_id: *const u8, - out_point: *const u8, - operation_id: *const u8, - attempt_index: u32, - bytes: *const u8, - bytes_len: usize, -) -> i32; - -/// Load all attempts, including consumed tombstones. Missing rows/read errors fail. -/// Ownership transfers to Rust only on success (return value zero). On failure, -/// the host retains ownership and must release any buffers it allocated. -pub type LoadShieldedFundingAttemptsFn = unsafe extern "C" fn( - context: *mut c_void, - wallet_id: *const u8, - out_point: *const u8, - out_entries: *mut *mut ShieldedFundingAttemptFFI, - out_count: *mut usize, -) -> i32; - -pub type FreeShieldedFundingAttemptsFn = unsafe extern "C" fn( - context: *mut c_void, - entries: *mut ShieldedFundingAttemptFFI, - count: usize, -); - /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -500,44 +449,6 @@ pub struct PersistenceCallbacksExtension { out_operation_id: *mut u8, ) -> i32, >, - pub on_persist_shielded_funding_attempt_fn: Option< - unsafe extern "C" fn( - context: *mut c_void, - wallet_id: *const u8, - out_point: *const u8, - operation_id: *const u8, - attempt_index: u32, - bytes: *const u8, - bytes_len: usize, - ) -> i32, - >, - pub on_load_shielded_funding_attempts_fn: Option< - unsafe extern "C" fn( - context: *mut c_void, - wallet_id: *const u8, - out_point: *const u8, - out_entries: *mut *mut ShieldedFundingAttemptFFI, - out_count: *mut usize, - ) -> i32, - >, - pub on_load_shielded_funding_attempts_free_fn: Option< - unsafe extern "C" fn( - context: *mut c_void, - entries: *mut ShieldedFundingAttemptFFI, - count: usize, - ), - >, - /// Additive removal half of atomic activity replacement; legacy row strides - /// remain unchanged. IDs are packed as `count` consecutive 32-byte values. - pub on_remove_shielded_activity_fn: Option< - unsafe extern "C" fn( - context: *mut c_void, - wallet_id: *const u8, - account_index: u32, - entry_ids: *const u8, - count: usize, - ) -> i32, - >, } impl Default for PersistenceCallbacksExtension { @@ -557,10 +468,6 @@ impl Default for PersistenceCallbacksExtension { on_load_identity_balance_block_time_fn: None, on_persist_asset_lock_operation_id_fn: None, on_load_asset_lock_operation_id_fn: None, - on_persist_shielded_funding_attempt_fn: None, - on_load_shielded_funding_attempts_fn: None, - on_load_shielded_funding_attempts_free_fn: None, - on_remove_shielded_activity_fn: None, } } } @@ -581,10 +488,6 @@ pub struct PersistenceExtensionCallbacks { pub load_identity_balance_block_time: Option, pub persist_asset_lock_operation_id: Option, pub load_asset_lock_operation_id: Option, - pub persist_shielded_funding_attempt: Option, - pub load_shielded_funding_attempts: Option, - pub load_shielded_funding_attempts_free: Option, - pub remove_shielded_activity: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1710,59 +1613,6 @@ impl FFIPersister { // tombstones and fail-closed loads. Legacy hosts do not set this bit. capabilities = capabilities.union(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS); } - if capabilities.contains(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS) - && self - .tracked_masternodes_callbacks - .persist_shielded_funding_attempt - .is_some() - && self - .tracked_masternodes_callbacks - .load_shielded_funding_attempts - .is_some() - && self - .tracked_masternodes_callbacks - .load_shielded_funding_attempts_free - .is_some() - { - capabilities = capabilities.union(PersistenceCapabilities::SHIELDED_FUNDING_ATTEMPTS); - } - #[cfg(feature = "shielded")] - if self.callbacks.on_persist_shielded_activity_fn.is_some() - && self.callbacks.on_load_shielded_activity_fn.is_some() - && self.callbacks.on_load_shielded_activity_free_fn.is_some() - && self - .tracked_masternodes_callbacks - .remove_shielded_activity - .is_some() - && self.callbacks.on_persist_shielded_notes_fn.is_some() - && self - .callbacks - .on_persist_shielded_nullifiers_spent_fn - .is_some() - && self.callbacks.on_load_shielded_notes_fn.is_some() - && self.callbacks.on_load_shielded_notes_free_fn.is_some() - && self - .callbacks - .on_persist_shielded_outgoing_notes_fn - .is_some() - && self.callbacks.on_load_shielded_outgoing_notes_fn.is_some() - && self - .callbacks - .on_load_shielded_outgoing_notes_free_fn - .is_some() - && self - .callbacks - .on_persist_shielded_synced_indices_fn - .is_some() - && self.callbacks.on_load_shielded_sync_states_fn.is_some() - && self - .callbacks - .on_load_shielded_sync_states_free_fn - .is_some() - { - capabilities = - capabilities.union(PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION); - } if self .tracked_masternodes_callbacks .persist_tracked_masternodes @@ -2680,42 +2530,6 @@ impl PlatformWalletPersistence for FFIPersister { outcome.record(rc); } } - if !entry.shielded_funding_attempts.is_empty() { - let (Some(operation_id), Some(cb)) = ( - entry.operation_id, - self.tracked_masternodes_callbacks - .persist_shielded_funding_attempt, - ) else { - outcome.record(1); - continue; - }; - let out_point = outpoint_to_bytes(&entry.out_point); - for (index, attempt) in entry.shielded_funding_attempts.iter().enumerate() { - let Ok(index) = u32::try_from(index) else { - outcome.record(1); - break; - }; - if attempt.is_empty() { - outcome.record(1); - break; - } - let rc = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - out_point.as_ptr(), - operation_id.as_ptr(), - index, - attempt.as_ptr(), - attempt.len(), - ) - }; - if rc != 0 { - outcome.record(rc); - break; - } - } - } } } @@ -3211,28 +3025,6 @@ impl PlatformWalletPersistence for FFIPersister { } } - // Removals precede upserts in the same host transaction. Missing - // support is an error, never an accepted-but-ignored replacement. - for (id, ids) in &shielded_cs.activity_removed { - if let Some(cb) = self.tracked_masternodes_callbacks.remove_shielded_activity { - let packed: Vec = ids.iter().flat_map(|id| id.iter().copied()).collect(); - let result = unsafe { - cb( - self.callbacks.context, - id.wallet_id.as_ptr(), - id.account_index, - packed.as_ptr(), - ids.len(), - ) - }; - if result != 0 { - outcome.record(result); - } - } else { - outcome.record(-1); - } - } - // 6) activity entries (derived activity log). The variable- // length fields (counterparty / memo / cmx + nullifier // arrays) borrow into `backing`, a Vec of owned byte @@ -3353,11 +3145,6 @@ impl PlatformWalletPersistence for FFIPersister { // `rows` and `entries` drop here, after the callback // has copied everything it needs. drop(rows); - } else if self - .declared_capabilities - .contains(PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION) - { - outcome.record(-1); } } } @@ -3529,24 +3316,6 @@ impl PlatformWalletPersistence for FFIPersister { &mut wallet_state.unused_asset_locks, )?; } - if let Some(cb) = self - .tracked_masternodes_callbacks - .load_shielded_funding_attempts - { - let free = self - .tracked_masternodes_callbacks - .load_shielded_funding_attempts_free - .ok_or_else(|| { - PersistenceError::backend("missing shielded funding attempts free callback") - })?; - load_shielded_funding_attempts( - cb, - free, - 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() { @@ -3635,10 +3404,6 @@ impl PlatformWalletPersistence for FFIPersister { )); } - let strict_activity = self - .declared_capabilities - .contains(PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION); - // 1) notes if let Some(load_notes) = self.callbacks.on_load_shielded_notes_fn { let mut notes_ptr: *const ShieldedNoteRestoreFFI = std::ptr::null(); @@ -3676,20 +3441,10 @@ impl PlatformWalletPersistence for FFIPersister { entries: notes_ptr, count: notes_count, }; - if strict_activity && notes_ptr.is_null() && notes_count > 0 { - return Err(PersistenceError::backend( - "shielded restore returned a null array with nonzero count", - )); - } if !notes_ptr.is_null() && notes_count > 0 { let slice = unsafe { slice::from_raw_parts(notes_ptr, notes_count) }; for ffi in slice { if ffi.note_data_ptr.is_null() || ffi.note_data_len == 0 { - if strict_activity { - return Err(PersistenceError::backend( - "missing shielded note bytes", - )); - } continue; } let note_data = unsafe { @@ -3754,11 +3509,6 @@ impl PlatformWalletPersistence for FFIPersister { entries: out_ptr, count: out_count, }; - if strict_activity && out_ptr.is_null() && out_count > 0 { - return Err(PersistenceError::backend( - "shielded restore returned a null array with nonzero count", - )); - } if !out_ptr.is_null() && out_count > 0 { let slice = unsafe { slice::from_raw_parts(out_ptr, out_count) }; for ffi in slice { @@ -3823,11 +3573,6 @@ impl PlatformWalletPersistence for FFIPersister { entries: states_ptr, count: states_count, }; - if strict_activity && states_ptr.is_null() && states_count > 0 { - return Err(PersistenceError::backend( - "shielded sync restore returned a null array with nonzero count", - )); - } if !states_ptr.is_null() && states_count > 0 { let slice = unsafe { slice::from_raw_parts(states_ptr, states_count) }; for ffi in slice { @@ -3887,34 +3632,9 @@ impl PlatformWalletPersistence for FFIPersister { entries: act_ptr, count: act_count, }; - if strict_activity && act_ptr.is_null() && act_count > 0 { - return Err(PersistenceError::backend( - "shielded restore returned a null array with nonzero count", - )); - } if !act_ptr.is_null() && act_count > 0 { let slice = unsafe { slice::from_raw_parts(act_ptr, act_count) }; for ffi in slice { - if strict_activity - && (ffi.kind_tag > 9 - || ffi.direction > 2 - || ffi.status > 3 - || ffi.note_cmxs_count > (isize::MAX as usize) / 32 - || ffi.spent_nullifiers_count > (isize::MAX as usize) / 32 - || ffi.has_fee > 1 - || ffi.has_block_height > 1 - || ffi.has_min_note_position > 1 - || ffi.has_identity_id > 1 - || (ffi.note_cmxs_count > 0 && ffi.note_cmxs_ptr.is_null()) - || (ffi.spent_nullifiers_count > 0 - && ffi.spent_nullifiers_ptr.is_null()) - || (ffi.counterparty_len > 0 && ffi.counterparty_ptr.is_null()) - || (ffi.memo_len > 0 && ffi.memo_ptr.is_null())) - { - return Err(PersistenceError::backend( - "malformed shielded activity row", - )); - } let kind = match ffi.kind_tag { 0 => ShieldedActivityKind::Shield, 1 => ShieldedActivityKind::ShieldFromAssetLock, @@ -6313,89 +6033,6 @@ fn load_asset_lock_operation_ids( Ok(()) } -/// Own successfully returned buffers until copying finishes, including decode -/// failures. A failed host callback retains ownership of its output buffers. -fn load_shielded_funding_attempts( - callback: LoadShieldedFundingAttemptsFn, - free: FreeShieldedFundingAttemptsFn, - context: *mut c_void, - wallet_id: &WalletId, - locks: &mut BTreeMap>, -) -> Result<(), PersistenceError> { - struct Guard { - free: FreeShieldedFundingAttemptsFn, - context: *mut c_void, - entries: *mut ShieldedFundingAttemptFFI, - count: usize, - } - impl Drop for Guard { - fn drop(&mut self) { - unsafe { (self.free)(self.context, self.entries, self.count) }; - } - } - for lock in locks.values_mut().flat_map(|locks| locks.values_mut()) { - let out_point = outpoint_to_bytes(&lock.out_point); - let mut entries = std::ptr::null_mut(); - let mut count = 0; - let rc = unsafe { - callback( - context, - wallet_id.as_ptr(), - out_point.as_ptr(), - &mut entries, - &mut count, - ) - }; - if rc != 0 { - return Err(persist_callback_error( - rc, - "Loading shielded funding attempts failed", - )); - } - let _guard = Guard { - free, - context, - entries, - count, - }; - if count > MAX_DRAIN_ATTEMPTS - || (count != 0 && (entries.is_null() || lock.operation_id.is_none())) - { - return Err(PersistenceError::backend( - "invalid shielded funding attempt rows", - )); - } - let rows = if count == 0 { - &[] - } else { - unsafe { slice::from_raw_parts(entries, count) } - }; - let mut total_bytes = 0usize; - // Validate all lengths before constructing any payload slice or copying bytes. - for row in rows { - total_bytes = total_bytes.checked_add(row.bytes_len).ok_or_else(|| { - PersistenceError::backend("shielded funding attempt bytes overflow") - })?; - if total_bytes > MAX_DRAIN_ATTEMPT_BYTES { - return Err(PersistenceError::backend( - "shielded funding attempt archive exceeds budget", - )); - } - if row.bytes.is_null() || row.bytes_len == 0 || row.bytes_len > isize::MAX as usize { - return Err(PersistenceError::backend( - "invalid signed shielded funding attempt", - )); - } - } - let mut attempts = Vec::with_capacity(count); - for row in rows { - attempts.push(unsafe { slice::from_raw_parts(row.bytes, row.bytes_len) }.to_vec()); - } - lock.shielded_funding_attempts = attempts; - } - 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 @@ -6499,7 +6136,6 @@ fn build_unused_asset_locks( identity_index: spec.identity_index, amount: spec.amount_duffs, operation_id: None, - shielded_funding_attempts: Vec::new(), status, proof, }; @@ -9033,7 +8669,6 @@ mod tests { status: status.clone(), proof: None, operation_id, - shielded_funding_attempts: Vec::new(), }; let (entries, _storage) = build_asset_lock_entries(&[&entry]); let wallet = WalletRestoreEntryFFI { @@ -9185,279 +8820,6 @@ mod tests { .contains(required)); } - #[test] - fn should_require_complete_shielded_funding_attempt_persistence_attestation() { - unsafe extern "C" fn persist_id( - _: *mut c_void, - _: *const u8, - _: *const u8, - _: *const u8, - ) -> i32 { - 0 - } - unsafe extern "C" fn load_id( - _: *mut c_void, - _: *const u8, - _: *const u8, - _: *mut bool, - _: *mut u8, - ) -> i32 { - 0 - } - unsafe extern "C" fn persist( - _: *mut c_void, - _: *const u8, - _: *const u8, - _: *const u8, - _: u32, - _: *const u8, - _: usize, - ) -> i32 { - 0 - } - unsafe extern "C" fn load( - _: *mut c_void, - _: *const u8, - _: *const u8, - _: *mut *mut ShieldedFundingAttemptFFI, - _: *mut usize, - ) -> i32 { - 0 - } - unsafe extern "C" fn free(_: *mut c_void, _: *mut ShieldedFundingAttemptFFI, _: usize) {} - 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 extensions = PersistenceExtensionCallbacks { - persist_asset_lock_operation_id: Some(persist_id), - load_asset_lock_operation_id: Some(load_id), - persist_shielded_funding_attempt: Some(persist), - load_shielded_funding_attempts: Some(load), - load_shielded_funding_attempts_free: Some(free), - ..Default::default() - }; - let required = PersistenceCapabilities::SHIELDED_FUNDING_ATTEMPTS; - let capabilities = |declaration, extensions| { - FFIPersister::new_with_persistence_capabilities_and_extensions( - callbacks(), - declaration, - extensions, - ) - .persistence_capabilities() - }; - assert!(capabilities(required, extensions).contains(required)); - assert!(!capabilities(PersistenceCapabilities::NONE, extensions).contains(required)); - for incomplete in [ - PersistenceExtensionCallbacks { - persist_shielded_funding_attempt: None, - ..extensions - }, - PersistenceExtensionCallbacks { - load_shielded_funding_attempts: None, - ..extensions - }, - PersistenceExtensionCallbacks { - load_shielded_funding_attempts_free: None, - ..extensions - }, - PersistenceExtensionCallbacks { - load_asset_lock_operation_id: None, - ..extensions - }, - ] { - assert!(!capabilities(required, incomplete).contains(required)); - } - } - - #[test] - fn should_restore_signed_shielded_funding_attempts_and_free_on_failure() { - use dashcore::hashes::Hash; - struct Host { - rows: Vec, - freed: bool, - fail: bool, - persisted: Vec>, - committed: bool, - } - unsafe extern "C" fn load( - context: *mut c_void, - _: *const u8, - _: *const u8, - entries: *mut *mut ShieldedFundingAttemptFFI, - count: *mut usize, - ) -> i32 { - let host = &mut *context.cast::(); - *entries = host.rows.as_mut_ptr(); - *count = host.rows.len(); - if host.fail { - -1 - } else { - 0 - } - } - unsafe extern "C" fn free( - context: *mut c_void, - _: *mut ShieldedFundingAttemptFFI, - _: usize, - ) { - (*context.cast::()).freed = true; - } - let attempts = [vec![18, 1, 2, 3], vec![18, 4, 5, 6, 7]]; - let mut host = Host { - rows: attempts - .iter() - .map(|bytes| ShieldedFundingAttemptFFI { - bytes: bytes.as_ptr(), - bytes_len: bytes.len(), - }) - .collect(), - freed: false, - fail: false, - persisted: Vec::new(), - committed: false, - }; - let out_point = dashcore::OutPoint { - txid: dashcore::Txid::from_byte_array([3; 32]), - vout: 0, - }; - let tracked = platform_wallet::TrackedAssetLock { - out_point, transaction: dashcore::Transaction { version: 3, lock_time: 0, - input: vec![], output: vec![], special_transaction_payload: None }, - account_index: 0, funding_type: key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType::AssetLockShieldedAddressTopUp, - identity_index: 0, amount: 1000, status: platform_wallet::AssetLockStatus::Consumed, - proof: None, operation_id: Some([7; 16]), shielded_funding_attempts: Vec::new(), - }; - let mut locks = [(0, [(out_point, tracked)].into_iter().collect())] - .into_iter() - .collect(); - load_shielded_funding_attempts( - load, - free, - std::ptr::addr_of_mut!(host).cast(), - &[42; 32], - &mut locks, - ) - .unwrap(); - assert!(host.freed); - assert_eq!(locks[&0][&out_point].shielded_funding_attempts, attempts); - unsafe extern "C" fn persist_id( - _: *mut c_void, - _: *const u8, - _: *const u8, - _: *const u8, - ) -> i32 { - 0 - } - unsafe extern "C" fn persist_attempt( - context: *mut c_void, - _: *const u8, - _: *const u8, - operation: *const u8, - index: u32, - bytes: *const u8, - len: usize, - ) -> i32 { - let host = &mut *context.cast::(); - assert_eq!(slice::from_raw_parts(operation, 16), &[7; 16]); - if host.fail { - return -1; - } - assert_eq!(index as usize, host.persisted.len()); - host.persisted - .push(slice::from_raw_parts(bytes, len).to_vec()); - 0 - } - unsafe extern "C" fn end(context: *mut c_void, _: *const u8, success: bool) -> i32 { - (*context.cast::()).committed = success; - 0 - } - let writer = FFIPersister::new_with_persistence_capabilities_and_extensions( - PersistenceCallbacks { - context: std::ptr::addr_of_mut!(host).cast(), - on_persist_asset_locks_fn: Some(noop_asset_locks), - on_changeset_end_fn: Some(end), - ..Default::default() - }, - PersistenceCapabilities::NONE, - PersistenceExtensionCallbacks { - persist_asset_lock_operation_id: Some(persist_id), - persist_shielded_funding_attempt: Some(persist_attempt), - ..Default::default() - }, - ); - let changeset = platform_wallet::changeset::PlatformWalletChangeSet { - asset_locks: Some(platform_wallet::changeset::AssetLockChangeSet { - asset_locks: [( - out_point, - platform_wallet::changeset::AssetLockEntry::from(&locks[&0][&out_point]), - )] - .into_iter() - .collect(), - ..Default::default() - }), - ..Default::default() - }; - writer.store([42; 32], changeset.clone()).unwrap(); - assert_eq!(host.persisted, attempts); - assert!(host.committed); - host.fail = true; - assert!(writer.store([42; 32], changeset).is_err()); - assert!(!host.committed); - host.freed = false; - host.fail = true; - assert!(load_shielded_funding_attempts( - load, - free, - std::ptr::addr_of_mut!(host).cast(), - &[42; 32], - &mut locks - ) - .is_err()); - assert!(!host.freed, "failed callbacks retain buffer ownership"); - host.freed = false; - host.fail = false; - host.rows[0].bytes = std::ptr::null(); - assert!(load_shielded_funding_attempts( - load, - free, - std::ptr::addr_of_mut!(host).cast(), - &[42; 32], - &mut locks - ) - .is_err()); - assert!(host.freed); - // Invalid addresses are deliberate: budget validation must finish before - // a payload pointer is dereferenced, including cumulative overflow. - for lengths in [ - vec![1; MAX_DRAIN_ATTEMPTS + 1], - vec![MAX_DRAIN_ATTEMPT_BYTES + 1], - vec![MAX_DRAIN_ATTEMPT_BYTES, 1], - vec![usize::MAX, 1], - ] { - host.rows = lengths - .into_iter() - .map(|bytes_len| ShieldedFundingAttemptFFI { - bytes: std::ptr::dangling(), - bytes_len, - }) - .collect(); - host.freed = false; - assert!(load_shielded_funding_attempts( - load, - free, - std::ptr::addr_of_mut!(host).cast(), - &[42; 32], - &mut locks - ) - .is_err()); - assert!(host.freed); - assert_eq!(locks[&0][&out_point].shielded_funding_attempts, attempts); - } - } - #[test] fn should_restore_proven_drain_completion_discriminant() { assert_eq!( @@ -9750,46 +9112,6 @@ mod tests { PersistenceCallbacksExtension, on_load_asset_lock_operation_id_fn ) + std::mem::size_of::>(), - std::mem::offset_of!( - PersistenceCallbacksExtension, - on_persist_shielded_funding_attempt_fn - ) - ); - assert_eq!( - std::mem::offset_of!( - PersistenceCallbacksExtension, - on_persist_shielded_funding_attempt_fn - ) + std::mem::size_of::>(), - std::mem::offset_of!( - PersistenceCallbacksExtension, - on_load_shielded_funding_attempts_fn - ) - ); - assert_eq!( - std::mem::offset_of!( - PersistenceCallbacksExtension, - on_load_shielded_funding_attempts_fn - ) + std::mem::size_of::>(), - std::mem::offset_of!( - PersistenceCallbacksExtension, - on_load_shielded_funding_attempts_free_fn - ) - ); - assert_eq!( - std::mem::offset_of!( - PersistenceCallbacksExtension, - on_load_shielded_funding_attempts_free_fn - ) + std::mem::size_of::>(), - std::mem::offset_of!( - PersistenceCallbacksExtension, - on_remove_shielded_activity_fn - ) - ); - assert_eq!( - std::mem::offset_of!( - PersistenceCallbacksExtension, - on_remove_shielded_activity_fn - ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( @@ -9852,14 +9174,6 @@ mod tests { PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS, PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS.bits() ); - assert_eq!( - PLATFORM_WALLET_PERSISTENCE_CAPABILITY_SHIELDED_FUNDING_ATTEMPTS, - PersistenceCapabilities::SHIELDED_FUNDING_ATTEMPTS.bits() - ); - assert_eq!( - PLATFORM_WALLET_PERSISTENCE_CAPABILITY_SHIELDED_ACTIVITY_RECONCILIATION, - PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION.bits() - ); assert_eq!( PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ACCOUNT_ADDRESS_POOLS, PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_FUNDING_INDICES @@ -10034,328 +9348,6 @@ mod tests { assert_eq!(sink.calls.load(Ordering::SeqCst), 1); } - #[cfg(feature = "shielded")] - #[test] - fn should_require_every_activity_reconciliation_callback() { - unsafe extern "C" fn persist( - _: *mut c_void, - _: *const u8, - _: *const T, - _: usize, - ) -> i32 { - 0 - } - unsafe extern "C" fn load(_: *mut c_void, _: *mut *const T, _: *mut usize) -> i32 { - 0 - } - unsafe extern "C" fn free(_: *mut c_void, _: *const T, _: usize) {} - unsafe extern "C" fn remove( - _: *mut c_void, - _: *const u8, - _: u32, - _: *const u8, - _: usize, - ) -> i32 { - 0 - } - let callbacks = || PersistenceCallbacks { - on_persist_shielded_activity_fn: Some(persist), - on_load_shielded_activity_fn: Some(load), - on_load_shielded_activity_free_fn: Some(free), - on_persist_shielded_notes_fn: Some(persist), - on_persist_shielded_nullifiers_spent_fn: Some(persist), - on_load_shielded_notes_fn: Some(load), - on_load_shielded_notes_free_fn: Some(free), - on_persist_shielded_outgoing_notes_fn: Some(persist), - on_load_shielded_outgoing_notes_fn: Some(load), - on_load_shielded_outgoing_notes_free_fn: Some(free), - on_persist_shielded_synced_indices_fn: Some(persist), - on_load_shielded_sync_states_fn: Some(load), - on_load_shielded_sync_states_free_fn: Some(free), - ..Default::default() - }; - let extension = PersistenceExtensionCallbacks { - remove_shielded_activity: Some(remove), - ..Default::default() - }; - let bit = PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION; - let capability = |cb, ext, declaration| { - FFIPersister::new_with_persistence_capabilities_and_extensions(cb, declaration, ext) - .persistence_capabilities() - }; - assert!(capability(callbacks(), extension, bit).contains(bit)); - assert!(!capability(callbacks(), extension, PersistenceCapabilities::NONE).contains(bit)); - assert!( - !capability(callbacks(), PersistenceExtensionCallbacks::default(), bit).contains(bit) - ); - macro_rules! missing { - ($field:ident) => {{ - let mut cb = callbacks(); - cb.$field = None; - assert!( - !capability(cb, extension, bit).contains(bit), - stringify!($field) - ); - }}; - } - missing!(on_persist_shielded_activity_fn); - missing!(on_load_shielded_activity_fn); - missing!(on_load_shielded_activity_free_fn); - missing!(on_persist_shielded_notes_fn); - missing!(on_persist_shielded_nullifiers_spent_fn); - missing!(on_load_shielded_notes_fn); - missing!(on_load_shielded_notes_free_fn); - missing!(on_persist_shielded_outgoing_notes_fn); - missing!(on_load_shielded_outgoing_notes_fn); - missing!(on_load_shielded_outgoing_notes_free_fn); - missing!(on_persist_shielded_synced_indices_fn); - missing!(on_load_shielded_sync_states_fn); - missing!(on_load_shielded_sync_states_free_fn); - } - - #[cfg(feature = "shielded")] - #[test] - fn should_abort_activity_replacement_round_on_either_callback_failure() { - use crate::shielded_persistence::ShieldedActivityFFI; - use platform_wallet::wallet::shielded::{ - ShieldedActivityEntry, ShieldedActivityKind, ShieldedActivityStatus, ShieldedDirection, - SubwalletId, - }; - #[derive(Default)] - struct Host { - calls: Vec<&'static str>, - failure: u8, - committed: bool, - } - unsafe extern "C" fn remove( - ctx: *mut c_void, - wallet: *const u8, - account: u32, - ids: *const u8, - count: usize, - ) -> i32 { - assert_eq!(slice::from_raw_parts(wallet, 32), &[1; 32]); - assert_eq!(account, 7); - assert_eq!(count, 1); - assert_eq!(slice::from_raw_parts(ids, 32), &[2; 32]); - let host = &mut *ctx.cast::(); - host.calls.push("remove"); - if host.failure == 1 { - -1 - } else { - 0 - } - } - unsafe extern "C" fn persist( - ctx: *mut c_void, - _: *const u8, - entries: *const ShieldedActivityFFI, - count: usize, - ) -> i32 { - assert_eq!(count, 1); - assert_eq!((*entries).entry_id, [3; 32]); - let host = &mut *ctx.cast::(); - host.calls.push("upsert"); - if host.failure == 2 { - -1 - } else { - 0 - } - } - unsafe extern "C" fn end(ctx: *mut c_void, _: *const u8, success: bool) -> i32 { - let host = &mut *ctx.cast::(); - host.calls.push("end"); - host.committed = success; - 0 - } - let id = SubwalletId::new([1; 32], 7); - let mut cs = platform_wallet::changeset::ShieldedChangeSet::default(); - cs.remove_activity_entry(id, [2; 32]); - cs.record_activity_entry( - id, - ShieldedActivityEntry { - id: [3; 32], - kind: ShieldedActivityKind::ShieldFromAssetLock, - status: ShieldedActivityStatus::Confirmed, - direction: ShieldedDirection::In, - amount: 1, - fee: None, - counterparty: None, - memo: None, - block_height: None, - created_at_ms: 0, - min_note_position: None, - note_cmxs: vec![[4; 32]], - spent_nullifiers: vec![], - }, - ); - for failure in [0, 1, 2, 3] { - let mut host = Host { - failure, - ..Default::default() - }; - let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( - PersistenceCallbacks { - context: (&mut host as *mut Host).cast(), - on_changeset_begin_fn: Some(noop_begin), - on_changeset_end_fn: Some(end), - on_persist_shielded_activity_fn: Some(persist), - ..Default::default() - }, - PersistenceCapabilities::NONE, - PersistenceExtensionCallbacks { - remove_shielded_activity: (failure != 3).then_some(remove), - ..Default::default() - }, - ); - let result = persister.store( - [1; 32], - PlatformWalletChangeSet { - shielded: Some(cs.clone()), - ..Default::default() - }, - ); - assert_eq!(result.is_ok(), failure == 0); - assert_eq!(host.committed, failure == 0); - assert_eq!( - host.calls, - if failure == 3 { - vec!["upsert", "end"] - } else { - vec!["remove", "upsert", "end"] - } - ); - } - } - - #[cfg(feature = "shielded")] - #[test] - fn should_keep_legacy_activity_upsert_best_effort_without_attestation() { - use platform_wallet::wallet::shielded::{ - ShieldedActivityEntry, ShieldedActivityKind, ShieldedActivityStatus, ShieldedDirection, - SubwalletId, - }; - let mut cs = platform_wallet::changeset::ShieldedChangeSet::default(); - cs.record_activity_entry( - SubwalletId::new([1; 32], 0), - ShieldedActivityEntry { - id: [3; 32], - kind: ShieldedActivityKind::Received, - status: ShieldedActivityStatus::Confirmed, - direction: ShieldedDirection::In, - amount: 1, - fee: None, - counterparty: None, - memo: None, - block_height: None, - created_at_ms: 0, - min_note_position: None, - note_cmxs: vec![[4; 32]], - spent_nullifiers: vec![], - }, - ); - for (capability, success) in [ - (PersistenceCapabilities::NONE, true), - ( - PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION, - false, - ), - ] { - let persister = declared_persister(PersistenceCallbacks::default(), capability); - assert_eq!( - persister - .store( - [1; 32], - PlatformWalletChangeSet { - shielded: Some(cs.clone()), - ..Default::default() - } - ) - .is_ok(), - success - ); - } - } - - #[cfg(feature = "shielded")] - #[test] - fn should_reject_malformed_activity_restore_for_attesting_hosts_and_free_rows() { - use crate::shielded_persistence::ShieldedActivityRestoreFFI; - struct Host { - mode: u8, - freed: bool, - } - unsafe extern "C" fn wallets( - _: *mut c_void, - entries: *mut *const WalletRestoreEntryFFI, - count: *mut usize, - ) -> i32 { - *entries = Box::into_raw(Box::new(WalletRestoreEntryFFI { - wallet_id: [1; 32], - ..Default::default() - })); - *count = 1; - 0 - } - unsafe extern "C" fn free_wallet( - _: *mut c_void, - entries: *const WalletRestoreEntryFFI, - _: usize, - ) { - drop(Box::from_raw(entries.cast_mut())); - } - unsafe extern "C" fn load( - ctx: *mut c_void, - entries: *mut *const ShieldedActivityRestoreFFI, - count: *mut usize, - ) -> i32 { - let host = &mut *ctx.cast::(); - // The wire row is entirely integer/array/raw-pointer fields. - let mut row: ShieldedActivityRestoreFFI = std::mem::zeroed(); - row.wallet_id = [1; 32]; - row.direction = if host.mode == 1 { 9 } else { 0 }; - if host.mode == 2 { - row.note_cmxs_count = usize::MAX; - } - if host.mode == 3 { - row.note_cmxs_count = 1; - } - *entries = if host.mode == 4 { - std::ptr::null() - } else { - Box::into_raw(Box::new(row)) - }; - *count = 1; - 0 - } - unsafe extern "C" fn free( - ctx: *mut c_void, - entries: *const ShieldedActivityRestoreFFI, - _: usize, - ) { - if !entries.is_null() { - drop(Box::from_raw(entries.cast_mut())); - } - (*ctx.cast::()).freed = true; - } - for mode in 1..=4 { - let mut host = Host { mode, freed: false }; - let persister = FFIPersister::new_with_persistence_capabilities( - PersistenceCallbacks { - context: (&mut host as *mut Host).cast(), - on_load_wallet_list_fn: Some(wallets), - on_load_wallet_list_free_fn: Some(free_wallet), - on_load_shielded_activity_fn: Some(load), - on_load_shielded_activity_free_fn: Some(free), - ..Default::default() - }, - PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION, - ); - assert!(persister.load().is_err(), "corruption mode {mode}"); - assert!(host.freed); - } - } - #[cfg(feature = "shielded")] #[test] fn shielded_viewing_key_capability_requires_complete_callback_triplet() { @@ -12062,7 +11054,6 @@ mod tests { status: platform_wallet::AssetLockStatus::Consumed, proof: None, operation_id: None, - shielded_funding_attempts: Vec::new(), }; let out_point = entry.out_point; let host = Arc::new(Host { diff --git a/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs b/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs index 68a59351bba..b521da6aab3 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs @@ -254,8 +254,8 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_wallet_drain result } -/// Reconcile authenticated exact outputs and full consumption of their asset -/// lock. May read proof-verified network state; never broadcasts. +/// 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. @@ -275,13 +275,13 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_wallet_drain_status( 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, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + 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(&coordinator, operation_id) + .shielded_wallet_drain_operation_status(operation_id) .await }) { Ok(snapshot) => { 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/V020__shielded_funding_attempts.rs b/packages/rs-platform-wallet-storage/migrations/V020__shielded_funding_attempts.rs deleted file mode 100644 index 7fdf6729bff..00000000000 --- a/packages/rs-platform-wallet-storage/migrations/V020__shielded_funding_attempts.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Exact signed funding transitions live outside the frozen lifecycle codec. -pub fn migration() -> String { - "ALTER TABLE asset_locks ADD COLUMN shielded_funding_attempts_blob BLOB \ - CHECK (shielded_funding_attempts_blob IS NULL OR typeof(shielded_funding_attempts_blob) = 'blob');".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 3bffb5abb68..ed0562fa98e 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1312,9 +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. - // Do not attest SHIELDED_ACTIVITY_RECONCILIATION either: this backend - // does not store/remove/restore activity rows. Wallet drains require - // that history checkpoint before marking the asset lock consumed. + // 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) @@ -1323,7 +1321,6 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::DPNS_NAME_STATES) .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) .union(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS) - .union(PersistenceCapabilities::SHIELDED_FUNDING_ATTEMPTS) .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 087d4fbe26d..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 @@ -17,7 +17,6 @@ use dpp::prelude::AssetLockProof; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use platform_wallet::changeset::AssetLockChangeSet; use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; -use platform_wallet::wallet::asset_lock::{MAX_DRAIN_ATTEMPTS, MAX_DRAIN_ATTEMPT_BYTES}; use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; @@ -60,64 +59,10 @@ struct AssetLockEntryWire { // PUBLIC material only: asset-lock lifecycle reaching `lifecycle_blob`. impl_persistable_blob!(AssetLockEntryWire); -/// Separate versioned sidecar; never extend the published lifecycle wire type. -#[derive(Serialize, Deserialize)] -struct ShieldedFundingAttemptsWire { - version: u32, - attempts: Vec>, -} -impl_persistable_blob!(ShieldedFundingAttemptsWire); - /// Nullable recovery columns read independently of the frozen lifecycle blob. struct AssetLockRecoveryRow { operation_id: Option>, lifecycle_blob: Vec, - shielded_funding_attempts_blob: Option>, -} - -fn decode_attempts(bytes: Option<&[u8]>) -> Result>, WalletStorageError> { - let Some(bytes) = bytes else { - return Ok(Vec::new()); - }; - // Read the frozen bincode sequence framing before allocating any attempts. - // The regular serde Vec decoder can reserve attacker-controlled lengths. - fn read>(bytes: &mut &[u8]) -> Result { - let (value, consumed) = bincode::decode_from_slice(bytes, blob::bounded_config())?; - *bytes = &bytes[consumed..]; - Ok(value) - } - let mut remaining = bytes; - let version: u32 = read(&mut remaining)?; - let count: u64 = read(&mut remaining)?; - if version != 1 || count > MAX_DRAIN_ATTEMPTS as u64 { - return Err(WalletStorageError::blob_decode( - "invalid shielded funding attempt count", - )); - } - let mut payloads = Vec::with_capacity(count as usize); - let mut total = 0usize; - for _ in 0..count { - let len: u64 = read(&mut remaining)?; - let len = usize::try_from(len) - .map_err(|_| WalletStorageError::blob_decode("attempt length overflow"))?; - total = total - .checked_add(len) - .ok_or_else(|| WalletStorageError::blob_decode("attempt bytes overflow"))?; - if len == 0 || total > MAX_DRAIN_ATTEMPT_BYTES || len > remaining.len() { - return Err(WalletStorageError::blob_decode( - "invalid shielded funding attempt bytes", - )); - } - let (payload, rest) = remaining.split_at(len); - payloads.push(payload); - remaining = rest; - } - if !remaining.is_empty() { - return Err(WalletStorageError::blob_decode( - "trailing shielded funding attempt bytes", - )); - } - Ok(payloads.into_iter().map(<[u8]>::to_vec).collect()) } /// Encode an [`AssetLockEntry`] to its on-disk `lifecycle_blob` form via the @@ -176,7 +121,6 @@ impl AssetLockEntryWire { status: self.status, proof, operation_id: None, - shielded_funding_attempts: Vec::new(), }) } } @@ -203,33 +147,30 @@ 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, operation_id, shielded_funding_attempts_blob) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) \ + (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, \ - operation_id = COALESCE(excluded.operation_id, asset_locks.operation_id), \ - shielded_funding_attempts_blob = excluded.shielded_funding_attempts_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, shielded_funding_attempts_blob, lifecycle_blob FROM asset_locks WHERE wallet_id = ?1 AND outpoint = ?2", + "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)?, - shielded_funding_attempts_blob: row.get(1)?, - lifecycle_blob: row.get(2)?, + lifecycle_blob: row.get(1)?, }), ).optional()?; - // A signed attempt belongs to this immutable operation UUID. - // Reject the whole snapshot before either lifecycle or sidecar - // writes, even when it carries no attempts of its own. + // 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 @@ -252,52 +193,7 @@ pub fn apply( continue; } } - let old_attempts = decode_attempts( - existing - .as_ref() - .and_then(|row| row.shielded_funding_attempts_blob.as_deref()), - )?; - let incoming = &entry.shielded_funding_attempts; - let incoming_bytes = incoming - .iter() - .try_fold(0usize, |total, bytes| total.checked_add(bytes.len())); - if incoming.len() > MAX_DRAIN_ATTEMPTS - || incoming_bytes.is_none_or(|total| total > MAX_DRAIN_ATTEMPT_BYTES) - || incoming.iter().any(Vec::is_empty) - || (!incoming.starts_with(&old_attempts) && !old_attempts.starts_with(incoming)) - { - return Err(WalletStorageError::blob_decode( - "shielded funding attempts cannot change", - )); - } - let attempts = if incoming.len() > old_attempts.len() { - incoming.clone() - } else { - old_attempts - }; - let operation_id = entry - .operation_id - .as_ref() - .map(|id| id.as_slice()) - .or_else(|| { - existing - .as_ref() - .and_then(|row| row.operation_id.as_deref()) - }); - if !attempts.is_empty() && operation_id.is_none() { - return Err(WalletStorageError::blob_decode( - "shielded funding attempts require an operation ID", - )); - } - let attempts_blob = if attempts.is_empty() { - None - } else { - Some(blob::encode(&ShieldedFundingAttemptsWire { - version: 1, - attempts, - })?) - }; - let applied = stmt.execute(params![ + stmt.execute(params![ wallet_id.as_slice(), &op_bytes[..], status_str(&entry.status), @@ -309,16 +205,7 @@ pub fn apply( )?, lifecycle_blob, entry.operation_id.as_ref().map(|id| id.as_slice()), - attempts_blob, ])?; - if applied == 0 { - // The terminal lifecycle guard may suppress a stale status, - // but signed evidence must still be durable before dispatch. - tx.execute( - "UPDATE asset_locks SET operation_id = COALESCE(?3, operation_id), shielded_funding_attempts_blob = ?4 WHERE wallet_id = ?1 AND outpoint = ?2", - params![wallet_id.as_slice(), &op_bytes[..], entry.operation_id.as_ref().map(|id| id.as_slice()), attempts_blob], - )?; - } } } if !cs.removed.is_empty() { @@ -412,7 +299,6 @@ fn decode_row( blob_bytes: &[u8], typed_status: &str, operation_id: Option>, - attempts_blob: Option>, ctx: &LoadCtx, ) -> Result<(u32, OutPoint, TrackedAssetLock), WalletStorageError> { let outpoint = blob::decode_outpoint(op_bytes)?; @@ -425,12 +311,6 @@ fn decode_row( }) }) .transpose()?; - entry.shielded_funding_attempts = decode_attempts(attempts_blob.as_deref())?; - if !entry.shielded_funding_attempts.is_empty() && entry.operation_id.is_none() { - return Err(WalletStorageError::blob_decode( - "shielded funding attempts require an operation ID", - )); - } 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 @@ -473,7 +353,6 @@ fn decode_row( status: entry.status, proof: entry.proof, operation_id: entry.operation_id, - shielded_funding_attempts: entry.shielded_funding_attempts, }; Ok((account_index, outpoint, tracked)) } @@ -489,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, operation_id, length(shielded_funding_attempts_blob), shielded_funding_attempts_blob \ + "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()])?; @@ -502,17 +381,12 @@ pub fn load_state( let blob_bytes: Vec = row.get(4)?; let status: String = row.get(5)?; let operation_id: Option> = row.get(6)?; - if let Some(size) = row.get::<_, Option>(7)? { - blob::check_size(size)?; - } - let attempts_blob: Option> = row.get(8)?; let (acct, outpoint, tracked) = decode_row( &op_bytes, account_index, &blob_bytes, &status, operation_id, - attempts_blob, ctx, )?; out.entry(acct).or_default().insert(outpoint, tracked); @@ -533,7 +407,7 @@ pub fn load_unconsumed( ctx: &LoadCtx, ) -> Result { let mut stmt = conn.prepare( - "SELECT length(outpoint), outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status, operation_id, length(shielded_funding_attempts_blob), shielded_funding_attempts_blob \ + "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()])?; @@ -546,17 +420,12 @@ pub fn load_unconsumed( let blob_bytes: Vec = row.get(4)?; let status: String = row.get(5)?; let operation_id: Option> = row.get(6)?; - if let Some(size) = row.get::<_, Option>(7)? { - blob::check_size(size)?; - } - let attempts_blob: Option> = row.get(8)?; let (acct, outpoint, tracked) = decode_row( &op_bytes, account_index, &blob_bytes, &status, operation_id, - attempts_blob, ctx, )?; if tracked.status.is_consumed() && tracked.operation_id.is_none() { @@ -623,7 +492,6 @@ mod tests { funding_type: AssetLockFundingType::IdentityRegistration, identity_index: 0, operation_id: None, - shielded_funding_attempts: Vec::new(), amount_duffs: 1000, status, proof, @@ -662,42 +530,10 @@ mod tests { status: tracked.status, proof: tracked.proof, operation_id: tracked.operation_id, - shielded_funding_attempts: tracked.shielded_funding_attempts, }) .expect("stored asset lock must load back") } - #[test] - fn should_bound_attempt_archive_before_payload_allocation() { - for attempts in [ - vec![vec![1]; MAX_DRAIN_ATTEMPTS], - vec![vec![2; MAX_DRAIN_ATTEMPT_BYTES]], - ] { - let bytes = blob::encode(&ShieldedFundingAttemptsWire { - version: 1, - attempts: attempts.clone(), - }) - .unwrap(); - assert_eq!(decode_attempts(Some(&bytes)).unwrap(), attempts); - } - for attempts in [ - vec![vec![1]; MAX_DRAIN_ATTEMPTS + 1], - vec![vec![1; MAX_DRAIN_ATTEMPT_BYTES], vec![2]], - ] { - let bytes = blob::encode(&ShieldedFundingAttemptsWire { - version: 1, - attempts, - }) - .unwrap(); - assert!(decode_attempts(Some(&bytes)).is_err()); - } - // Only the length prefix is present: reject it without allocating. - let bytes = bincode::encode_to_vec((1u32, u64::MAX), blob::bounded_config()).unwrap(); - assert!(decode_attempts(Some(&bytes)).is_err()); - let bytes = bincode::encode_to_vec((1u32, 1u64, u64::MAX), blob::bounded_config()).unwrap(); - assert!(decode_attempts(Some(&bytes)).is_err()); - } - #[test] fn should_preserve_proven_drain_completion_over_stale_consumed_snapshot() { let mut conn = migrated_conn(); @@ -884,7 +720,6 @@ mod tests { funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, operation_id: None, - shielded_funding_attempts: Vec::new(), amount_duffs: 1000, status: AssetLockStatus::Built, proof: None, 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 e1edb26af63..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 @@ -36,7 +36,6 @@ fn entry(op: OutPoint, status: AssetLockStatus) -> AssetLockEntry { funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, operation_id: None, - shielded_funding_attempts: Vec::new(), amount_duffs: 1000, status, proof: None, @@ -250,7 +249,6 @@ fn should_migrate_legacy_blob_without_reencoding_and_reject_invalid_operation_id asset_locks::load_unconsumed(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) .unwrap(); assert_eq!(locks[&0][&outpoint].operation_id, None); - assert!(locks[&0][&outpoint].shielded_funding_attempts.is_empty()); assert!(conn .execute("UPDATE asset_locks SET operation_id = ?1", [vec![1_u8; 15]]) .is_err()); @@ -288,7 +286,8 @@ fn should_reject_conflicting_operation_id_before_mutating_any_stored_columns() { .unwrap() } - // Exercise the regular upsert and the consumed-row sidecar fallback. + // 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); @@ -296,7 +295,6 @@ fn should_reject_conflicting_operation_id_before_mutating_any_stored_columns() { let outpoint = op(0x34); let mut original = entry(outpoint, status); original.operation_id = Some([8; 16]); - original.shielded_funding_attempts = vec![vec![18, 1, 2, 3]]; persister .store( wallet_id, @@ -312,8 +310,6 @@ fn should_reject_conflicting_operation_id_before_mutating_any_stored_columns() { let mut incoming = entry(outpoint, AssetLockStatus::ChainLocked); incoming.operation_id = Some([9; 16]); - // The empty prefix used to pass the signed-attempt validation. - assert!(incoming.shielded_funding_attempts.is_empty()); incoming.account_index = 7; incoming.identity_index = 11; incoming.amount_duffs = 2_000; @@ -344,77 +340,3 @@ fn should_reject_conflicting_operation_id_before_mutating_any_stored_columns() { ); } } - -#[test] -fn should_restore_append_only_signed_attempts_and_reject_replacement() { - let (persister, _tmp, path) = fresh_persister(); - let w = wid(0xA9); - ensure_wallet_meta(&persister, &w); - let outpoint = op(0x33); - let first = vec![18, 1, 2, 3]; - let second = vec![18, 4, 5, 6, 7]; - let store = |attempts: Vec>, status, id| { - let mut row = entry(outpoint, status); - row.operation_id = id; - row.shielded_funding_attempts = attempts; - persister.store( - w, - platform_wallet::changeset::PlatformWalletChangeSet { - asset_locks: Some(AssetLockChangeSet { - asset_locks: [(outpoint, row)].into_iter().collect(), - ..Default::default() - }), - ..Default::default() - }, - ) - }; - store( - vec![first.clone()], - AssetLockStatus::Broadcast, - Some([8; 16]), - ) - .unwrap(); - store( - vec![first.clone()], - AssetLockStatus::Consumed, - Some([8; 16]), - ) - .unwrap(); - // A delayed evidence snapshot preserves terminal status while appending bytes. - store( - vec![first.clone(), second.clone()], - AssetLockStatus::Broadcast, - Some([8; 16]), - ) - .unwrap(); - assert!(store(vec![vec![18, 9]], AssetLockStatus::Consumed, Some([8; 16])).is_err()); - // Legacy and stale prefixes cannot erase persisted attempts, even at terminal status. - store(Vec::new(), AssetLockStatus::Consumed, None).unwrap(); - store( - vec![first.clone()], - AssetLockStatus::Consumed, - Some([8; 16]), - ) - .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(); - assert_eq!( - locks[&0][&outpoint].shielded_funding_attempts, - vec![first, second] - ); - assert_eq!(locks[&0][&outpoint].status, AssetLockStatus::Consumed); - conn.execute( - "UPDATE asset_locks SET shielded_funding_attempts_blob = X'00'", - [], - ) - .unwrap(); - assert!( - asset_locks::load_unconsumed(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) - .is_err() - ); -} 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 cc2d63ec2eb..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 @@ -53,7 +53,6 @@ fn entry(op: OutPoint, status: AssetLockStatus, proof: Option) - funding_type: AssetLockFundingType::IdentityRegistration, identity_index: 0, operation_id: None, - shielded_funding_attempts: Vec::new(), 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 513750dcd27..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 @@ -1088,7 +1088,6 @@ fn tc_p4_005_load_asset_locks_bucketed() { funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, operation_id: None, - shielded_funding_attempts: Vec::new(), amount_duffs: 1000, status: AssetLockStatus::Built, proof: None, @@ -1395,7 +1394,6 @@ fn tc_p4_008c_asset_locks_corruption_is_hard_error() { funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, operation_id: None, - shielded_funding_attempts: Vec::new(), 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 f3e99b427c8..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 @@ -407,7 +407,6 @@ fn tc010_asset_lock_roundtrip() { funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 9, operation_id: None, - shielded_funding_attempts: Vec::new(), amount_duffs: 12_345, status: AssetLockStatus::Built, proof: None, @@ -475,7 +474,6 @@ fn tc010b_recovered_from_chain_lock_roundtrip() { funding_type: AssetLockFundingType::AssetLockShieldedAddressTopUp, identity_index: 0, operation_id: None, - shielded_funding_attempts: Vec::new(), amount_duffs: 500_000, status: AssetLockStatus::RecoveredFromChain, proof: Some(dpp::prelude::AssetLockProof::Chain(ChainAssetLockProof { @@ -548,7 +546,6 @@ fn tc010c_stale_recovery_snapshot_cannot_regress_consumed_row() { funding_type: AssetLockFundingType::IdentityRegistration, identity_index: 0, operation_id: None, - shielded_funding_attempts: Vec::new(), 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 4961036d6eb..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 @@ -291,7 +291,6 @@ fn seed_asset_lock_status_drift( funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, operation_id: None, - shielded_funding_attempts: Vec::new(), 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 4421094e100..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 = - "257836a39b1c58c0a2765bf48c328a4fbc5caefe10b28481d743761a67999f2a"; + "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 = - "bbff5b9244da4c9db6567c3dd520276e69388f4a1851e488a1b44939ef3b8229"; + "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 5d1e44a548f..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 @@ -84,20 +84,18 @@ fn sqlite_advertises_shielded_viewing_key_capability() { } #[test] -fn sqlite_does_not_attest_wallet_drain_without_durable_activity_history() { +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::SHIELDED_ACTIVITY_RECONCILIATION)); assert!(!capabilities.contains(PersistenceCapabilities::WALLET_DRAIN)); assert!(capabilities .missing(PersistenceCapabilities::WALLET_DRAIN) .names() - .contains(&"shielded_activity_reconciliation")); + .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)); - assert!(capabilities.contains(PersistenceCapabilities::SHIELDED_FUNDING_ATTEMPTS)); } #[test] 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 fd9418367b6..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 @@ -381,7 +381,6 @@ fn asset_lock_typed_vs_blob_mismatch_rejected() { funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, operation_id: None, - shielded_funding_attempts: Vec::new(), 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 babfc5f5afd..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 @@ -367,7 +367,6 @@ fn asset_lock_changeset() -> AssetLockChangeSet { funding_type: AssetLockFundingType::IdentityTopUp, identity_index: 0, operation_id: None, - shielded_funding_attempts: Vec::new(), 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 91a32a933f2..07246c01ac9 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1433,9 +1433,6 @@ pub struct AssetLockEntry { /// Stable host operation identifier; legacy rows have no identifier. #[cfg_attr(feature = "serde", serde(default))] pub operation_id: Option<[u8; 16]>, - /// Exact signed shielded funding transitions, retained in broadcast order. - #[cfg_attr(feature = "serde", serde(default))] - pub shielded_funding_attempts: Vec>, /// The outpoint identifying this credit output (txid + vout). pub out_point: OutPoint, /// The full asset lock transaction. @@ -1463,15 +1460,14 @@ pub struct AssetLockEntry { } impl AssetLockEntry { - /// Preserve the immutable operation association and every known signed - /// attempt when folding or replaying a possibly stale snapshot. Returns - /// false for a conflicting UUID: the caller must discard the whole snapshot - /// so its lifecycle or evidence cannot be attached to a different operation. + /// 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]>, - previous_attempts: &[Vec], ) -> bool { if let Some(previous_id) = previous_operation_id { if self @@ -1483,13 +1479,6 @@ impl AssetLockEntry { } self.operation_id = Some(previous_id); } - let mut attempts = previous_attempts.to_vec(); - for attempt in std::mem::take(&mut self.shielded_funding_attempts) { - if !attempts.contains(&attempt) { - attempts.push(attempt); - } - } - self.shielded_funding_attempts = attempts; true } } @@ -1511,19 +1500,15 @@ impl Merge for AssetLockChangeSet { // racing snapshots immaterial. 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, - &existing.shielded_funding_attempts, - ) { + 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 discards - // exact signed evidence carried by a delayed snapshot. + // Terminality guards lifecycle state, never the operation + // association carried by a delayed snapshot. existing.operation_id = entry.operation_id; - existing.shielded_funding_attempts = entry.shielded_funding_attempts; continue; } } @@ -2569,7 +2554,6 @@ mod tests { }; let entry_with = |status: AssetLockStatus| AssetLockEntry { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point: outpoint, transaction: Transaction { version: 3, @@ -2613,52 +2597,19 @@ mod tests { "a non-Consumed snapshot must not replace the Consumed terminal" ); - // Signed evidence is append-only even when its status snapshot is stale. - folded - .asset_locks - .get_mut(&outpoint) - .unwrap() - .shielded_funding_attempts = vec![vec![18, 1]]; - let mut delayed = cs_with(AssetLockStatus::Broadcast); - delayed - .asset_locks - .get_mut(&outpoint) - .unwrap() - .shielded_funding_attempts = vec![vec![18, 1], vec![18, 2]]; - folded.merge(delayed); - folded.merge(cs_with(AssetLockStatus::Consumed)); - assert_eq!( - folded.asset_locks[&outpoint].shielded_funding_attempts, - vec![vec![18, 1], vec![18, 2]] - ); - assert_eq!( - folded.asset_locks[&outpoint].status, - AssetLockStatus::Consumed - ); - - // A conflicting operation cannot replace either lifecycle or evidence. + // 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]); - original - .asset_locks - .get_mut(&outpoint) - .unwrap() - .shielded_funding_attempts = vec![vec![18, 1]]; let mut conflicting = cs_with(AssetLockStatus::Consumed); conflicting .asset_locks .get_mut(&outpoint) .unwrap() .operation_id = Some([43; 16]); - conflicting - .asset_locks - .get_mut(&outpoint) - .unwrap() - .shielded_funding_attempts = vec![vec![18, 2]]; let expected = original.clone(); original.merge(conflicting); assert_eq!(original.asset_locks, expected.asset_locks); @@ -3318,7 +3269,6 @@ mod tests { OutPoint::default(), AssetLockEntry { operation_id: None, - shielded_funding_attempts: Vec::new(), 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 84c9efa1487..c730678cf9b 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -6116,7 +6116,6 @@ mod tests { out_point, AssetLockEntry { operation_id: None, - shielded_funding_attempts: Vec::new(), 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 8e2cc6aba62..6c17b550b1e 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -111,22 +111,13 @@ impl PersistenceCapabilities { /// empty result. Required before a wallet drain can treat absence as not sent. pub const ASSET_LOCK_OPERATION_IDS: Self = Self(1 << 13); - /// Exact signed shielded funding attempts are append-only, atomically persisted - /// before dispatch and restored losslessly, including consumed tombstones. - pub const SHIELDED_FUNDING_ATTEMPTS: Self = Self(1 << 14); - - /// Shielded activity upserts, removals and strict restore are durable and - /// atomic with asset-lock completion. Authenticated scan notes, outgoing - /// notes and sync watermarks also survive restart without silent loss. - pub const SHIELDED_ACTIVITY_RECONCILIATION: Self = Self(1 << 15); - /// 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 = 15; + const HIGHEST_DECLARED_BIT: u32 = 13; /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -149,9 +140,7 @@ impl PersistenceCapabilities { pub const WALLET_DRAIN: Self = Self( Self::ASSET_LOCK_RECONCILIATION.0 | Self::ASSET_LOCK_FUNDING_INDICES.0 - | Self::ASSET_LOCK_OPERATION_IDS.0 - | Self::SHIELDED_FUNDING_ATTEMPTS.0 - | Self::SHIELDED_ACTIVITY_RECONCILIATION.0, + | Self::ASSET_LOCK_OPERATION_IDS.0, ); pub const fn from_bits_retain(bits: u64) -> Self { @@ -232,14 +221,6 @@ impl PersistenceCapabilities { PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS, "asset_lock_operation_ids", ), - ( - PersistenceCapabilities::SHIELDED_FUNDING_ATTEMPTS, - "shielded_funding_attempts", - ), - ( - PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION, - "shielded_activity_reconciliation", - ), ]; KNOWN @@ -257,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::SHIELDED_ACTIVITY_RECONCILIATION.bits() + PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS.bits() == 1u64 << PersistenceCapabilities::HIGHEST_DECLARED_BIT, "HIGHEST_DECLARED_BIT must name the highest declared capability bit" ); @@ -289,15 +270,7 @@ mod tests { PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS.bits(), 0x2000 ); - assert_eq!( - PersistenceCapabilities::SHIELDED_FUNDING_ATTEMPTS.bits(), - 0x4000 - ); - assert_eq!( - PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION.bits(), - 0x8000 - ); - assert_eq!(PersistenceCapabilities::WALLET_DRAIN.bits(), 0xe285); + assert_eq!(PersistenceCapabilities::WALLET_DRAIN.bits(), 0x2285); assert_eq!( PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), 0x281 diff --git a/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs b/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs index ad5f2efdf4b..3ac11cca4b1 100644 --- a/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs @@ -15,7 +15,7 @@ //! (already persisted in `ClientPersistentCommitmentTree`'s //! SQLite file at the host-supplied `db_path`). -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use crate::changeset::merge::Merge; use crate::wallet::shielded::{ @@ -51,10 +51,6 @@ pub struct ShieldedChangeSet { /// Defaults empty so every pre-activity flow rides the existing /// changeset path unchanged. pub activity_entries: BTreeMap>, - /// Superseded scan aggregates to remove in the same atomic round as their - /// replacement entries. Removal wins over stale upserts folded in the round. - #[cfg_attr(feature = "serde", serde(default))] - pub activity_removed: BTreeMap>, /// Per-subwallet Orchard viewing keys to persist, as the raw /// 96-byte FVK encoding (stored as `Vec` — serde-derive only /// covers arrays ≤ 32). Emitted once per seed-backed @@ -73,7 +69,6 @@ impl ShieldedChangeSet { && self.outgoing_notes.is_empty() && self.synced_indices.is_empty() && self.activity_entries.is_empty() - && self.activity_removed.is_empty() && self.viewing_keys.is_empty() } @@ -96,24 +91,7 @@ impl ShieldedChangeSet { /// The persister upserts by `entry.id`, so re-recording the same id /// with a refined kind / flipped status replaces the prior row. pub fn record_activity_entry(&mut self, id: SubwalletId, entry: ShieldedActivityEntry) { - if !self - .activity_removed - .get(&id) - .is_some_and(|ids| ids.contains(&entry.id)) - { - self.activity_entries.entry(id).or_default().push(entry); - } - } - - /// Retire an aggregate whose outputs have been split into exact operations. - pub fn remove_activity_entry(&mut self, id: SubwalletId, entry_id: [u8; 32]) { - self.activity_removed - .entry(id) - .or_default() - .insert(entry_id); - if let Some(entries) = self.activity_entries.get_mut(&id) { - entries.retain(|entry| entry.id != entry_id); - } + self.activity_entries.entry(id).or_default().push(entry); } /// Accumulator helper: advance the per-subwallet sync watermark. @@ -148,7 +126,6 @@ impl ShieldedChangeSet { outgoing_notes, synced_indices, activity_entries, - activity_removed, viewing_keys, } = self; let mut out: BTreeMap = @@ -183,12 +160,6 @@ impl ShieldedChangeSet { .activity_entries .insert(id, entries); } - for (id, entries) in activity_removed { - out.entry(id.wallet_id) - .or_default() - .activity_removed - .insert(id, entries); - } for (id, fvk) in viewing_keys { out.entry(id.wallet_id) .or_default() @@ -226,15 +197,8 @@ impl Merge for ShieldedChangeSet { // Activity entries append; the persister upserts by `entry.id`, // so a later flip/refinement of the same id (appended after the // original) wins at persist time without needing to dedupe here. - for (id, entries) in other.activity_removed { - for entry in entries { - self.remove_activity_entry(id, entry); - } - } for (id, entries) in other.activity_entries { - for entry in entries { - self.record_activity_entry(id, entry); - } + self.activity_entries.entry(id).or_default().extend(entries); } // Viewing keys: last write wins (the FVK for a subwallet is // stable on a given network, so any re-emit is identical). @@ -349,33 +313,4 @@ mod activity_changeset_tests { .activity_entries .contains_key(&SubwalletId::new(wallet_b, 0))); } - #[test] - fn activity_removal_wins_stale_fold_and_stays_wallet_scoped() { - let id = SubwalletId::new([1; 32], 0); - let other = SubwalletId::new([2; 32], 0); - let old = entry(1, ShieldedActivityStatus::Confirmed); - let replacement = entry(2, ShieldedActivityStatus::Confirmed); - let mut stale = ShieldedChangeSet::default(); - stale.record_activity_entry(id, old.clone()); - stale.record_activity_entry(other, old.clone()); - let mut repaired = ShieldedChangeSet::default(); - repaired.remove_activity_entry(id, old.id); - repaired.record_activity_entry(id, replacement.clone()); - // Either folding order must retain the removal, including a buffered - // scan round that was built before the canonical replacement. - for (mut first, second) in [(stale.clone(), repaired.clone()), (repaired, stale)] { - first.merge(second); - let split = first.split_by_wallet_id(); - assert_eq!( - split[&id.wallet_id].activity_entries[&id], - vec![replacement.clone()] - ); - assert!(split[&id.wallet_id].activity_removed[&id].contains(&old.id)); - assert_eq!( - split[&other.wallet_id].activity_entries[&other], - vec![old.clone()] - ); - assert!(split[&other.wallet_id].activity_removed.is_empty()); - } - } } diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 7df4b5a1dbd..50fe85f9864 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -357,17 +357,13 @@ impl PlatformWalletInfo { if let Some(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, - &existing.shielded_funding_attempts, - ) { + 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; - existing.shielded_funding_attempts = entry.shielded_funding_attempts; continue; } } @@ -375,7 +371,6 @@ impl PlatformWalletInfo { out_point, TrackedAssetLock { operation_id: entry.operation_id, - shielded_funding_attempts: entry.shielded_funding_attempts, out_point: entry.out_point, transaction: entry.transaction, account_index: entry.account_index, @@ -749,7 +744,6 @@ mod tests { out_point, AssetLockEntry { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point, transaction: dashcore::Transaction { version: 3, @@ -810,11 +804,9 @@ mod tests { fn recovery_asset_lock( status: AssetLockStatus, operation_id: Option<[u8; 16]>, - attempts: Vec>, ) -> AssetLockEntry { AssetLockEntry { operation_id, - shielded_funding_attempts: attempts, out_point: OutPoint::default(), transaction: dashcore::Transaction { version: 3, @@ -854,35 +846,27 @@ mod tests { let mut wallet = build_test_wallet(); let mut info = empty_info(&wallet); let operation_id = Some([42; 16]); - let attempts = vec![vec![18, 1], vec![18, 2]]; apply_recovery_asset_lock( &mut info, &mut wallet, - recovery_asset_lock(AssetLockStatus::Broadcast, operation_id, attempts.clone()), + recovery_asset_lock(AssetLockStatus::Broadcast, operation_id), ); - for stale_attempts in [vec![attempts[0].clone()], Vec::new()] { - apply_recovery_asset_lock( - &mut info, - &mut wallet, - recovery_asset_lock(AssetLockStatus::RecoveredFromChain, None, stale_attempts), - ); - let restored = &info.tracked_asset_locks[&OutPoint::default()]; - assert_eq!(restored.operation_id, operation_id); - assert_eq!(restored.shielded_funding_attempts, attempts); - } - - let all_attempts = vec![vec![18, 1], vec![18, 2], vec![18, 3]]; - let newest = recovery_asset_lock( - AssetLockStatus::ChainLocked, - operation_id, - all_attempts.clone(), + // 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.shielded_funding_attempts, all_attempts); assert_eq!(restored.status, AssetLockStatus::ChainLocked); } @@ -894,13 +878,12 @@ mod tests { apply_recovery_asset_lock( &mut info, &mut wallet, - recovery_asset_lock(AssetLockStatus::Consumed, operation_id, vec![vec![18, 1]]), + recovery_asset_lock(AssetLockStatus::Consumed, operation_id), ); - let attempts = vec![vec![18, 1], vec![18, 2]]; apply_recovery_asset_lock( &mut info, &mut wallet, - recovery_asset_lock(AssetLockStatus::Broadcast, None, attempts.clone()), + recovery_asset_lock(AssetLockStatus::Broadcast, None), ); let mut removed = AssetLockChangeSet::default(); removed.removed.insert(OutPoint::default()); @@ -914,7 +897,6 @@ mod tests { .expect("apply stale removal"); let restored = &info.tracked_asset_locks[&OutPoint::default()]; assert_eq!(restored.operation_id, operation_id); - assert_eq!(restored.shielded_funding_attempts, attempts); assert_eq!(restored.status, AssetLockStatus::Consumed); } @@ -931,7 +913,7 @@ mod tests { apply_recovery_asset_lock( &mut info, &mut wallet, - recovery_asset_lock(status.clone(), Some([42; 16]), vec![vec![18, 1]]), + recovery_asset_lock(status.clone(), Some([42; 16])), ); if status == AssetLockStatus::DrainCompleted { assert_eq!( @@ -960,16 +942,12 @@ mod tests { 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]), - vec![vec![18, 1]], - ); + 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]), vec![vec![18, 2]]), + recovery_asset_lock(AssetLockStatus::Consumed, Some([43; 16])), ); assert_eq!( AssetLockEntry::from(&info.tracked_asset_locks[&OutPoint::default()]), 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 426e05653a2..8d91f4776c5 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -1157,7 +1157,6 @@ impl AssetLockManager { let tracked = TrackedAssetLock { operation_id: operation.map(|context| context.operation_id), - shielded_funding_attempts: Vec::new(), out_point, transaction: tx.clone(), account_index: source_index, @@ -1735,9 +1734,6 @@ mod tests { entry.out_point, TrackedAssetLock { operation_id: entry.operation_id, - shielded_funding_attempts: entry - .shielded_funding_attempts - .clone(), out_point: entry.out_point, transaction: entry.transaction.clone(), account_index: entry.account_index, @@ -4869,104 +4865,6 @@ mod tests { } } - #[cfg(feature = "shielded")] - #[tokio::test] - async fn should_bound_drain_attempt_archive_without_discarding_evidence() { - use super::super::operation::{MAX_DRAIN_ATTEMPTS, MAX_DRAIN_ATTEMPT_BYTES}; - - // Count boundary, cumulative byte boundary, and one oversized attempt. - for mode in 0..3 { - 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(CountingOkBroadcaster::default()), - ); - persistence - .restore_stored_locks - .store(true, std::sync::atomic::Ordering::SeqCst); - let operation = Arc::new(super::AssetLockOperationContext::new([55; 16])); - let _registration = manager.register_wallet_drain_operation(operation.clone()); - manager.begin_wallet_drain(&operation).await.unwrap(); - 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 point = operation.snapshot().out_point.unwrap(); - let attempts: Vec> = match mode { - 0 => (0..MAX_DRAIN_ATTEMPTS) - .map(|i| i.to_le_bytes().to_vec()) - .collect(), - 1 => vec![ - vec![1; MAX_DRAIN_ATTEMPT_BYTES / 2], - vec![2; MAX_DRAIN_ATTEMPT_BYTES / 2], - ], - _ => vec![], - }; - for attempt in &attempts { - manager - .persist_wallet_drain_attempt(point, attempt.clone()) - .await - .unwrap(); - } - let rejected = if mode == 2 { - vec![3; MAX_DRAIN_ATTEMPT_BYTES + 1] - } else { - vec![3] - }; - let stores_before = persistence.stored.lock().unwrap().len(); - let error = manager - .persist_wallet_drain_attempt(point, rejected) - .await - .unwrap_err(); - assert!(error.to_string().contains("recovery budget exhausted")); - assert_eq!(persistence.stored.lock().unwrap().len(), stores_before); - assert_eq!( - manager.list_tracked_locks().await[0].shielded_funding_attempts, - attempts - ); - 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.shielded_funding_attempts, attempts); - assert_eq!( - manager - .wallet_drain_operation_status([55; 16]) - .await - .unwrap() - .state, - super::AssetLockOperationState::Pending - ); - // Byte-identical replay/persistence retries remain possible at the - // limit, without appending or throwing away older recovery evidence. - if let Some(first) = attempts.first() { - manager - .persist_wallet_drain_attempt(point, first.clone()) - .await - .unwrap(); - assert_eq!( - manager.list_tracked_locks().await[0].shielded_funding_attempts, - attempts - ); - } - assert_eq!(manager.broadcaster.calls(), 1); - } - } - #[tokio::test] async fn should_wallet_drain_failed_row_flush_never_publish_resumable_lock() { let (wallet_manager, wallet_id, _, signer) = 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 fcd39097acc..6557e4b797b 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs @@ -7,7 +7,7 @@ mod build; mod operation; pub use operation::{ AssetLockOperationContext, AssetLockOperationRegistration, AssetLockOperationSnapshot, - AssetLockOperationState, MAX_DRAIN_ATTEMPTS, MAX_DRAIN_ATTEMPT_BYTES, + AssetLockOperationState, }; pub mod lock_notify_handler; pub mod manager; diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs index fee5f681a91..4e93e9dc8e1 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs @@ -12,18 +12,13 @@ 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}; -// Local recovery resource budgets, not protocol validity limits. Keep every -// admitted attempt as evidence; exhaustion stops new dispatches, never recovery -// or replay of an already recorded attempt. 64 attempts allow repeated freshness -// retries, while 4 MiB bounds the archive even if individual transitions grow. -pub const MAX_DRAIN_ATTEMPTS: usize = 64; -pub const MAX_DRAIN_ATTEMPT_BYTES: usize = 4 * 1024 * 1024; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AssetLockOperationState { NotSent, @@ -156,102 +151,56 @@ impl AssetLockManager { } } - /// Retain every exact signed attempt before dispatch. Failed writes retain - /// the candidate in memory too: a backend may have committed before its - /// flush failed, so the next attempt must never reuse its append index. - #[cfg(feature = "shielded")] - pub(crate) async fn persist_wallet_drain_attempt( + /// 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, - signed_attempt: Vec, + operation_id: [u8; 16], ) -> Result<(), PlatformWalletError> { self.require_wallet_drain_persistence()?; - let candidate = { + { 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.operation_id.is_none() { - return Err(PlatformWalletError::Persistence( - "drain attempt has no operation UUID".into(), - )); - } - if lock.status.is_consumed() { - return Err(PlatformWalletError::AssetLockAlreadyConsumed(out_point)); - } - if !lock.shielded_funding_attempts.contains(&signed_attempt) { - let total_bytes = lock - .shielded_funding_attempts - .iter() - .try_fold(signed_attempt.len(), |total, attempt| { - total.checked_add(attempt.len()) - }); - if lock.shielded_funding_attempts.len() >= MAX_DRAIN_ATTEMPTS - || total_bytes.is_none_or(|total| total > MAX_DRAIN_ATTEMPT_BYTES) - { - return Err(PlatformWalletError::Persistence( - "drain signed-attempt recovery budget exhausted; operation remains pending" - .into(), - )); + 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()))?; + if let Err(error) = self.persister.flush() { + if !self.persister.store_commits_inline() { + return Err(PlatformWalletError::Persistence(error.to_string())); + } + // The row is already committed. A failed notification must + // not suppress its live publication. + tracing::warn!(%out_point, %error, "drain completion committed inline; flush notification failed"); } - lock.shielded_funding_attempts.push(signed_attempt); + lock.status = AssetLockStatus::DrainCompleted; + lock.proof = None; } - lock.clone() - }; - self.persist_drain_lock_snapshot(&candidate) - } - - /// Commit the tombstone before publishing completion. The caller owns the - /// activity-store guard and must publish history synchronously afterwards. - /// Refuse a busy manager without waiting: no cancellation or lock-order - /// inversion may separate durable completion from its memory publication. - #[cfg(feature = "shielded")] - pub(crate) fn complete_wallet_drain( - &self, - out_point: OutPoint, - operation_id: [u8; 16], - history: crate::changeset::ShieldedChangeSet, - ) -> Result { - self.require_wallet_drain_persistence()?; - let Ok(mut manager) = self.wallet_manager.try_write() else { - return Ok(false); - }; - 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))?; - let mut candidate = lock.clone(); - candidate.status = AssetLockStatus::DrainCompleted; - candidate.proof = None; - let mut changes = crate::changeset::AssetLockChangeSet::default(); - changes - .asset_locks - .insert(candidate.out_point, (&candidate).into()); - self.persister - .store(crate::changeset::PlatformWalletChangeSet { - asset_locks: Some(changes), - shielded: Some(history), - ..Default::default() - }) - .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; - if let Err(error) = self.persister.flush() { - if !self.persister.store_commits_inline() { - return Err(PlatformWalletError::Persistence(error.to_string())); - } - // store already committed history and the tombstone. A failed - // notification must not suppress their live publication. - tracing::warn!(%out_point, %error, "wallet drain 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(true) + Ok(()) } #[cfg(any(feature = "shielded", test))] @@ -259,10 +208,10 @@ impl AssetLockManager { &self, lock: &TrackedAssetLock, ) -> Result<(), PlatformWalletError> { - let mut changes = crate::changeset::AssetLockChangeSet::default(); + let mut changes = AssetLockChangeSet::default(); changes.asset_locks.insert(lock.out_point, lock.into()); self.persister - .store(crate::changeset::PlatformWalletChangeSet { + .store(PlatformWalletChangeSet { asset_locks: Some(changes), ..Default::default() }) @@ -450,7 +399,7 @@ impl AssetLockManager { .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, consumption state or signed recovery attempt. + // replace a newer proof or consumption state. let current = info .tracked_asset_locks .entry(out_point) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs index 49ab03d83a1..2d3ae657b26 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -454,7 +454,8 @@ impl AssetLockManager { /// so it cannot prove that the requested operation completed. Promote the /// lock to an SPV-backed ChainLock proof, durably retain it as /// consumption-unknown, and preserve the typed host signal. Successful and - /// unrelated results pass through unchanged. + /// unrelated results pass through unchanged. Wallet drains settle through + /// [`Self::settle_asset_lock_submit_result`] instead. /// /// `chain_lock_timeout` bounds the IS→CL promotion. `None` does **not** /// mean "wait forever" here: it selects @@ -538,6 +539,40 @@ impl AssetLockManager { Err(PlatformWalletError::AssetLockAlreadyConsumed(*out_point)) } + /// 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. + #[cfg(any(feature = "shielded", test))] + pub(crate) 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) + } + /// Rebuild a ChainLock proof for a lock whose IS-lock never propagated, /// **persist it**, and hand back the proof with the derivation path the /// caller needs to sign. @@ -1147,7 +1182,6 @@ mod tests { out_point, TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), 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 4cb858b7b9c..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 @@ -2013,7 +2013,6 @@ mod chain_lock_wait_tests { out_point, TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -2120,7 +2119,6 @@ mod chain_lock_wait_tests { out_point, TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), 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 ae83a74cdc8..edb561860e4 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 @@ -232,7 +232,6 @@ fn reconstruct_candidates( let (status, proof) = recovered_status(record, out_point); Some(TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point, transaction: record.transaction.clone(), account_index, @@ -765,7 +764,6 @@ mod tests { out_point, TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point, transaction: tx.clone(), account_index: 0, @@ -1002,7 +1000,6 @@ mod tests { out_point, TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), 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 1c0bf1326fb..44ff287b04d 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 @@ -106,7 +106,6 @@ impl AssetLockManager { } let lock = TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point, transaction: tx, account_index, @@ -2176,7 +2175,6 @@ mod tests { out_point, TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point, transaction: transaction.clone(), account_index: 0, @@ -2313,7 +2311,6 @@ mod tests { out_point, TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -2536,7 +2533,6 @@ mod tests { let out_point = OutPoint::new(tx.txid(), 0); let lock = TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point, transaction: tx, account_index: 0, @@ -2814,7 +2810,6 @@ mod tests { self.out_point, TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point: self.out_point, transaction: self.transaction.clone(), account_index: 0, @@ -4326,7 +4321,6 @@ mod tests { out_point, TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -4841,7 +4835,6 @@ mod tests { out_point, TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -4966,7 +4959,6 @@ mod tests { out_point, TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -5470,7 +5462,6 @@ mod tests { out_point, TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -5576,7 +5567,6 @@ mod tests { out_point, TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -5856,7 +5846,6 @@ mod tests { out_point, TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -6002,7 +5991,6 @@ mod tests { out_point, TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), 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 bb8e50229d4..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. /// 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 0ee24dbea4a..8fcf3f482f1 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs @@ -107,10 +107,11 @@ pub enum AssetLockStatus { /// Platform is the arbiter and rejects an already-spent outpoint /// with a typed error. RecoveredFromChain, - /// A wallet drain completed only after authenticated exact owned outputs - /// and proof-verified full consumption of the original outpoint. Committed - /// atomically with canonical shielded history by `complete_wallet_drain`. - /// Unlike generic `Consumed`, this is a durable drain-completion receipt. + /// 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, } @@ -128,8 +129,6 @@ impl AssetLockStatus { pub struct TrackedAssetLock { /// Stable host operation identifier, retained through consumption and restart. pub operation_id: Option<[u8; 16]>, - /// Exact signed Type18 attempts, persisted before dispatch and retained on completion. - pub shielded_funding_attempts: Vec>, /// The outpoint identifying this credit output (txid + vout). pub out_point: OutPoint, pub transaction: Transaction, @@ -152,7 +151,6 @@ impl From<&TrackedAssetLock> for AssetLockEntry { fn from(lock: &TrackedAssetLock) -> Self { Self { operation_id: lock.operation_id, - shielded_funding_attempts: lock.shielded_funding_attempts.clone(), out_point: lock.out_point, transaction: lock.transaction.clone(), account_index: lock.account_index, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/activity.rs b/packages/rs-platform-wallet/src/wallet/shielded/activity.rs index 1b81e284cda..7fb79b2b365 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/activity.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/activity.rs @@ -452,35 +452,7 @@ pub fn derive_activity_from_scan_data( input: &ScanDeriveInput, existing_cmxs: &std::collections::BTreeMap<[u8; 32], [u8; 32]>, ) -> DerivedActivity { - derive_activity_from_scan_data_inner(input, existing_cmxs, false) -} - -/// Reclassify the unowned remainder of a superseded scan aggregate. Preserve -/// all notes as nullifier/spend context while excluding already-owned outputs -/// from clusters, so another authored row cannot hide an unrelated receipt. -pub(super) fn derive_unowned_activity_from_scan_data( - input: &ScanDeriveInput, - existing_cmxs: &BTreeMap<[u8; 32], [u8; 32]>, -) -> DerivedActivity { - derive_activity_from_scan_data_inner(input, existing_cmxs, true) -} - -fn derive_activity_from_scan_data_inner( - input: &ScanDeriveInput, - existing_cmxs: &BTreeMap<[u8; 32], [u8; 32]>, - exclude_owned_outputs: bool, -) -> DerivedActivity { - let mut clusters = cluster_events(input); - if exclude_owned_outputs { - for cluster in clusters.values_mut() { - cluster - .received - .retain(|note| !existing_cmxs.contains_key(¬e.cmx)); - cluster - .outgoing - .retain(|note| !existing_cmxs.contains_key(¬e.cmx)); - } - } + let clusters = cluster_events(input); let mut out = DerivedActivity::default(); // Scan-derived entries deliberately carry NO height and NO // timestamp (dashwallet-ios restored-history acceptance criteria): diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 4231fa90652..731b35e51ad 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -231,11 +231,6 @@ pub struct NetworkShieldedCoordinator { /// other way round. lifecycle: tokio::sync::Mutex<()>, - /// Serialize scan-derived history persistence with exact drain replacement. - /// Held through persistence, so a previously derived aggregate cannot land - /// after its canonical replacement. Never protects viewing keys or network I/O. - pub(super) activity_checkpoint: tokio::sync::Mutex<()>, - /// Wallets whose per-subwallet store state has been successfully /// hydrated from the host's persisted snapshot this session /// (`restore_for_wallet` completed without error after the @@ -404,7 +399,6 @@ impl NetworkShieldedCoordinator { progress_handler: std::sync::Mutex::new(None), tree_progress_handler: std::sync::Mutex::new(None), lifecycle: tokio::sync::Mutex::new(()), - activity_checkpoint: tokio::sync::Mutex::new(()), hydrated: RwLock::new(std::collections::BTreeSet::new()), clear_generation: std::sync::atomic::AtomicU64::new(0), } @@ -1113,26 +1107,7 @@ impl NetworkShieldedCoordinator { .filter(|record| record.identity_user_abandoned) .map(|record| record.activity_id) .collect(); - // A note belongs to at most one activity row, and the live cache - // is at least as fresh as any snapshot. A snapshot row claiming a - // note that a different live row already owns is therefore stale, - // e.g. a scan aggregate that a completed drain replaced after this - // snapshot was loaded. Restoring it would count that note twice. - let mut claimed = BTreeMap::new(); - for live in store - .get_activity(*id, 0, usize::MAX) - .map_err(|e| crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()))? - { - claimed.extend(live.note_cmxs.iter().map(|cmx| (*cmx, live.id))); - } for entry in &sub.activity { - if entry - .note_cmxs - .iter() - .any(|cmx| claimed.get(cmx).is_some_and(|owner| *owner != entry.id)) - { - continue; - } let mut entry = entry.clone(); if abandoned.contains(&entry.id) && entry.status != ShieldedActivityStatus::Confirmed @@ -1480,7 +1455,6 @@ impl NetworkShieldedCoordinator { // id, so re-running is idempotent. Failures are logged and // swallowed: a derivation miss must never fail a sync pass. let mut notes = notes; - let activity_checkpoint = self.activity_checkpoint.lock().await; if let Err(e) = self .derive_activity_into_changeset(&subwallets, &mut notes.changeset) .await @@ -1488,6 +1462,18 @@ impl NetworkShieldedCoordinator { tracing::warn!(error = %e, "Shielded activity derivation failed; skipping this pass"); } + // Identity-funded shields have no input nullifiers and use an + // outputs-only empty-tree anchor, so the ordinary stale-reservation + // pass can never schedule them. After activity derivation has first + // cleared guards for outputs observed by this scan, drive each still- + // unresolved byte-identical transition once per wallet. Wallet-wide + // enumeration includes guards rehydrated from the durable store. + let wallet_ids: HashSet = subwallets.iter().map(|(id, _)| id.wallet_id).collect(); + for wallet_id in wallet_ids { + super::operations::redrive_pending_identity_shields(&self.sdk, &self.store, wallet_id) + .await; + } + // The note-side changeset already carries saves, synced // indices, AND the scan-detected spends, so split it per // WalletId directly — each per-wallet `WalletPersister.store` @@ -1517,15 +1503,6 @@ impl NetworkShieldedCoordinator { } } } - drop(activity_checkpoint); - - // Re-drive only after history is persisted and its checkpoint guard is - // released: network I/O must not block exact history reconciliation. - let wallet_ids: HashSet = subwallets.iter().map(|(id, _)| id.wallet_id).collect(); - for wallet_id in wallet_ids { - super::operations::redrive_pending_identity_shields(&self.sdk, &self.store, wallet_id) - .await; - } // Cooldown decision based on aggregate activity across // every subwallet — any new commitment scanned or @@ -1787,7 +1764,7 @@ impl NetworkShieldedCoordinator { /// /// All client-side (Option B): no node / DAPI query, only data the /// store already holds. - pub(super) async fn derive_activity_into_changeset( + async fn derive_activity_into_changeset( &self, subwallets: &[(SubwalletId, AccountViewingKeys)], changeset: &mut crate::changeset::ShieldedChangeSet, @@ -2153,7 +2130,7 @@ fn build_per_wallet_summary( /// in `fund_from_asset_lock.rs`). A malformed recipient (wrong length / /// off-curve) returns `false` (treated as external) so a corrupt row /// can't silently mask a real send. -pub(super) fn is_own_orchard_recipient(views: &AccountViewingKeys, recipient: &[u8]) -> bool { +fn is_own_orchard_recipient(views: &AccountViewingKeys, recipient: &[u8]) -> bool { let raw: [u8; 43] = match recipient.try_into() { Ok(b) => b, Err(_) => return false, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs b/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs deleted file mode 100644 index 2880d8c88b0..00000000000 --- a/packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs +++ /dev/null @@ -1,2218 +0,0 @@ -//! Restart recovery for a drain's exact Type18 attempts. Completion requires -//! both authenticated exact output sightings and proof-verified full consumption -//! of the original outpoint. Neither evidence alone establishes completion. - -use std::collections::{BTreeMap, BTreeSet}; -use std::future::Future; -use std::time::Duration; - -use dash_sdk::platform::shielded::{try_decrypt_note, try_recover_outgoing_note}; -use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; -use dash_sdk::platform::transition::put_settings::PutSettings; -use dash_sdk::query_types::ShieldedEncryptedNote; -use dashcore::blockdata::transaction::special_transaction::TransactionPayload; -use dashcore::hashes::{sha256d, Hash}; -use dashcore::OutPoint; -use dpp::asset_lock::StoredAssetLockInfo; -use dpp::balances::credits::CREDITS_PER_DUFF; -use dpp::identity::state_transition::AssetLockProved; -use dpp::serialization::{PlatformDeserializableUntrusted, PlatformSerializable, Signable}; -use dpp::state_transition::proof_result::StateTransitionProofResult; -use dpp::state_transition::shield_from_asset_lock_transition::accessors::ShieldFromAssetLockTransitionAccessorsV0; -use dpp::state_transition::{StateTransition, StateTransitionSingleSigned}; -use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; - -use crate::error::{ - as_asset_lock_proof_cl_height_too_low, is_asset_lock_proof_transaction_height_invalid, - is_instant_lock_proof_invalid, PlatformWalletError, -}; -use crate::wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; -use crate::wallet::PlatformWallet; - -use super::keys::AccountViewingKeys; -use super::store::{ShieldedStore, SubwalletId}; -use super::NetworkShieldedCoordinator; - -fn invalid(reason: impl std::fmt::Display) -> PlatformWalletError { - PlatformWalletError::Persistence(format!("invalid wallet drain recovery evidence: {reason}")) -} - -/// Recover the complete nonzero output set from the locally signed request, -/// not from arbitrary activity metadata. The asset-lock signature authenticates -/// the outpoint and bundle together. Missing/wrong keys cannot yield success. -fn attempt_outputs( - lock: &TrackedAssetLock, - bytes: &[u8], - keys: &BTreeMap, -) -> Result, PlatformWalletError> { - if lock.operation_id.is_none() - || lock.funding_type != AssetLockFundingType::AssetLockShieldedAddressTopUp - || lock.transaction.txid() != lock.out_point.txid - { - return Err(invalid("wrong operation, funding role or transaction")); - } - let st = StateTransition::deserialize_from_bytes_untrusted(bytes).map_err(invalid)?; - let StateTransition::ShieldFromAssetLock(transition) = &st else { - return Err(invalid("expected a signed Type18 transition")); - }; - if transition.asset_lock_proof().out_point() != Some(lock.out_point) { - return Err(invalid( - "attempt belongs to a different asset-lock outpoint", - )); - } - let Some(TransactionPayload::AssetLockPayloadType(payload)) = - &lock.transaction.special_transaction_payload - else { - return Err(invalid("tracked transaction has no asset-lock payload")); - }; - let credit_output = payload - .credit_outputs - .get(lock.out_point.vout as usize) - .ok_or_else(|| invalid("asset-lock credit output is missing"))?; - if credit_output.value != lock.amount - || transition.value_balance() == 0 - || lock - .amount - .checked_mul(CREDITS_PER_DUFF) - .is_none_or(|amount| transition.value_balance() > amount) - { - return Err(invalid("attempt value does not match the tracked lock")); - } - let public_key_hash = credit_output - .script_pubkey - .p2pkh_public_key_hash_bytes() - .ok_or_else(|| invalid("asset-lock credit output is not P2PKH"))?; - let digest = sha256d::Hash::hash(&st.signable_bytes().map_err(invalid)?); - dashcore::signer::verify_hash_signature( - digest.as_byte_array(), - transition.signature().as_slice(), - public_key_hash, - ) - .map_err(invalid)?; - - let mut outputs = BTreeSet::new(); - let mut recovered_value = 0u64; - for action in transition.actions() { - let wire = ShieldedEncryptedNote { - cmx: action.cmx.to_vec(), - nullifier: action.nullifier.to_vec(), - cv_net: action.cv_net.to_vec(), - encrypted_note: action.encrypted_note.clone(), - }; - for (account, keys) in keys { - let note = try_decrypt_note(&keys.prepared_ivk, &wire) - .map(|(note, _)| note) - .or_else(|| { - try_recover_outgoing_note(&keys.outgoing_viewing_key, &wire) - .map(|(note, _, _)| note) - }); - if let Some(note) = note { - if note.value().inner() > 0 { - if !outputs.insert((*account, action.cmx)) { - return Err(invalid("duplicate output commitment")); - } - recovered_value = recovered_value - .checked_add(note.value().inner()) - .ok_or_else(|| invalid("output value overflow"))?; - } - break; - } - } - } - if outputs.is_empty() || recovered_value != transition.value_balance() { - return Err(invalid( - "viewing keys cannot recover the complete funded output set", - )); - } - Ok(outputs) -} - -/// Replace only scan-derived aggregates that contain this exact attempt's -/// outputs. Reclassify the remaining scan data so unrelated same-anchor -/// receipts retain their own history row instead of being lost or counted twice. -fn drain_history_changeset( - store: &impl ShieldedStore, - wallet_id: [u8; 32], - keys: &BTreeMap, - actions: &[dpp::shielded::SerializedAction], - outputs: &BTreeSet<(u32, [u8; 32])>, -) -> Result { - use super::activity::{ - compute_activity_id, derive_unowned_activity_from_scan_data, ScanDeriveInput, - ShieldedActivityEntry, ShieldedActivityKind, ShieldedActivityStatus, ShieldedDirection, - }; - let mut history = crate::changeset::ShieldedChangeSet::default(); - for (account, views) in keys { - let cmxs: BTreeSet<_> = outputs - .iter() - .filter_map(|(owner, cmx)| (owner == account).then_some(*cmx)) - .collect(); - if cmxs.is_empty() { - continue; - } - let id = SubwalletId::new(wallet_id, *account); - let entry_id = compute_activity_id(&cmxs.iter().copied().collect::>()); - let mut amount = 0u64; - for action in actions.iter().filter(|action| cmxs.contains(&action.cmx)) { - let wire = ShieldedEncryptedNote { - cmx: action.cmx.to_vec(), - nullifier: action.nullifier.to_vec(), - cv_net: action.cv_net.to_vec(), - encrypted_note: action.encrypted_note.clone(), - }; - let note = try_decrypt_note(&views.prepared_ivk, &wire) - .map(|(note, _)| note) - .or_else(|| { - try_recover_outgoing_note(&views.outgoing_viewing_key, &wire) - .map(|(note, _, _)| note) - }) - .ok_or_else(|| invalid("history output no longer recoverable"))?; - amount = amount - .checked_add(note.value().inner()) - .ok_or_else(|| invalid("history amount overflow"))?; - } - let existing = store.get_activity(id, 0, usize::MAX).map_err(invalid)?; - let notes = store.get_all_notes(id).map_err(invalid)?; - let outgoing = store.get_outgoing_notes(id).map_err(invalid)?; - let mut retained = BTreeMap::new(); - for entry in &existing { - if entry.id != entry_id && entry.note_cmxs.iter().any(|cmx| cmxs.contains(cmx)) { - if entry.created_at_ms != 0 - || entry.block_height.is_some() - || !matches!( - entry.kind, - ShieldedActivityKind::Received - | ShieldedActivityKind::Sent - | ShieldedActivityKind::ShieldedSpend - ) - { - return Err(invalid("exact drain overlaps another authored activity")); - } - history.remove_activity_entry(id, entry.id); - } else { - retained.extend(entry.note_cmxs.iter().map(|cmx| (*cmx, entry.id))); - } - } - let canonical = ShieldedActivityEntry { - id: entry_id, - kind: ShieldedActivityKind::ShieldFromAssetLock, - direction: ShieldedDirection::In, - amount, - fee: None, - counterparty: None, - memo: None, - block_height: None, - status: ShieldedActivityStatus::Confirmed, - // Recovery does not know the original transaction's wall-clock time. - created_at_ms: existing - .iter() - .find(|entry| entry.id == entry_id) - .map_or(0, |entry| entry.created_at_ms), - min_note_position: notes - .iter() - .filter(|note| cmxs.contains(¬e.cmx)) - .map(|note| note.position) - .min(), - note_cmxs: cmxs.iter().copied().collect(), - spent_nullifiers: vec![], - }; - retained.extend(cmxs.iter().map(|cmx| (*cmx, entry_id))); - let own_addresses = outgoing - .iter() - .filter(|note| super::coordinator::is_own_orchard_recipient(views, ¬e.recipient)) - .map(|note| note.recipient.clone()) - .collect(); - let input = ScanDeriveInput { - notes, - outgoing, - own_addresses, - }; - let derived = derive_unowned_activity_from_scan_data(&input, &retained); - for entry in derived.new_entries { - history.record_activity_entry(id, entry); - } - history.record_activity_entry(id, canonical); - } - Ok(history) -} - -impl PlatformWallet { - /// Replay the newest byte-identical attempt first. A recognized proof - /// freshness rejection permits the normal same-outpoint proof rebuild; - /// every other verdict remains ambiguous until exact outputs are observed. - /// Returns true when an existing attempt still awaits authenticated scan. - pub(super) async fn replay_wallet_drain_attempt( - &self, - out_point: OutPoint, - settings: Option, - ) -> Result { - let Some(lock) = self - .asset_locks - .list_tracked_locks() - .await - .into_iter() - .find(|lock| lock.out_point == out_point && lock.operation_id.is_some()) - else { - return Ok(false); - }; - let Some(bytes) = lock.shielded_funding_attempts.last() else { - return Ok(false); - }; - let keys = self - .shielded_keys - .read() - .await - .clone() - .ok_or_else(|| invalid("shielded viewing keys are not bound"))?; - attempt_outputs(&lock, bytes, &keys)?; - // Also retries a prior failed store/flush before any network effect. - self.asset_locks - .persist_wallet_drain_attempt(out_point, bytes.clone()) - .await?; - let st = StateTransition::deserialize_from_bytes_untrusted(bytes).map_err(invalid)?; - let StateTransition::ShieldFromAssetLock(transition) = &st else { - return Err(invalid("expected a signed Type18 transition")); - }; - // Preserve historical evidence, but do not keep replaying a format the - // active protocol no longer accepts. The caller already resolved this - // operation to FromExistingAssetLock, so rebuilding uses the same lock. - if !transition - .verify_protocol_version(self.sdk.version().protocol_version) - .map_err(invalid)? - { - return Ok(false); - } - if let Err(error) = st.broadcast(&self.sdk, settings).await { - if is_instant_lock_proof_invalid(&error) - || as_asset_lock_proof_cl_height_too_low(&error).is_some() - || is_asset_lock_proof_transaction_height_invalid(&error) - { - if let StateTransition::ShieldFromAssetLock(ref transition) = st { - self.asset_locks - .invalidate_rejected_chain_proof( - &out_point, - transition.asset_lock_proof(), - &error, - ) - .await; - } - return Ok(false); - } - tracing::info!(%out_point, %error, "exact drain replay remains unresolved until its outputs are observed"); - } - Ok(true) - } - - /// Called while holding the wallet's shield single-flight guard. - pub(super) async fn persist_drain_shielded_attempt( - &self, - out_point: OutPoint, - transition: &StateTransition, - ) -> Result<(), PlatformWalletError> { - let Some(lock) = self - .asset_locks - .list_tracked_locks() - .await - .into_iter() - .find(|lock| lock.out_point == out_point && lock.operation_id.is_some()) - else { - return Ok(()); - }; - let bytes = transition.serialize_to_bytes().map_err(invalid)?; - { - let keys = self.shielded_keys.read().await; - let keys = keys - .as_ref() - .ok_or_else(|| invalid("shielded viewing keys are not bound"))?; - attempt_outputs(&lock, &bytes, keys)?; - } - self.asset_locks - .persist_wallet_drain_attempt(out_point, bytes) - .await - } - - /// The store's note/OVK sightings come exclusively from authenticated sync. - /// Keep all attempts, including unsuccessful retries, until and after a - /// durable consumed tombstone exists. A malformed attempt fails closed. - pub(super) async fn reconcile_wallet_drain( - &self, - coordinator: &NetworkShieldedCoordinator, - out_point: OutPoint, - ) -> Result { - self.reconcile_wallet_drain_with_consumption_check( - coordinator, - out_point, - |transition| async move { - // Status is read-only and must not inherit the long submission - // deadline. Bound the streaming wait as well as each request. - let timeout = Duration::from_secs(3); - let mut settings = PutSettings::default(); - settings.request_settings.connect_timeout = Some(timeout); - settings.request_settings.timeout = Some(timeout); - settings.request_settings.retries = Some(0); - settings.wait_timeout = Some(timeout); - tokio::time::timeout( - timeout, - transition.wait_for_affected_state::( - &self.sdk, - Some(settings), - ), - ) - .await - .map_err(|_| dash_sdk::Error::Generic("drain consumption proof timed out".into()))? - }, - ) - .await - } - - async fn reconcile_wallet_drain_with_consumption_check( - &self, - coordinator: &NetworkShieldedCoordinator, - out_point: OutPoint, - check_consumption: F, - ) -> Result - where - F: FnOnce(StateTransition) -> Fut, - Fut: Future>, - { - let Some(lock) = self - .asset_locks - .list_tracked_locks() - .await - .into_iter() - .find(|lock| lock.out_point == out_point && lock.operation_id.is_some()) - else { - return Ok(false); - }; - if lock.status == AssetLockStatus::DrainCompleted { - return Ok(true); - } - if lock.shielded_funding_attempts.is_empty() { - return Ok(false); - } - let keys = self - .shielded_keys - .read() - .await - .clone() - .ok_or_else(|| invalid("shielded viewing keys are not bound"))?; - let attempts = lock - .shielded_funding_attempts - .iter() - .map(|bytes| attempt_outputs(&lock, bytes, &keys)) - .collect::, _>>()?; - let observed = { - // A scan owns this lock across its network stream. Status must - // report Pending instead of holding shield_guard behind that I/O. - let Ok(store) = coordinator.store().try_read() else { - return Ok(false); - }; - let mut observed = BTreeSet::new(); - for account in keys.keys() { - let id = SubwalletId::new(self.wallet_id(), *account); - for note in store.get_all_notes(id).map_err(invalid)? { - observed.insert((*account, note.cmx)); - } - for note in store.get_outgoing_notes(id).map_err(invalid)? { - observed.insert((*account, note.cmx)); - } - } - observed - }; - let Some((matched_outputs, bytes)) = attempts - .iter() - .zip(&lock.shielded_funding_attempts) - .find(|(outputs, _)| outputs.is_subset(&observed)) - else { - return Ok(false); - }; - // The SDK verifies the affected-state proof against this exact signed - // transition's outpoint, already validated by attempt_outputs above. - // Orchard commitments alone do not bind an asset-lock outpoint. - let transition = - StateTransition::deserialize_from_bytes_untrusted(bytes).map_err(invalid)?; - let StateTransition::ShieldFromAssetLock(attempt) = &transition else { - return Err(invalid("expected the matched Type18 attempt")); - }; - // Every retry randomizes its Orchard bundle. History must describe the - // persisted attempt whose exact outputs were observed, not the latest - // submitted attempt, which may never have executed. - let actions = attempt.actions().to_vec(); - match check_consumption(transition).await { - Ok( - StateTransitionProofResult::VerifiedAssetLockConsumed( - StoredAssetLockInfo::FullyConsumed, - ) - | StateTransitionProofResult::VerifiedAssetLockConsumedWithAddressInfos( - StoredAssetLockInfo::FullyConsumed, - _, - ), - ) => {} - Ok(_) => return Ok(false), - Err(error) => { - tracing::info!(%out_point, %error, "drain awaits authenticated full consumption of its original outpoint"); - return Ok(false); - } - } - let operation_id = lock - .operation_id - .ok_or_else(|| invalid("operation UUID missing"))?; - // Serialize with scan derivation through its host persistence round. - // Build a replacement without mutating the memory cache: if the atomic - // history+tombstone write fails, a retry still sees the original rows. - let Ok(_checkpoint) = coordinator.activity_checkpoint.try_lock() else { - return Ok(false); - }; - let Ok(mut store) = coordinator.store().try_write() else { - return Ok(false); - }; - let history = - drain_history_changeset(&*store, self.wallet_id(), &keys, &actions, matched_outputs)?; - // Acquire every publication guard before persistence. The completion - // helper and all cache updates below are synchronous, so cancellation - // cannot expose a durable tombstone with stale aggregate history. - if !self - .asset_locks - .complete_wallet_drain(out_point, operation_id, history.clone())? - { - return Ok(false); - } - for (id, removed) in history.activity_removed { - for entry_id in removed { - store.remove_activity(id, &entry_id).map_err(invalid)?; - } - } - for (id, entries) in history.activity_entries { - for entry in entries { - store.save_activity(id, &entry).map_err(invalid)?; - } - } - tracing::info!(%out_point, "wallet drain completed from authenticated exact outputs and full original-outpoint consumption"); - Ok(true) - } - - pub(super) async fn reconcile_wallet_drains( - &self, - coordinator: &NetworkShieldedCoordinator, - ) -> Result<(), PlatformWalletError> { - for lock in self.asset_locks.list_tracked_locks().await { - if lock.operation_id.is_some() && lock.status != AssetLockStatus::DrainCompleted { - self.reconcile_wallet_drain(coordinator, lock.out_point) - .await?; - } - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::broadcaster::SpvBroadcaster; - use crate::changeset::{ - AssetLockEntry, ClientStartState, ClientWalletStartState, PersistenceCapabilities, - PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, - }; - use crate::events::PlatformEventManager; - use crate::spv::SpvRuntime; - use crate::test_support::funded_wallet_manager; - use crate::wallet::asset_lock::{AssetLockOperationContext, AssetLockOperationState}; - use crate::wallet::shielded::activity::{ - compute_activity_id, ShieldedActivityKind, ShieldedActivityStatus, - }; - use crate::wallet::shielded::{FileBackedShieldedStore, OrchardKeySet, ShieldedNote}; - use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; - use dashcore::{Network, ScriptBuf, Transaction, TxOut}; - use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; - use dpp::prelude::AssetLockProof; - use dpp::shielded::SerializedAction; - use dpp::state_transition::shield_from_asset_lock_transition::methods::ShieldFromAssetLockTransitionMethodsV0; - use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; - use dpp::version::PlatformVersion; - use grovedb_commitment_tree::{ - DashMemo, Domain, ExtractedNoteCommitment, Note, NoteValue, Nullifier, OrchardDomain, - RandomSeed, Rho, ValueCommitTrapdoor, ValueCommitment, - }; - use key_wallet::account::StandardAccountType; - use key_wallet::wallet::ManagedWalletInfo; - use key_wallet::Wallet; - use rand::{rngs::OsRng, RngCore}; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::{Arc, Mutex}; - use tokio::sync::Notify; - - fn encrypted_action(keys: &AccountViewingKeys) -> SerializedAction { - let recipient = keys.default_address; - let ovk = keys.outgoing_viewing_key.clone(); - let value_credits = 1_000; - let memo = [0; 36]; - let mut rng = OsRng; - - let (nf, rho) = loop { - let mut b = [0u8; 32]; - rng.fill_bytes(&mut b); - if let (Some(nf), Some(rho)) = ( - Nullifier::from_bytes(&b).into_option(), - Rho::from_bytes(&b).into_option(), - ) { - break (nf, rho); - } - }; - let rseed = loop { - let mut b = [0u8; 32]; - rng.fill_bytes(&mut b); - if let Some(rseed) = RandomSeed::from_bytes(b, &rho).into_option() { - break rseed; - } - }; - let value = NoteValue::from_raw(value_credits); - let note = Note::from_parts(recipient, value, rho, rseed) - .into_option() - .expect("valid note parts"); - let cmx = ExtractedNoteCommitment::from(note.commitment()); - - let rcv = loop { - let mut b = [0u8; 32]; - rng.fill_bytes(&mut b); - if let Some(rcv) = ValueCommitTrapdoor::from_bytes(b).into_option() { - break rcv; - } - }; - let cv = ValueCommitment::derive(value - NoteValue::from_raw(0), rcv); - - let ne = - grovedb_commitment_tree::OrchardNoteEncryption::::new(Some(ovk), note, memo); - let epk = OrchardDomain::::epk_bytes(ne.epk()); - let enc = ne.encrypt_note_plaintext(); - let out = ne.encrypt_outgoing_plaintext(&cv, &cmx, &mut rng); - - let mut encrypted_note = Vec::with_capacity(216); - encrypted_note.extend_from_slice(&epk.0); - encrypted_note.extend_from_slice(enc.as_ref()); - encrypted_note.extend_from_slice(&out); - assert_eq!(encrypted_note.len(), 216, "wire note must be 216 bytes"); - - SerializedAction { - cmx: cmx.to_bytes(), - nullifier: nf.to_bytes(), - cv_net: cv.to_bytes(), - rk: [0; 32], - spend_auth_sig: [0; 64], - encrypted_note, - } - } - - fn evidence_fixture() -> ( - TrackedAssetLock, - BTreeMap, - StateTransition, - ) { - evidence_fixture_at(PlatformVersion::latest()) - } - - fn evidence_fixture_at( - platform_version: &PlatformVersion, - ) -> ( - TrackedAssetLock, - BTreeMap, - StateTransition, - ) { - let keys = OrchardKeySet::from_seed(&[0x42; 32], Network::Testnet, 0) - .unwrap() - .viewing_keys(); - let secret = dashcore::secp256k1::SecretKey::from_slice(&[0x11; 32]).unwrap(); - let public = dashcore::PublicKey::new(dashcore::secp256k1::PublicKey::from_secret_key( - &dashcore::secp256k1::Secp256k1::new(), - &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, - }); - let actions = vec![encrypted_action(&keys), encrypted_action(&keys)]; - // Actual encrypted outputs and ECDSA signature, with no expensive Halo2 - // proof: admission is outside this test; sightings model verified sync. - let st = ShieldFromAssetLockTransition::try_from_asset_lock_with_bundle( - proof.clone(), - &secret.secret_bytes(), - actions, - 2_000, - [0; 32], - vec![], - [0; 64], - None, - platform_version, - ) - .unwrap(); - ( - TrackedAssetLock { - operation_id: Some([0x33; 16]), - shielded_funding_attempts: Vec::new(), - 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)]), - st, - ) - } - - struct RecoveryPersistence { - wallet: Wallet, - info: ManagedWalletInfo, - committed: Mutex>, - staged: Mutex>, - fail_store: AtomicBool, - fail_flush: AtomicBool, - commits_inline: AtomicBool, - fail_load: AtomicBool, - before_store: Mutex>>, - activity: Mutex>, - staged_activity: Mutex>, - fail_activity: AtomicBool, - omit_activity_capability: AtomicBool, - } - impl PlatformWalletPersistence for RecoveryPersistence { - fn store_commits_inline(&self) -> bool { - self.commits_inline.load(Ordering::SeqCst) - } - fn persistence_capabilities(&self) -> PersistenceCapabilities { - let bits = PersistenceCapabilities::WALLET_DRAIN.bits(); - PersistenceCapabilities::from_bits_retain( - if self.omit_activity_capability.load(Ordering::SeqCst) { - bits & !PersistenceCapabilities::SHIELDED_ACTIVITY_RECONCILIATION.bits() - } else { - bits - }, - ) - } - fn store( - &self, - _wallet_id: [u8; 32], - cs: PlatformWalletChangeSet, - ) -> Result<(), PersistenceError> { - if let Some(before_store) = self.before_store.lock().unwrap().as_ref() { - before_store(); - } - if self.fail_store.load(Ordering::SeqCst) { - return Err(PersistenceError::backend("store failed")); - } - if cs - .shielded - .as_ref() - .is_some_and(|cs| !cs.activity_entries.is_empty()) - && self.fail_activity.load(Ordering::SeqCst) - { - return Err(PersistenceError::backend("activity write failed")); - } - *self.staged_activity.lock().unwrap() = cs.shielded; - 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, - shielded_funding_attempts: row.shielded_funding_attempts.clone(), - 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 { - shielded: crate::changeset::ShieldedSyncStartState { - per_subwallet: self.activity.lock().unwrap().clone(), - ..Default::default() - }, - wallets: BTreeMap::from([( - self.wallet.compute_wallet_id(), - ClientWalletStartState { - 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) { - if let Some(cs) = self.staged_activity.lock().unwrap().take() { - let mut activity = self.activity.lock().unwrap(); - for (id, ids) in cs.activity_removed { - activity - .entry(id) - .or_default() - .activity - .retain(|entry| !ids.contains(&entry.id)); - } - for (id, entries) in cs.activity_entries { - let rows = &mut activity.entry(id).or_default().activity; - for entry in entries { - rows.retain(|old| old.id != entry.id); - rows.push(entry); - } - } - } - self.committed - .lock() - .unwrap() - .extend(self.staged.lock().unwrap().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), - commits_inline: AtomicBool::new(false), - fail_load: AtomicBool::new(false), - before_store: Mutex::new(None), - activity: Mutex::new(BTreeMap::new()), - staged_activity: Mutex::new(None), - fail_activity: AtomicBool::new(false), - omit_activity_capability: 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)), - ); - 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!("drain_recovery_{}", 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) - } - - 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)), - ); - *wallet.shielded_keys.write().await = old.shielded_keys.read().await.clone(); - wallet - } - - async fn observe( - coordinator: &NetworkShieldedCoordinator, - wallet_id: [u8; 32], - account: u32, - cmx: [u8; 32], - position: u64, - ) { - coordinator - .store() - .write() - .await - .save_note( - SubwalletId::new(wallet_id, account), - &ShieldedNote { - position, - cmx, - nullifier: [position as u8; 32], - block_height: 42, - is_spent: false, - value: 1_000, - note_data: vec![], - }, - ) - .unwrap(); - } - - async fn reconcile_fully_consumed( - wallet: &PlatformWallet, - coordinator: &NetworkShieldedCoordinator, - out_point: OutPoint, - ) -> Result { - wallet - .reconcile_wallet_drain_with_consumption_check( - coordinator, - out_point, - |transition| async move { - let StateTransition::ShieldFromAssetLock(transition) = transition else { - panic!("expected Type18"); - }; - assert_eq!(transition.asset_lock_proof().out_point(), Some(out_point)); - Ok(StateTransitionProofResult::VerifiedAssetLockConsumed( - StoredAssetLockInfo::FullyConsumed, - )) - }, - ) - .await - } - - async fn assert_exact_drain_history( - wallet: &PlatformWallet, - coordinator: &NetworkShieldedCoordinator, - outputs: &BTreeSet<(u32, [u8; 32])>, - ) { - let history = coordinator - .store() - .read() - .await - .get_activity(SubwalletId::new(wallet.wallet_id(), 0), 0, 10) - .unwrap(); - assert_eq!(history.len(), 1, "recovery upserts one canonical transfer"); - let entry = &history[0]; - let cmxs = outputs.iter().map(|(_, cmx)| *cmx).collect::>(); - assert_eq!(entry.id, compute_activity_id(&cmxs)); - assert_eq!(entry.kind, ShieldedActivityKind::ShieldFromAssetLock); - assert_eq!(entry.status, ShieldedActivityStatus::Confirmed); - assert_eq!(entry.amount, 2_000); - assert_eq!( - entry.note_cmxs.iter().copied().collect::>(), - cmxs.into_iter().collect() - ); - } - - #[tokio::test] - async fn should_release_viewing_key_lock_before_the_persistence_callback() { - let (lock, keys, st) = evidence_fixture(); - let (wallet, persistence, _, _dir) = wallet_fixture(&lock, keys).await; - let keys = wallet.shielded_keys.clone(); - *persistence.before_store.lock().unwrap() = Some(Box::new(move || { - assert!( - keys.try_write().is_ok(), - "viewing-key writers must not wait on persistence callbacks" - ); - })); - wallet - .persist_drain_shielded_attempt(lock.out_point, &st) - .await - .unwrap(); - assert_eq!( - persistence.committed.lock().unwrap()[&lock.out_point] - .shielded_funding_attempts - .len(), - 1 - ); - } - - #[tokio::test] - async fn should_release_registration_after_returned_prebuild_load_failure() { - let (mut lock, keys, _) = evidence_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::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, - crate::wallet::asset_lock::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 - .asset_locks - .wallet_drain_operation_status(operation.operation_id) - .await - .unwrap() - .state, - AssetLockOperationState::NotSent - ); - assert!(operation.snapshot().out_point.is_none()); - } - - #[tokio::test] - async fn should_require_both_exact_outputs_and_fully_consumed_original_outpoint() { - for initial_status in [ - AssetLockStatus::RecoveredFromChain, - AssetLockStatus::Consumed, - ] { - let (mut lock, keys, st) = evidence_fixture(); - lock.status = initial_status; - lock.shielded_funding_attempts = vec![st.serialize_to_bytes().unwrap()]; - let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); - let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; - assert_eq!( - wallet - .asset_locks - .wallet_drain_operation_status([0x33; 16]) - .await - .unwrap() - .state, - AssetLockOperationState::Pending - ); - // A successful affected-state response alone cannot complete a fresh - // submit or a recovery: its exact outputs must have been scanned too. - assert!( - !reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) - .await - .unwrap() - ); - for (index, (account, cmx)) in outputs.iter().enumerate() { - observe( - &coordinator, - wallet.wallet_id(), - *account, - *cmx, - index as u64, - ) - .await; - } - let partial = dpp::asset_lock::reduced_asset_lock_value::AssetLockValue::new( - 1_000_000, - vec![], - 500_000, - vec![], - dpp::version::PlatformVersion::latest(), - ) - .unwrap(); - // The bundle can be replayed with another lock: sightings do not prove - // that this original lock is fully consumed. Absence, partial use, - // unrelated response variants and unavailable proofs stay pending. - for result in [ - Ok(StateTransitionProofResult::VerifiedAssetLockConsumed( - StoredAssetLockInfo::NotPresent, - )), - Ok(StateTransitionProofResult::VerifiedAssetLockConsumed( - StoredAssetLockInfo::PartiallyConsumed(partial), - )), - Ok(StateTransitionProofResult::VerifiedShieldedNullifiers( - vec![], - )), - Err(dash_sdk::Error::Generic( - "consumption proof unavailable".into(), - )), - ] { - assert!(!wallet - .reconcile_wallet_drain_with_consumption_check( - &coordinator, - lock.out_point, - |transition| async move { - let StateTransition::ShieldFromAssetLock(transition) = transition - else { - panic!("expected Type18") - }; - assert_eq!( - transition.asset_lock_proof().out_point(), - Some(lock.out_point) - ); - result - }, - ) - .await - .unwrap()); - assert_ne!( - persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::DrainCompleted - ); - assert!( - coordinator - .store() - .read() - .await - .get_activity(SubwalletId::new(wallet.wallet_id(), 0), 0, 10) - .unwrap() - .is_empty(), - "one-sided evidence must not create confirmed history" - ); - } - assert!( - reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) - .await - .unwrap() - ); - assert_exact_drain_history(&wallet, &coordinator, &outputs).await; - assert_eq!( - persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::DrainCompleted - ); - let wallet = restart_wallet(wallet, persistence).await; - assert_eq!( - wallet - .asset_locks - .wallet_drain_operation_status([0x33; 16]) - .await - .unwrap() - .state, - AssetLockOperationState::Completed - ); - } - } - - #[tokio::test] - async fn should_not_trust_legacy_consumed_without_signed_attempts() { - let (mut lock, keys, _) = evidence_fixture(); - lock.status = AssetLockStatus::Consumed; - let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; - assert!(wallet - .asset_locks - .consume_asset_lock(&lock.out_point) - .await - .is_err()); - assert!(!wallet - .reconcile_wallet_drain_with_consumption_check( - &coordinator, - lock.out_point, - |_| async { - panic!("without signed evidence no consumption proof should be requested"); - } - ) - .await - .unwrap()); - let wallet = restart_wallet(wallet, persistence.clone()).await; - assert_eq!( - wallet - .shielded_wallet_drain_operation_status(&coordinator, [0x33; 16]) - .await - .unwrap() - .state, - AssetLockOperationState::Pending - ); - } - - #[tokio::test] - async fn should_evict_completed_context_after_last_lease_and_keep_pending_context() { - for complete_while_registered in [false, true] { - let (mut lock, keys, st) = evidence_fixture(); - lock.shielded_funding_attempts = vec![st.serialize_to_bytes().unwrap()]; - let outputs = - attempt_outputs(&lock, &lock.shielded_funding_attempts[0], &keys).unwrap(); - let (wallet, _, coordinator, _dir) = wallet_fixture(&lock, keys).await; - let operation = Arc::new(AssetLockOperationContext::new([0x33; 16])); - 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([0x33; 16]), - )); - 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 - }; - for (index, (account, cmx)) in outputs.iter().enumerate() { - observe( - &coordinator, - wallet.wallet_id(), - *account, - *cmx, - index as u64, - ) - .await; - } - assert!( - reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) - .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 tombstone also releases a newly - // registered context once that caller's final lease leaves. - let restored = Arc::new(AssetLockOperationContext::new([0x33; 16])); - let weak = Arc::downgrade(&restored); - let lease = wallet.register_wallet_drain_operation(restored.clone()); - drop(restored); - assert_eq!( - wallet - .asset_locks - .wallet_drain_operation_status([0x33; 16]) - .await - .unwrap() - .state, - AssetLockOperationState::Completed - ); - drop(lease); - assert!(weak.upgrade().is_none()); - } - } - - #[tokio::test] - async fn should_release_cancelled_drain_waiting_for_the_shield_guard() { - let (mut lock, keys, _) = evidence_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::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, - crate::wallet::asset_lock::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 - .asset_locks - .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 - .asset_locks - .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_reconcile_drain_after_dispatch_crash_using_an_earlier_exact_attempt() { - let (lock, keys, st) = evidence_fixture(); - let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); - let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys.clone()).await; - // Dispatch is permitted only after these exact signed bytes are durable. - wallet - .persist_drain_shielded_attempt(lock.out_point, &st) - .await - .unwrap(); - assert_eq!( - persistence.committed.lock().unwrap()[&lock.out_point].shielded_funding_attempts, - vec![st.serialize_to_bytes().unwrap()] - ); - // A later randomized retry must never erase the first dispatched attempt. - let (_, _, later) = evidence_fixture(); - wallet - .persist_drain_shielded_attempt(lock.out_point, &later) - .await - .unwrap(); - let wallet = restart_wallet(wallet, persistence.clone()).await; - assert_eq!( - wallet.asset_locks.list_tracked_locks().await[0] - .shielded_funding_attempts - .len(), - 2 - ); - assert_eq!( - wallet - .shielded_wallet_drain_operation_status(&coordinator, [0x33; 16]) - .await - .unwrap() - .state, - AssetLockOperationState::Pending - ); - // Another wallet/account, and even a partial matching set, cannot confirm. - for (index, (account, cmx)) in outputs.iter().enumerate() { - observe(&coordinator, [0x99; 32], *account, *cmx, index as u64).await; - observe(&coordinator, wallet.wallet_id(), 9, *cmx, index as u64).await; - } - assert!(!wallet - .reconcile_wallet_drain(&coordinator, lock.out_point) - .await - .unwrap()); - let (account, first) = outputs.iter().next().unwrap(); - observe(&coordinator, wallet.wallet_id(), *account, *first, 0).await; - assert!(!wallet - .reconcile_wallet_drain(&coordinator, lock.out_point) - .await - .unwrap()); - for (index, (account, cmx)) in outputs.iter().enumerate() { - observe( - &coordinator, - wallet.wallet_id(), - *account, - *cmx, - index as u64, - ) - .await; - } - // History and terminal tombstone commit together, before the memory - // cache is updated. The store guard prevents a cancellation gap; - // the unrelated viewing-key slot stays available to callbacks. - let activity_store = coordinator.store().clone(); - let key_slot = wallet.shielded_keys.clone(); - *persistence.before_store.lock().unwrap() = Some(Box::new(move || { - assert!(key_slot.try_write().is_ok()); - assert!(activity_store.try_read().is_err()); - })); - assert!( - reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) - .await - .unwrap() - ); - assert_exact_drain_history(&wallet, &coordinator, &outputs).await; - let status = wallet - .shielded_wallet_drain_operation_status(&coordinator, [0x33; 16]) - .await - .unwrap(); - assert_eq!(status.state, AssetLockOperationState::Completed); - assert_eq!(status.out_point, Some(lock.out_point)); - assert_eq!( - persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::DrainCompleted - ); - let wallet = restart_wallet(wallet, persistence).await; - assert_eq!( - wallet - .shielded_wallet_drain_operation_status(&coordinator, [0x33; 16]) - .await - .unwrap() - .state, - AssetLockOperationState::Completed - ); - let next = Arc::new(AssetLockOperationContext::new([0x44; 16])); - let _registration = wallet.register_wallet_drain_operation(next.clone()); - assert!( - wallet - .asset_locks - .begin_wallet_drain(&next) - .await - .unwrap() - .is_none(), - "a new explicitly requested drain is no longer blocked" - ); - } - - #[tokio::test] - async fn should_publish_committed_drain_history_without_a_cancellation_gap() { - for block_manager in [true, false] { - let (lock, keys, st) = evidence_fixture(); - let views = keys[&0].clone(); - let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); - let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; - wallet - .persist_drain_shielded_attempt(lock.out_point, &st) - .await - .unwrap(); - for (index, (account, cmx)) in outputs.iter().enumerate() { - observe( - &coordinator, - wallet.wallet_id(), - *account, - *cmx, - index as u64, - ) - .await; - } - let id = SubwalletId::new(wallet.wallet_id(), 0); - let unrelated = [0x93; 32]; - observe(&coordinator, wallet.wallet_id(), 0, unrelated, 2).await; - let mut scan = crate::changeset::ShieldedChangeSet::default(); - coordinator - .derive_activity_into_changeset(&[(id, views.clone())], &mut scan) - .await - .unwrap(); - persistence - .store( - wallet.wallet_id(), - PlatformWalletChangeSet { - shielded: Some(scan), - ..Default::default() - }, - ) - .unwrap(); - persistence.flush(wallet.wallet_id()).unwrap(); - - // On the old path, the callback can take either publication lock: - // the first post-commit poll then suspends and can be cancelled. - let manager_blocker = Arc::new(Mutex::new(None)); - let store_blocker = Arc::new(Mutex::new(None)); - let manager_slot = manager_blocker.clone(); - let store_slot = store_blocker.clone(); - let manager = wallet.wallet_manager.clone(); - let store = coordinator.store().clone(); - *persistence.before_store.lock().unwrap() = Some(Box::new(move || { - if block_manager { - *manager_slot.lock().unwrap() = manager.clone().try_write_owned().ok(); - } else { - *store_slot.lock().unwrap() = store.clone().try_write_owned().ok(); - } - })); - let mut future = Box::pin(reconcile_fully_consumed( - &wallet, - &coordinator, - lock.out_point, - )); - let result = futures::poll!(&mut future); - drop(future); - manager_blocker.lock().unwrap().take(); - store_blocker.lock().unwrap().take(); - assert!(matches!(result, std::task::Poll::Ready(Ok(true))), - "durable commit must publish all caches before returning to the executor: {result:?}"); - *persistence.before_store.lock().unwrap() = None; - let history = coordinator - .store() - .read() - .await - .get_activity(id, 0, 10) - .unwrap(); - assert_eq!(history.len(), 2); - assert!(history - .iter() - .any(|entry| entry.note_cmxs == vec![unrelated] && entry.amount == 1_000)); - assert!(history.iter().any(|entry| entry.kind - == ShieldedActivityKind::ShieldFromAssetLock - && entry.amount == 2_000)); - assert_eq!(persistence.activity.lock().unwrap()[&id].activity.len(), 2); - assert_eq!( - persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::DrainCompleted - ); - assert_eq!( - wallet.asset_locks.list_tracked_locks().await[0].status, - AssetLockStatus::DrainCompleted - ); - let mut next_scan = crate::changeset::ShieldedChangeSet::default(); - coordinator - .derive_activity_into_changeset(&[(id, views)], &mut next_scan) - .await - .unwrap(); - assert!( - next_scan.activity_entries.is_empty(), - "a later scan cannot duplicate/overwrite the canonical drain" - ); - } - } - - #[tokio::test] - async fn should_leave_drain_pending_when_publication_guards_are_busy() { - for block_manager in [true, false] { - let (lock, keys, st) = evidence_fixture(); - let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); - let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; - wallet - .persist_drain_shielded_attempt(lock.out_point, &st) - .await - .unwrap(); - for (index, (account, cmx)) in outputs.iter().enumerate() { - observe( - &coordinator, - wallet.wallet_id(), - *account, - *cmx, - index as u64, - ) - .await; - } - let proof_ready = Notify::new(); - let mut future = Box::pin(wallet.reconcile_wallet_drain_with_consumption_check( - &coordinator, - lock.out_point, - |_| async { - proof_ready.notified().await; - Ok(StateTransitionProofResult::VerifiedAssetLockConsumed( - StoredAssetLockInfo::FullyConsumed, - )) - }, - )); - assert!(futures::poll!(&mut future).is_pending()); - let manager_guard = block_manager.then(|| wallet.wallet_manager.try_read().unwrap()); - let store_guard = (!block_manager).then(|| coordinator.store().try_write().unwrap()); - proof_ready.notify_one(); - let result = futures::poll!(&mut future); - drop(future); - drop(manager_guard); - drop(store_guard); - assert_ne!( - persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::DrainCompleted, - "cancellation/contended publication must not commit terminal state" - ); - assert!(matches!(result, std::task::Poll::Ready(Ok(false)))); - assert!( - reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) - .await - .unwrap() - ); - assert_exact_drain_history(&wallet, &coordinator, &outputs).await; - } - } - - #[tokio::test] - async fn should_return_pending_status_without_waiting_for_a_scan() { - let (lock, keys, st) = evidence_fixture(); - let (wallet, _, coordinator, _dir) = wallet_fixture(&lock, keys).await; - wallet - .persist_drain_shielded_attempt(lock.out_point, &st) - .await - .unwrap(); - let scan = coordinator.store().write().await; - let mut future = Box::pin( - wallet.shielded_wallet_drain_operation_status(&coordinator, lock.operation_id.unwrap()), - ); - 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))); - assert!(wallet.shield_guard.try_lock().is_ok()); - drop(scan); - } - - #[tokio::test] - async fn should_publish_inline_committed_history_when_flush_notification_fails() { - let (lock, keys, st) = evidence_fixture(); - let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); - let views = keys[&0].clone(); - let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; - wallet - .persist_drain_shielded_attempt(lock.out_point, &st) - .await - .unwrap(); - for (index, (account, cmx)) in outputs.iter().enumerate() { - observe( - &coordinator, - wallet.wallet_id(), - *account, - *cmx, - index as u64, - ) - .await; - } - persistence.commits_inline.store(true, Ordering::SeqCst); - persistence.fail_flush.store(true, Ordering::SeqCst); - assert!( - reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) - .await - .unwrap() - ); - assert_eq!( - persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::DrainCompleted - ); - assert_eq!( - wallet.asset_locks.list_tracked_locks().await[0].status, - AssetLockStatus::DrainCompleted - ); - assert_exact_drain_history(&wallet, &coordinator, &outputs).await; - let id = SubwalletId::new(wallet.wallet_id(), 0); - assert_eq!(persistence.activity.lock().unwrap()[&id].activity.len(), 1); - let mut scan = crate::changeset::ShieldedChangeSet::default(); - coordinator - .derive_activity_into_changeset(&[(id, views)], &mut scan) - .await - .unwrap(); - assert!(scan.activity_entries.is_empty()); - assert!( - reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) - .await - .unwrap() - ); - } - - #[tokio::test] - async fn should_fail_closed_on_attempt_or_completion_persistence_failure() { - let (lock, keys, st) = evidence_fixture(); - let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); - let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; - persistence.fail_store.store(true, Ordering::SeqCst); - assert!(wallet - .persist_drain_shielded_attempt(lock.out_point, &st) - .await - .is_err()); - assert!(persistence.committed.lock().unwrap()[&lock.out_point] - .shielded_funding_attempts - .is_empty()); - persistence.fail_store.store(false, Ordering::SeqCst); - persistence.fail_flush.store(true, Ordering::SeqCst); - assert!(wallet - .persist_drain_shielded_attempt(lock.out_point, &st) - .await - .is_err()); - assert!(persistence.committed.lock().unwrap()[&lock.out_point] - .shielded_funding_attempts - .is_empty()); - persistence.fail_flush.store(false, Ordering::SeqCst); - wallet - .persist_drain_shielded_attempt(lock.out_point, &st) - .await - .unwrap(); - for (index, (account, cmx)) in outputs.iter().enumerate() { - observe( - &coordinator, - wallet.wallet_id(), - *account, - *cmx, - index as u64, - ) - .await; - } - persistence.fail_flush.store(true, Ordering::SeqCst); - assert!( - reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) - .await - .is_err() - ); - assert_ne!( - wallet.asset_locks.list_tracked_locks().await[0].status, - AssetLockStatus::DrainCompleted - ); - assert_ne!( - persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::DrainCompleted - ); - assert!(coordinator - .store() - .read() - .await - .get_activity(SubwalletId::new(wallet.wallet_id(), 0), 0, 10) - .unwrap() - .is_empty()); - persistence.fail_flush.store(false, Ordering::SeqCst); - assert!( - reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) - .await - .unwrap() - ); - assert_exact_drain_history(&wallet, &coordinator, &outputs).await; - } - - struct FixedDrainSigner; - #[async_trait::async_trait] - impl key_wallet::signer::Signer for FixedDrainSigner { - type Error = String; - fn supported_methods(&self) -> &[key_wallet::signer::SignerMethod] { - &[key_wallet::signer::SignerMethod::Digest] - } - async fn public_key( - &self, - _: &key_wallet::bip32::DerivationPath, - ) -> Result { - Ok(dashcore::secp256k1::PublicKey::from_secret_key( - &dashcore::secp256k1::Secp256k1::new(), - &dashcore::secp256k1::SecretKey::from_slice(&[0x11; 32]).unwrap(), - )) - } - async fn sign_ecdsa( - &self, - path: &key_wallet::bip32::DerivationPath, - digest: [u8; 32], - ) -> Result< - ( - dashcore::secp256k1::ecdsa::Signature, - dashcore::secp256k1::PublicKey, - ), - String, - > { - let signature = dashcore::secp256k1::Secp256k1::new().sign_ecdsa( - &dashcore::secp256k1::Message::from_digest(digest), - &dashcore::secp256k1::SecretKey::from_slice(&[0x11; 32]).unwrap(), - ); - Ok((signature, self.public_key(path).await?)) - } - } - #[async_trait::async_trait] - impl key_wallet::signer::ExtendedPubKeySigner for FixedDrainSigner { - async fn extended_public_key( - &self, - _: &key_wallet::bip32::DerivationPath, - ) -> Result { - Err("not used by asset-lock signing".into()) - } - } - - #[tokio::test] - async fn should_validate_actual_outer_external_signer_for_each_protocol_generation() { - for version in [PlatformVersion::get(13).unwrap(), PlatformVersion::latest()] { - let (lock, keys, template) = evidence_fixture_at(version); - let StateTransition::ShieldFromAssetLock(attempt) = template else { - panic!("Type18 expected"); - }; - let signed = ShieldFromAssetLockTransition::try_from_asset_lock_with_bundle_and_signer( - attempt.asset_lock_proof().clone(), - &key_wallet::bip32::DerivationPath::default(), - &FixedDrainSigner, - attempt.actions().to_vec(), - attempt.value_balance(), - [0; 32], - vec![], - [0; 64], - None, - version, - ) - .await - .unwrap(); - assert_eq!( - attempt_outputs(&lock, &signed.serialize_to_bytes().unwrap(), &keys) - .unwrap() - .len(), - 2 - ); - assert!(matches!( - (&signed, version.protocol_version), - ( - StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(_)), - 13 - ) | ( - StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V1(_)), - 14 - ) - )); - let mut altered = signed; - if let StateTransition::ShieldFromAssetLock(attempt) = &mut altered { - attempt.set_value_balance(attempt.value_balance() + 1); - } - assert!(attempt_outputs(&lock, &altered.serialize_to_bytes().unwrap(), &keys).is_err()); - } - } - - #[tokio::test] - async fn should_recover_outer_v0_and_v1_and_retire_only_replay_format() { - let v13 = PlatformVersion::get(13).unwrap(); - let (mut lock, keys, old) = evidence_fixture_at(v13); - assert!(matches!( - &old, - StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(_)) - )); - let old_bytes = old.serialize_to_bytes().unwrap(); - let outputs = attempt_outputs(&lock, &old_bytes, &keys).unwrap(); - lock.shielded_funding_attempts.push(old_bytes.clone()); - let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; - let wallet = restart_wallet(wallet, persistence.clone()).await; - // No SDK network expectation: retired V0 is not dispatched to PV14. - assert!(!wallet - .replay_wallet_drain_attempt(lock.out_point, None) - .await - .unwrap()); - let (new_lock, new_keys, current) = evidence_fixture(); - assert_eq!(new_lock.out_point, lock.out_point); - assert!(matches!( - ¤t, - StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V1(_)) - )); - assert!(attempt_outputs(&lock, ¤t.serialize_to_bytes().unwrap(), &new_keys).is_ok()); - wallet - .persist_drain_shielded_attempt(lock.out_point, ¤t) - .await - .unwrap(); - let operation = Arc::new(AssetLockOperationContext::new(lock.operation_id.unwrap())); - let _registration = wallet.register_wallet_drain_operation(operation.clone()); - assert_eq!( - wallet - .asset_locks - .begin_wallet_drain(&operation) - .await - .unwrap(), - Some(lock.out_point) - ); - let committed = persistence.committed.lock().unwrap().clone(); - assert_eq!( - committed.len(), - 1, - "protocol upgrade must never create a second Core lock" - ); - assert_eq!( - committed[&lock.out_point].shielded_funding_attempts, - vec![old_bytes, current.serialize_to_bytes().unwrap()] - ); - // Historical V0 is still usable completion evidence after V1 is appended. - for (index, (account, cmx)) in outputs.iter().enumerate() { - observe( - &coordinator, - wallet.wallet_id(), - *account, - *cmx, - index as u64, - ) - .await; - } - assert!( - reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) - .await - .unwrap() - ); - assert_exact_drain_history(&wallet, &coordinator, &outputs).await; - } - - #[tokio::test] - async fn should_refuse_drain_without_viewing_keys_before_core_funding() { - for empty_binding in [false, true] { - let (mut lock, keys, _) = evidence_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::super::prover::CachedOrchardProver::new(); - let operation = Arc::new(AssetLockOperationContext::new([0x97; 16])); - let error = wallet - .shielded_fund_from_asset_lock( - &coordinator, - crate::wallet::asset_lock::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_refuse_drain_without_atomic_activity_capability_before_build() { - let (lock, keys, _) = evidence_fixture(); - let (wallet, persistence, _, _dir) = wallet_fixture(&lock, keys).await; - persistence - .omit_activity_capability - .store(true, Ordering::SeqCst); - let operation = Arc::new(AssetLockOperationContext::new([0x98; 16])); - let _registration = wallet.register_wallet_drain_operation(operation.clone()); - let error = wallet - .asset_locks - .begin_wallet_drain(&operation) - .await - .unwrap_err(); - assert!(error - .to_string() - .contains("shielded_activity_reconciliation")); - assert!(operation.snapshot().out_point.is_none()); - assert_eq!(persistence.committed.lock().unwrap().len(), 1); - } - - #[tokio::test] - async fn should_atomically_replace_scan_aggregate_preserving_other_receipt_after_restart() { - let (lock, keys, st) = evidence_fixture(); - let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); - let views = keys[&0].clone(); - let (wallet, persistence, coordinator, dir) = wallet_fixture(&lock, keys.clone()).await; - wallet - .persist_drain_shielded_attempt(lock.out_point, &st) - .await - .unwrap(); - let id = SubwalletId::new(wallet.wallet_id(), 0); - for (index, (account, cmx)) in outputs.iter().enumerate() { - observe( - &coordinator, - wallet.wallet_id(), - *account, - *cmx, - index as u64, - ) - .await; - } - let unrelated_cmx = [0x91; 32]; - observe(&coordinator, wallet.wallet_id(), 0, unrelated_cmx, 2).await; - let mut scan = crate::changeset::ShieldedChangeSet::default(); - coordinator - .derive_activity_into_changeset(&[(id, views)], &mut scan) - .await - .unwrap(); - let aggregate = coordinator - .store() - .read() - .await - .get_activity(id, 0, 10) - .unwrap(); - assert_eq!(aggregate.len(), 1); - assert_eq!(aggregate[0].amount, 3_000); - assert_eq!(aggregate[0].note_cmxs.len(), 3); - // A distinct authored C discovered at the same anchor must survive, - // and must not cause residual B to be suppressed by overlap deduping. - let authored_cmx = [0x92; 32]; - observe(&coordinator, wallet.wallet_id(), 0, authored_cmx, 3).await; - let mut authored = aggregate[0].clone(); - authored.id = compute_activity_id(&[authored_cmx]); - authored.note_cmxs = vec![authored_cmx]; - authored.amount = 1_000; - authored.kind = ShieldedActivityKind::Shield; - authored.created_at_ms = 123; - coordinator - .store() - .write() - .await - .save_activity(id, &authored) - .unwrap(); - let before = coordinator - .store() - .read() - .await - .get_activity(id, 0, 10) - .unwrap(); - let snapshot = crate::changeset::ShieldedSubwalletStartState { - activity: before.clone(), - notes: coordinator.store().read().await.get_all_notes(id).unwrap(), - ..Default::default() - }; - persistence.activity.lock().unwrap().insert(id, snapshot); - // Only the activity callback fails; the lock write itself would succeed. - persistence.fail_activity.store(true, Ordering::SeqCst); - assert!( - reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) - .await - .is_err() - ); - let restored = persistence.load().unwrap(); - assert_ne!( - persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::DrainCompleted - ); - assert_eq!(restored.shielded.per_subwallet[&id].activity, before); - // Reopen a fresh coordinator cache entirely from the host snapshot. - drop(coordinator); - let wallet = restart_wallet(wallet, persistence.clone()).await; - let path = dir.0.join("reopened.sqlite"); - let store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); - let coordinator = - NetworkShieldedCoordinator::new(wallet.sdk.clone(), Network::Testnet, path, store); - coordinator - .register_wallet(wallet.wallet_id(), keys, wallet.persister().clone()) - .await - .unwrap(); - coordinator - .restore_for_wallet(wallet.wallet_id(), &restored.shielded) - .await - .unwrap(); - persistence.fail_activity.store(false, Ordering::SeqCst); - assert!( - reconcile_fully_consumed(&wallet, &coordinator, lock.out_point) - .await - .unwrap() - ); - let durable = persistence.load().unwrap(); - let rows = &durable.shielded.per_subwallet[&id].activity; - assert_eq!(rows.len(), 3); - assert!(rows.iter().all(|row| row.id != aggregate[0].id)); - assert_eq!(rows.iter().map(|row| row.amount).sum::(), 4_000); - assert!(rows.iter().any( - |row| row.kind == ShieldedActivityKind::ShieldFromAssetLock && row.amount == 2_000 - )); - assert!(rows - .iter() - .any(|row| row.kind == ShieldedActivityKind::Received - && row.note_cmxs == vec![unrelated_cmx] - && row.amount == 1_000)); - assert!(rows.contains(&authored)); - assert_eq!( - persistence.committed.lock().unwrap()[&lock.out_point].status, - AssetLockStatus::DrainCompleted - ); - // A new scan cannot recreate the removed aggregate or double count A. - let mut scan = crate::changeset::ShieldedChangeSet::default(); - coordinator - .derive_activity_into_changeset( - &[( - id, - wallet.shielded_keys.read().await.as_ref().unwrap()[&0].clone(), - )], - &mut scan, - ) - .await - .unwrap(); - // The existing live C may gain a confirmation height; no aggregate - // or duplicate of A/B may be emitted. - assert!(scan - .activity_entries - .values() - .flatten() - .all(|entry| entry.id == authored.id)); - assert_eq!( - coordinator - .store() - .read() - .await - .get_activity(id, 0, 10) - .unwrap() - .len(), - 3 - ); - // A bind snapshot loaded before completion and restored afterwards - // must not resurrect the replaced aggregate beside the canonical rows. - coordinator - .restore_for_wallet(wallet.wallet_id(), &restored.shielded) - .await - .unwrap(); - let live = coordinator - .store() - .read() - .await - .get_activity(id, 0, 10) - .unwrap(); - assert_eq!(live.len(), 3); - assert!(live.iter().all(|row| row.id != aggregate[0].id)); - assert_eq!(live.iter().map(|row| row.amount).sum::(), 4_000); - } - - #[test] - fn should_reject_foreign_corrupted_unsigned_or_incomplete_drain_evidence() { - let (lock, keys, st) = evidence_fixture(); - let bytes = st.serialize_to_bytes().unwrap(); - assert!(attempt_outputs(&lock, &bytes, &keys).is_ok()); - assert!(attempt_outputs(&lock, b"not a transition", &keys).is_err()); - let mut foreign = lock.clone(); - foreign.out_point.vout += 1; - assert!(attempt_outputs(&foreign, &bytes, &keys).is_err()); - let mut unrelated = lock.clone(); - unrelated.operation_id = None; - assert!(attempt_outputs(&unrelated, &bytes, &keys).is_err()); - let mut wrong_role = lock.clone(); - wrong_role.funding_type = AssetLockFundingType::IdentityRegistration; - assert!(attempt_outputs(&wrong_role, &bytes, &keys).is_err()); - let other_keys = BTreeMap::from([( - 0, - OrchardKeySet::from_seed(&[0x88; 32], Network::Testnet, 0) - .unwrap() - .viewing_keys(), - )]); - assert!(attempt_outputs(&lock, &bytes, &other_keys).is_err()); - let mut altered = st.clone(); - if let StateTransition::ShieldFromAssetLock(transition) = &mut altered { - transition.set_value_balance(transition.value_balance() + 1); - } - assert!(attempt_outputs(&lock, &altered.serialize_to_bytes().unwrap(), &keys).is_err()); - let mut unsigned = st; - if let StateTransition::ShieldFromAssetLock(transition) = &mut unsigned { - transition.set_signature(Default::default()); - } - assert!(attempt_outputs(&lock, &unsigned.serialize_to_bytes().unwrap(), &keys).is_err()); - } - - #[tokio::test] - async fn should_leave_legacy_drain_without_evidence_pending_even_with_matching_balance() { - let (lock, keys, st) = evidence_fixture(); - let outputs = attempt_outputs(&lock, &st.serialize_to_bytes().unwrap(), &keys).unwrap(); - let (wallet, _, coordinator, _dir) = wallet_fixture(&lock, keys).await; - for (index, (account, cmx)) in outputs.iter().enumerate() { - observe( - &coordinator, - wallet.wallet_id(), - *account, - *cmx, - index as u64, - ) - .await; - } - assert!(!wallet - .reconcile_wallet_drain(&coordinator, lock.out_point) - .await - .unwrap()); - assert_eq!( - wallet - .shielded_wallet_drain_operation_status(&coordinator, [0x33; 16]) - .await - .unwrap() - .state, - AssetLockOperationState::Pending - ); - } -} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 4d964991d73..0970e75d48a 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -755,13 +755,6 @@ impl ShieldedStore for FileBackedShieldedStore { Ok(()) } - fn remove_activity(&mut self, id: SubwalletId, entry_id: &[u8; 32]) -> Result<(), Self::Error> { - if let Some(subwallet) = self.subwallets.get_mut(&id) { - subwallet.remove_activity(entry_id); - } - Ok(()) - } - fn get_activity( &self, id: SubwalletId, 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 f12d12e1079..4b1d4d7f53c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -19,10 +19,14 @@ //! `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; @@ -157,9 +161,6 @@ impl PlatformWallet { .as_ref() .map(|operation| self.register_wallet_drain_operation(operation.clone())); let _shield_guard = self.shield_guard.lock().await; - if operation.is_some() { - self.reconcile_wallet_drains(coordinator).await?; - } let funding = if let Some(operation) = &operation { match self.asset_locks.begin_wallet_drain(operation).await? { Some(out_point) => AssetLockFunding::FromExistingAssetLock { @@ -171,34 +172,7 @@ impl PlatformWallet { } else { funding }; - // A generic exact-outpoint resume can resolve the same durable drain. - let resumed_outpoint = match &funding { - AssetLockFunding::FromExistingAssetLock { out_point, .. } => Some(*out_point), - _ => None, - }; - if let Some(out_point) = resumed_outpoint { - if self.reconcile_wallet_drain(coordinator, out_point).await? { - if let Some(operation) = &operation { - operation.set(AssetLockOperationState::Completed, Some(out_point)); - } - return Ok(()); - } - if self - .replay_wallet_drain_attempt(out_point, settings) - .await? - { - if self.reconcile_wallet_drain(coordinator, out_point).await? { - if let Some(operation) = &operation { - operation.set(AssetLockOperationState::Completed, Some(out_point)); - } - return Ok(()); - } - return Err(PlatformWalletError::TransactionBroadcastUnconfirmed( - "original drain attempt replayed; waiting for authenticated output confirmation".into(), - )); - } - } - let mut result = self + let result = self .shielded_fund_from_asset_lock_inner( coordinator, funding, @@ -211,17 +185,6 @@ impl PlatformWallet { cl_wait, ) .await; - // A scan may have completed during the network wait. Only exact - // persisted outputs can upgrade an ambiguous/already-consumed reply. - if result.is_err() { - let out_point = resumed_outpoint - .or_else(|| operation.as_ref().and_then(|op| op.snapshot().out_point)); - if let Some(out_point) = out_point { - if self.reconcile_wallet_drain(coordinator, out_point).await? { - result = Ok(()); - } - } - } if let Some(operation) = operation { let snapshot = operation.snapshot(); if result.is_ok() { @@ -322,9 +285,11 @@ impl PlatformWallet { operation, .. } => { - // A drain requires owned-output evidence for restart recovery. - // Refuse missing keys before Core funding while shield_guard - // serializes this operation with key binding and removal. + // 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() @@ -506,7 +471,6 @@ impl PlatformWallet { // them on success. let (submit_result, effective_proof) = match submit_with_cl_height_retry(settings, |s| { build_and_broadcast_shielded( - self, sdk.clone(), recipient, shield_amount, @@ -545,7 +509,6 @@ impl PlatformWallet { self.asset_locks.queue_asset_lock_changeset(cs); let submit_result = submit_with_cl_height_retry(settings, |s| { build_and_broadcast_shielded( - self, sdk.clone(), recipient, shield_amount, @@ -575,36 +538,31 @@ 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_actions) = self + .asset_locks + .settle_asset_lock_submit_result( submit_result, &proof_out_point, &effective_proof, cl_wait, + drain_operation_id, ) - .await?; - - // A Type18 affected-state response proves only the asset-lock state, - // not execution of this exact Orchard bundle. Drains use the same - // dual-evidence gate as restart recovery, including on a fresh submit. - if let Some(out_point) = tracked_out_point { - if self - .asset_locks - .list_tracked_locks() - .await - .iter() - .any(|lock| lock.out_point == out_point && lock.operation_id.is_some()) - { - return if self.reconcile_wallet_drain(coordinator, out_point).await? { - Ok(()) - } else { - Err(PlatformWalletError::TransactionBroadcastUnconfirmed( - "drain awaits authenticated exact outputs and full original-outpoint consumption".into(), - )) - }; - } - } + .await? + else { + tracing::info!( + outpoint = %proof_out_point, + "wallet drain completed: Platform reports its asset lock already consumed by an earlier attempt" + ); + return Ok(()); + }; // Record a live `ShieldFromAssetLock` activity entry over the // landed bundle. One entry per call (= one per seed-pool batch), @@ -616,38 +574,48 @@ impl PlatformWallet { 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; the next resume completes it from + // Platform's already-consumed verdict. + (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!( @@ -861,7 +829,6 @@ async fn lookup_asset_lock_value_credits( /// scan will recover). #[allow(clippy::too_many_arguments)] async fn build_and_broadcast_shielded( - wallet: &PlatformWallet, sdk: std::sync::Arc, recipient: OrchardAddress, shield_amount: Credits, @@ -881,7 +848,6 @@ where use dpp::state_transition::shield_from_asset_lock_transition::accessors::ShieldFromAssetLockTransitionAccessorsV0; use dpp::state_transition::StateTransition; - let out_point = out_point_from_proof(&proof); let st = build_shield_from_asset_lock_transition_with_signer( &recipient, shield_amount, @@ -902,22 +868,15 @@ where _ => Vec::new(), }; - // This is the last fallible local step before dispatch. Every rebuilt CL - // retry appends its own signed bytes, so a prior successful attempt remains - // identifiable after process death or a later unauthenticated rejection. - wallet - .persist_drain_shielded_attempt(out_point, &st) - .await - .map_err(|error| dash_sdk::Error::Generic(error.to_string()))?; - // Wait for the verified result rather than relay-ACK. Single-use // asset-lock proof: a false-positive on a transition Platform // later rejects would strand the L1 outpoint with no in-app // 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. + // 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. st.broadcast_and_wait_for_affected_state::(&sdk, settings) .await?; Ok(actions) @@ -993,6 +952,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}; @@ -1050,6 +1010,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 { @@ -1060,6 +1022,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 } @@ -1160,7 +1124,6 @@ mod tests { out_point, TrackedAssetLock { operation_id: None, - shielded_funding_attempts: Vec::new(), out_point, transaction, account_index: 0, @@ -1193,6 +1156,299 @@ 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); + 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 whose flush fails has not committed: stay Pending. + *ctx.persistence + .fail_next_flush + .lock() + .expect("recording persistence mutex") = Some(PersistenceErrorKind::Transient); + assert!(matches!( + settle(&ctx).await, + Err(PlatformWalletError::Persistence(_)) + )); + assert_eq!(live_lock(&ctx).await.status, AssetLockStatus::ChainLocked); + 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 ce1794c7593..3b986f0b770 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -39,7 +39,6 @@ pub mod balance; #[cfg(test)] mod balance_tests; pub mod coordinator; -mod drain_recovery; pub mod file_store; pub mod fund_from_asset_lock; pub mod keys; @@ -51,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/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index c7bfb5e54e8..6e20a0ae1fc 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -370,9 +370,6 @@ pub trait ShieldedStore: Send + Sync { entry: &super::activity::ShieldedActivityEntry, ) -> Result<(), Self::Error>; - /// Remove a superseded scan aggregate after its durable replacement commits. - fn remove_activity(&mut self, id: SubwalletId, entry_id: &[u8; 32]) -> Result<(), Self::Error>; - /// Return a page of derived activity for `id`, sorted for display /// (pendings first, then by `block_height` desc, tiebreak by /// `created_at_ms` then `id`), sliced by `[offset, offset+limit)`. @@ -798,10 +795,6 @@ impl SubwalletState { self.activity.insert(entry.id, entry.clone()); } - pub(super) fn remove_activity(&mut self, entry_id: &[u8; 32]) { - self.activity.remove(entry_id); - } - /// Display-sorted page of activity entries. pub(super) fn activity_page( &self, @@ -1050,13 +1043,6 @@ impl ShieldedStore for InMemoryShieldedStore { .unwrap_or_default()) } - fn remove_activity(&mut self, id: SubwalletId, entry_id: &[u8; 32]) -> Result<(), Self::Error> { - if let Some(subwallet) = self.subwallets.get_mut(&id) { - subwallet.remove_activity(entry_id); - } - Ok(()) - } - fn get_activity_by_entry_id( &self, id: SubwalletId, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs index 92749e15a3d..40acfd62a97 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs @@ -55,28 +55,12 @@ impl PlatformWallet { 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, - coordinator: &super::NetworkShieldedCoordinator, operation_id: [u8; 16], ) -> Result { - let Ok(_shield_guard) = self.shield_guard.try_lock() else { - // A status read must not wait behind a network submission. The - // active operation's context already provides a fail-closed view. - return self - .asset_locks - .wallet_drain_operation_status(operation_id) - .await; - }; - let snapshot = self - .asset_locks - .wallet_drain_operation_status(operation_id) - .await?; - if snapshot.state == crate::wallet::asset_lock::AssetLockOperationState::Pending { - if let Some(out_point) = snapshot.out_point { - self.reconcile_wallet_drain(coordinator, out_point).await?; - } - } self.asset_locks .wallet_drain_operation_status(operation_id) .await 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..e77657df406 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs @@ -0,0 +1,712 @@ +//! 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_slice(&[0x11; 32]).unwrap(); + let public = dashcore::PublicKey::new(dashcore::secp256k1::PublicKey::from_secret_key( + &dashcore::secp256k1::Secp256k1::new(), + &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 { + 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), + commits_inline: AtomicBool::new(false), + 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)), + ); + 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)), + ); + *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; + for (fail_store, fail_flush) in [(true, false), (false, true)] { + persistence.fail_store.store(fail_store, Ordering::SeqCst); + persistence.fail_flush.store(fail_flush, 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); + persistence.fail_flush.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 + ); +} + +#[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 + ); +} From 3eb509e86b81096cbbc953a1943d420a8fb159d5 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 15 Oct 2026 09:22:06 +0200 Subject: [PATCH 14/24] refactor(swift-sdk)!: drop the signed-attempt archive and activity reconciliation host support The Rust SDK no longer keeps a signed-attempt archive or replaces scan history when a drain completes, so the host callbacks that persisted, loaded and freed attempts and the one that removed activity rows are gone, together with the `shieldedFundingAttemptsBlob` field of the unshipped schema 4.0.0, the attempt archive codec and its budgets, and the two capability bits. The shielded restore loaders and the activity upsert go back to their pre-PR log-and-skip behaviour; failing a whole restore on one malformed row only served reconciliation. Operation UUIDs, their callbacks, the terminal `DrainCompleted` rules and the throwing `loadCachedAssetLocks` stay. Co-Authored-By: Claude Fable 5.1 --- .../Models/PersistentAssetLock.swift | 5 - .../PlatformWalletManager.swift | 3 - .../PlatformWalletPersistenceHandler.swift | 524 ++++++------------ .../AssetLockOperationPersistenceTests.swift | 77 --- .../InvitationPersistenceTests.swift | 2 - .../ShieldedActivityReconciliationTests.swift | 376 ------------- 6 files changed, 166 insertions(+), 821 deletions(-) delete mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedActivityReconciliationTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift index 585019150ac..0ac5f527808 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift @@ -206,11 +206,6 @@ public final class PersistentAssetLock { /// completed transfer from an operation that never reached broadcast. public var operationId: Data? - /// Versioned binary property list of exact signed Type18 transitions. Rust - /// owns construction and recovery; this append-only sidecar stores bytes. - /// Nil on published V3 rows upgraded to the current, unshipped V4 schema. - public var shieldedFundingAttemptsBlob: Data? - /// Record timestamps. public var createdAt: Date public var updatedAt: Date diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 36b5a4901a3..24a4edede32 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -109,9 +109,6 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { public static let dashpayPayments: UInt64 = 1 << 12 /// Durable operation UUIDs, including consumed asset-lock tombstones. public static let assetLockOperationIds: UInt64 = 1 << 13 - public static let shieldedFundingAttempts: UInt64 = 1 << 14 - /// Atomic activity replacement and strict durable restoration. - public static let shieldedActivityReconciliation: UInt64 = 1 << 15 public let version: UInt32 public let bits: UInt64 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index eb649a430a3..a0c6d2be665 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -25,10 +25,6 @@ private enum AssetLockPersistenceError: Error { case requiresChangeset } -private enum ShieldedRestoreError: Error { - case malformedWalletId -} - struct LiveModelFetcher: ModelFetching { func fetch( _ descriptor: FetchDescriptor, @@ -719,109 +715,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } - // Mirrors the native wallet's MAX_DRAIN_ATTEMPTS / MAX_DRAIN_ATTEMPT_BYTES. - fileprivate static let maxDrainAttempts = 64 - fileprivate static let maxDrainAttemptBytes = 4 * 1024 * 1024 - - private struct ShieldedFundingAttemptsArchive: Codable { - let version: UInt32 - var attempts: [Data] - enum CodingKeys: CodingKey { case version, attempts } - - init(version: UInt32, attempts: [Data]) { - self.version = version - self.attempts = attempts - } - - init(from decoder: Decoder) throws { - let fields = try decoder.container(keyedBy: CodingKeys.self) - version = try fields.decode(UInt32.self, forKey: .version) - var rows = try fields.nestedUnkeyedContainer(forKey: .attempts) - guard version == 1, (rows.count ?? 0) <= maxDrainAttempts else { - throw AssetLockPersistenceError.malformedRecord - } - attempts = [] - var total = 0 - while !rows.isAtEnd { - guard attempts.count < maxDrainAttempts else { - throw AssetLockPersistenceError.malformedRecord - } - let data = try rows.decode(Data.self) - let (next, overflow) = total.addingReportingOverflow(data.count) - guard !data.isEmpty, !overflow, next <= maxDrainAttemptBytes else { - throw AssetLockPersistenceError.malformedRecord - } - total = next - attempts.append(data) - } - } - } - - private static func decodeShieldedFundingAttempts(_ record: PersistentAssetLock) throws -> [Data] { - guard let blob = record.shieldedFundingAttemptsBlob else { return [] } - // Bound the plist parser itself before it materializes its object graph. - // Allow XML base64 expansion plus framing; writers use compact binary plists. - guard blob.count <= maxDrainAttemptBytes * 2 + 64 * 1024 else { - throw AssetLockPersistenceError.malformedRecord - } - let archive = try PropertyListDecoder().decode(ShieldedFundingAttemptsArchive.self, from: blob) - guard archive.version == 1, !archive.attempts.contains(where: { $0.isEmpty }), - archive.attempts.isEmpty || record.operationId?.count == 16 else { - throw AssetLockPersistenceError.malformedRecord - } - return archive.attempts - } - - /// Idempotent per-index append, saved with the UUID and base row by endChangeset. - func persistShieldedFundingAttempt(walletId: Data, outPoint: Data, operationId: Data, - attemptIndex: UInt32, bytes: Data) throws { - try onQueue { - guard inChangeset else { throw AssetLockPersistenceError.requiresChangeset } - guard outPoint.count == 36, operationId.count == 16, !bytes.isEmpty 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 - } - guard record.operationId == operationId else { throw AssetLockPersistenceError.duplicateOperationId } - var attempts = try Self.decodeShieldedFundingAttempts(record) - let index = Int(attemptIndex) - if index < attempts.count { - guard attempts[index] == bytes else { throw AssetLockPersistenceError.malformedRecord } - return - } - guard index == attempts.count else { throw AssetLockPersistenceError.malformedRecord } - guard attempts.count < Self.maxDrainAttempts, - bytes.count <= Self.maxDrainAttemptBytes - attempts.reduce(0, { $0 + $1.count }) else { - throw AssetLockPersistenceError.malformedRecord - } - attempts.append(bytes) - let encoder = PropertyListEncoder() - encoder.outputFormat = .binary - record.shieldedFundingAttemptsBlob = try encoder.encode( - ShieldedFundingAttemptsArchive(version: 1, attempts: attempts)) - record.updatedAt = Date() - } - } - - func loadShieldedFundingAttempts(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 - } - return try Self.decodeShieldedFundingAttempts(record) - } - } - /// 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 @@ -3364,8 +3257,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.coreSweepRemoval | PlatformWalletPersistenceCapabilities.dashpayPayments | PlatformWalletPersistenceCapabilities.assetLockOperationIds - | PlatformWalletPersistenceCapabilities.shieldedFundingAttempts - | PlatformWalletPersistenceCapabilities.shieldedActivityReconciliation ) } @@ -3405,10 +3296,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { 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 - extensionCallbacks.on_persist_shielded_funding_attempt_fn = persistShieldedFundingAttemptCallback - extensionCallbacks.on_load_shielded_funding_attempts_fn = loadShieldedFundingAttemptsCallback - extensionCallbacks.on_load_shielded_funding_attempts_free_fn = loadShieldedFundingAttemptsFreeCallback - extensionCallbacks.on_remove_shielded_activity_fn = removeShieldedActivityCallback return extensionCallbacks } @@ -5778,91 +5665,61 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `entryId` refines the existing row in place: a `Pending` row flips /// to `Confirmed`/`Failed`, and a scan-derived `ShieldedSpend` can be /// upgraded to a richer kind (the Rust side re-emits the same id). - @discardableResult - func persistShieldedActivity(walletId: Data, snapshots: [ShieldedActivitySnapshot]) -> Bool { + func persistShieldedActivity(walletId: Data, snapshots: [ShieldedActivitySnapshot]) { onQueue { - do { - for snap in snapshots { - guard snap.walletId == walletId, walletId.count == 32, snap.entryId.count == 32, - snap.noteCmxs.count % 32 == 0, snap.spentNullifiers.count % 32 == 0 else { - throw AssetLockPersistenceError.malformedRecord - } - let wid = snap.walletId - let acct = snap.accountIndex - let eid = snap.entryId - let predicate = #Predicate { - $0.walletId == wid && $0.accountIndex == acct && $0.entryId == eid - } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = 1 - if let existing = try modelFetcher.fetch(descriptor, in: backgroundContext).first { - existing.kindTag = snap.kindTag - existing.direction = snap.direction - existing.status = snap.status - existing.amount = snap.amount - existing.fee = snap.fee - existing.hasFee = snap.hasFee - existing.blockHeight = snap.blockHeight - existing.hasBlockHeight = snap.hasBlockHeight - existing.createdAtMs = snap.createdAtMs - existing.minNotePosition = snap.minNotePosition - existing.hasMinNotePosition = snap.hasMinNotePosition - existing.identityId = snap.identityId - existing.counterparty = snap.counterparty - existing.memo = snap.memo - existing.noteCmxs = snap.noteCmxs - existing.spentNullifiers = snap.spentNullifiers - existing.lastUpdated = Date() - } else { - let row = PersistentShieldedActivity( - walletId: snap.walletId, - accountIndex: snap.accountIndex, - entryId: snap.entryId, - kindTag: snap.kindTag, - direction: snap.direction, - status: snap.status, - amount: snap.amount, - fee: snap.fee, - hasFee: snap.hasFee, - blockHeight: snap.blockHeight, - hasBlockHeight: snap.hasBlockHeight, - createdAtMs: snap.createdAtMs, - minNotePosition: snap.minNotePosition, - hasMinNotePosition: snap.hasMinNotePosition, - identityId: snap.identityId, - counterparty: snap.counterparty, - memo: snap.memo, - noteCmxs: snap.noteCmxs, - spentNullifiers: snap.spentNullifiers - ) - backgroundContext.insert(row) - } + for snap in snapshots { + let wid = snap.walletId + let acct = snap.accountIndex + let eid = snap.entryId + let predicate = #Predicate { + $0.walletId == wid && $0.accountIndex == acct && $0.entryId == eid } - if !inChangeset { try backgroundContext.save() } - return true - } catch { - if !inChangeset { backgroundContext.rollback() } - return false - } - } - } - - /// Remove only the rows selected by Rust, within the same atomic round - /// as their replacements and the asset-lock completion marker. - func removeShieldedActivity(walletId: Data, accountIndex: UInt32, entryIds: [Data]) throws { - try onQueue { - guard inChangeset else { throw AssetLockPersistenceError.requiresChangeset } - guard walletId.count == 32, entryIds.allSatisfy({ $0.count == 32 }) else { - throw AssetLockPersistenceError.malformedRecord - } - for entryId in entryIds { - let descriptor = FetchDescriptor(predicate: #Predicate { - $0.walletId == walletId && $0.accountIndex == accountIndex && $0.entryId == entryId - }) - for row in try modelFetcher.fetch(descriptor, in: backgroundContext) { - backgroundContext.delete(row) + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + if let existing = try? backgroundContext.fetch(descriptor).first { + existing.kindTag = snap.kindTag + existing.direction = snap.direction + existing.status = snap.status + existing.amount = snap.amount + existing.fee = snap.fee + existing.hasFee = snap.hasFee + existing.blockHeight = snap.blockHeight + existing.hasBlockHeight = snap.hasBlockHeight + existing.createdAtMs = snap.createdAtMs + existing.minNotePosition = snap.minNotePosition + existing.hasMinNotePosition = snap.hasMinNotePosition + existing.identityId = snap.identityId + existing.counterparty = snap.counterparty + existing.memo = snap.memo + existing.noteCmxs = snap.noteCmxs + existing.spentNullifiers = snap.spentNullifiers + existing.lastUpdated = Date() + } else { + let row = PersistentShieldedActivity( + walletId: snap.walletId, + accountIndex: snap.accountIndex, + entryId: snap.entryId, + kindTag: snap.kindTag, + direction: snap.direction, + status: snap.status, + amount: snap.amount, + fee: snap.fee, + hasFee: snap.hasFee, + blockHeight: snap.blockHeight, + hasBlockHeight: snap.hasBlockHeight, + createdAtMs: snap.createdAtMs, + minNotePosition: snap.minNotePosition, + hasMinNotePosition: snap.hasMinNotePosition, + identityId: snap.identityId, + counterparty: snap.counterparty, + memo: snap.memo, + noteCmxs: snap.noteCmxs, + spentNullifiers: snap.spentNullifiers + ) + backgroundContext.insert(row) } } + saveBackgroundContextIfNeeded(operation: "shielded_activity", walletId: walletId) } } @@ -5975,33 +5832,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// network (legacy callers that haven't threaded `network` through), /// signalling "don't filter" so those paths keep their pre-refactor /// cross-network behavior. Caller must be on `onQueue`. - private func inNetworkWalletIds() throws -> Set? { + private func inNetworkWalletIds() -> Set? { guard let network = self.network else { return nil } let raw = network.rawValue let descriptor = FetchDescriptor( predicate: #Predicate { $0.networkRaw == raw } ) - let wallets = try modelFetcher.fetch(descriptor, in: backgroundContext) - guard wallets.allSatisfy({ $0.walletId.count == 32 }) else { - throw ShieldedRestoreError.malformedWalletId - } + let wallets = (try? backgroundContext.fetch(descriptor)) ?? [] return Set(wallets.map { $0.walletId }) } - /// A malformed wallet ID cannot safely be classified as another network. - /// Validate it before filtering, and propagate scope-fetch failures instead - /// of returning a successful empty snapshot. Recovery needs the complete - /// scan snapshot to preserve unrelated receipts when replacing history. - private func shieldedRowsForNetwork( - _ rows: [Row], walletId: (Row) -> Data - ) throws -> [Row] { - guard rows.allSatisfy({ walletId($0).count == 32 }) else { - throw ShieldedRestoreError.malformedWalletId - } - guard let ids = try inNetworkWalletIds() else { return rows } - return rows.filter { ids.contains(walletId($0)) } - } - /// Build the host-allocated `ShieldedNoteRestoreFFI` array Rust /// reads at boot. The allocation is tracked in /// `shieldedLoadAllocations` and freed by @@ -6016,33 +5856,47 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var resultErrored = false onQueue { let descriptor = FetchDescriptor() - let rows: [PersistentShieldedNote] + var rows: [PersistentShieldedNote] do { - rows = try shieldedRowsForNetwork( - modelFetcher.fetch(descriptor, in: backgroundContext), walletId: { $0.walletId } - ) + rows = try backgroundContext.fetch(descriptor) } catch { resultErrored = true return } - if rows.isEmpty { - return + // Scope to the handler's bound network so a per-network + // manager never rehydrates another network's shielded notes + // (the commitment tree DB is network-scoped). `nil` ids => + // no in-network wallets => nothing to restore. + if let inNetworkIds = self.inNetworkWalletIds() { + rows = rows.filter { inNetworkIds.contains($0.walletId) } } - // Validate the complete snapshot before allocating or returning any - // rows. Omitting a corrupt unrelated note would let reconciliation - // replace an aggregate history entry without preserving that note. - guard rows.allSatisfy({ - $0.cmx.count == 32 && $0.nullifier.count == 32 && $0.noteData.count == 115 - }) else { - resultErrored = true + if rows.isEmpty { return } let allocation = ShieldedLoadAllocation() + // Allocate the entries buffer up front; populate slots + // one by one and track `entriesInitialized` so a + // mid-loop bail-out can deinit only the populated + // slots. (Today nothing fails in this loop, but + // matching the existing `LoadAllocation` pattern keeps + // future field additions safe.) let buf = UnsafeMutablePointer.allocate(capacity: rows.count) allocation.entries = buf allocation.entriesCount = rows.count + // `written` is the next free slot in `buf`; we increment it + // only after a row's struct is fully populated, so the + // returned prefix `[0..written)` is contiguous initialized + // memory regardless of how many rows are skipped by the + // length guards below. Indexing by `rows.enumerated()`'s + // `idx` here would leave gaps when an early row is skipped + // and Rust would read uninitialized bytes off the + // `slice::from_raw_parts(ptr, count)` it builds in + // `FFIPersister::load`. var written = 0 for row in rows { + guard row.walletId.count == 32 else { continue } + guard row.cmx.count == 32 else { continue } + guard row.nullifier.count == 32 else { continue } let noteDataBuf = UnsafeMutablePointer.allocate(capacity: row.noteData.count) row.noteData.copyBytes(to: noteDataBuf, count: row.noteData.count) allocation.scalarBuffers.append((noteDataBuf, row.noteData.count)) @@ -6115,20 +5969,21 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var resultErrored = false onQueue { let descriptor = FetchDescriptor() - let rows: [PersistentShieldedOutgoingNote] + var rows: [PersistentShieldedOutgoingNote] do { - rows = try shieldedRowsForNetwork( - modelFetcher.fetch(descriptor, in: backgroundContext), walletId: { $0.walletId } - ) + rows = try backgroundContext.fetch(descriptor) } catch { resultErrored = true return } - if rows.isEmpty { - return + // Scope to the handler's bound network so a per-network + // manager never rehydrates another network's send history + // (the commitment tree DB is network-scoped). `nil` ids => + // no in-network wallets => nothing to restore. + if let inNetworkIds = self.inNetworkWalletIds() { + rows = rows.filter { inNetworkIds.contains($0.walletId) } } - guard rows.allSatisfy({ $0.cmx.count == 32 && $0.recipient.count == 43 }) else { - resultErrored = true + if rows.isEmpty { return } let allocation = ShieldedOutgoingNoteLoadAllocation() @@ -6137,8 +5992,32 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) allocation.entries = buf allocation.entriesCount = rows.count + // Same `written`-counter discipline as `loadShieldedNotes`: + // increment only after a slot is fully populated so the + // returned prefix `[0..written)` is contiguous initialized + // memory even when malformed rows are skipped. var written = 0 for row in rows { + guard row.walletId.count == 32 else { continue } + guard row.cmx.count == 32 else { continue } + // `recipient` is a fixed 43-byte raw Orchard address. + // A wrong-length blob is a corrupt row — skip it (with + // a log) rather than zero-padding it into a wrong + // address. Mirrors the Rust persist side, which rejects + // non-43-byte recipients before they reach SwiftData. + guard row.recipient.count == 43 else { + SDKLogger.event( + "persistence_shielded_outgoing_row_skipped", + category: .persistence, + severity: .warning, + fields: [ + "actual_length": .integer(Int64(row.recipient.count)), + "expected_length": .integer(43), + "reason": .publicText("malformed_recipient"), + ] + ) + continue + } let memoBuf = UnsafeMutablePointer.allocate(capacity: row.memo.count) if row.memo.count > 0 { row.memo.copyBytes(to: memoBuf, count: row.memo.count) @@ -6206,13 +6085,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let descriptor = FetchDescriptor() var rows: [PersistentShieldedActivity] do { - rows = try shieldedRowsForNetwork( - modelFetcher.fetch(descriptor, in: backgroundContext), walletId: { $0.walletId } - ) + rows = try backgroundContext.fetch(descriptor) } catch { resultErrored = true return } + if let inNetworkIds = self.inNetworkWalletIds() { + rows = rows.filter { inNetworkIds.contains($0.walletId) } + } if rows.isEmpty { return } @@ -6224,18 +6104,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { allocation.entriesCount = rows.count var written = 0 for row in rows { - // A partial restore cannot establish durable history. Fail - // the whole read on corrupt rows or linkage instead of hiding - // them and allowing recovery to commit a completion marker. - guard row.walletId.count == 32, row.entryId.count == 32, - row.identityId.isEmpty || row.identityId.count == 32, - row.noteCmxs.count % 32 == 0, row.spentNullifiers.count % 32 == 0, - let kindTagU8 = UInt8(exactly: row.kindTag), + guard row.walletId.count == 32, row.entryId.count == 32 else { continue } + // Exact conversion, not truncating: a stored tag outside + // u8 range (corruption / unmigrated future tag) must NOT + // wrap into a different valid-looking discriminant (256 → + // 0 = Shield/In/Pending) — that would bypass Rust's + // unknown-tag fallback. Drop the row instead. + guard let kindTagU8 = UInt8(exactly: row.kindTag), let directionU8 = UInt8(exactly: row.direction), let statusU8 = UInt8(exactly: row.status) else { - allocation.release() - resultErrored = true - return + continue } // Per-row variable-length buffers: counterparty, memo, @@ -6282,8 +6160,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { counterparty_len: UInt(cpLen), memo_ptr: memoLen > 0 ? UnsafePointer(memoPtr) : nil, memo_len: UInt(memoLen), - // Linkage lengths were validated before allocation; - // preserve every complete 32-byte element on restore. + // A persisted blob that isn't a whole number of + // 32-byte elements is corrupt — drop the linkage + // (count 0, null ptr) rather than silently truncating + // trailing bytes into a wrong-but-plausible array. note_cmxs_ptr: cmxLen > 0 && cmxLen % 32 == 0 ? UnsafePointer(cmxPtr) : nil, note_cmxs_count: cmxLen % 32 == 0 ? UInt(cmxLen / 32) : 0, spent_nullifiers_ptr: nfLen > 0 && nfLen % 32 == 0 ? UnsafePointer(nfPtr) : nil, @@ -6324,15 +6204,19 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var resultErrored = false onQueue { let descriptor = FetchDescriptor() - let rows: [PersistentShieldedSyncState] + var rows: [PersistentShieldedSyncState] do { - rows = try shieldedRowsForNetwork( - modelFetcher.fetch(descriptor, in: backgroundContext), walletId: { $0.walletId } - ) + rows = try backgroundContext.fetch(descriptor) } catch { resultErrored = true return } + // Same network scoping as `loadShieldedNotes` — keep both + // loaders consistent so a per-network manager doesn't restore + // foreign-network sync watermarks. + if let inNetworkIds = self.inNetworkWalletIds() { + rows = rows.filter { inNetworkIds.contains($0.walletId) } + } if rows.isEmpty { return } @@ -6342,8 +6226,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) allocation.entries = buf allocation.entriesCount = rows.count + // Same `written`-counter pattern as `loadShieldedNotes`: + // skip malformed rows without leaving holes in the + // contiguous prefix Rust will read. var written = 0 for row in rows { + guard row.walletId.count == 32 else { continue } var walletIdTuple: FFIByteTuple32 = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) row.walletId.withUnsafeBytes { src in Swift.withUnsafeMutableBytes(of: &walletIdTuple) { dst in @@ -6391,20 +6279,26 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var resultErrored = false onQueue { let descriptor = FetchDescriptor() - let rows: [PersistentShieldedViewingKey] + var rows: [PersistentShieldedViewingKey] do { - rows = try shieldedRowsForNetwork( - modelFetcher.fetch(descriptor, in: backgroundContext), walletId: { $0.walletId } - ) + rows = try backgroundContext.fetch(descriptor) } catch { resultErrored = true return } + // Same network scoping as the other shielded loaders — the + // FVK embeds the coin type, so serving another network's + // rows would fail the Rust-side bind, not corrupt it, but + // the loaders stay consistent regardless. + if let inNetworkIds = self.inNetworkWalletIds() { + rows = rows.filter { inNetworkIds.contains($0.walletId) } + } if rows.isEmpty { return } // Fail closed on a present-but-malformed row, BEFORE any - // allocation: silently skipping it would make Rust see the account as + // allocation: silently skipping it (the sync-state + // loader's pattern) would make Rust see the account as // "no persisted key" and fall back to a mnemonic resolve, // masking persistence corruption — the exact opposite of // `bind_shielded_from_persisted`'s documented contract, @@ -10903,8 +10797,7 @@ private func persistShieldedActivityCallback( entriesPtr: UnsafePointer?, count: UInt ) -> Int32 { - guard let context, let walletIdPtr, count <= UInt(Int.max), - count == 0 || entriesPtr != nil else { return -1 } + guard let context = context, let walletIdPtr = walletIdPtr else { return 0 } let handler = Unmanaged .fromOpaque(context) .takeUnretainedValue() @@ -10917,24 +10810,21 @@ private func persistShieldedActivityCallback( let e = entriesPtr[i] // Copy every pointer-backed field out now — the Rust // pointers are only valid for this callback window. - func validBuffer(_ ptr: UnsafePointer?, _ len: UInt) -> Bool { - len <= UInt(Int.max) && (len == 0 || ptr != nil) - } - let (cmxBytes, cmxOverflow) = UInt(e.note_cmxs_count).multipliedReportingOverflow(by: 32) - let (nfBytes, nfOverflow) = UInt(e.spent_nullifiers_count).multipliedReportingOverflow(by: 32) - guard !cmxOverflow, !nfOverflow, - validBuffer(e.counterparty_ptr, UInt(e.counterparty_len)), - validBuffer(e.memo_ptr, UInt(e.memo_len)), - validBuffer(e.note_cmxs_ptr, cmxBytes), - validBuffer(e.spent_nullifiers_ptr, nfBytes) else { return -1 } func data(_ ptr: UnsafePointer?, _ len: UInt) -> Data { - guard let ptr, len > 0 else { return Data() } - return Data(bytes: ptr, count: Int(len)) + if let ptr = ptr, len > 0 { return Data(bytes: ptr, count: Int(len)) } + return Data() } let counterparty = data(e.counterparty_ptr, UInt(e.counterparty_len)) let memo = data(e.memo_ptr, UInt(e.memo_len)) - let noteCmxs = data(e.note_cmxs_ptr, cmxBytes) - let spentNullifiers = data(e.spent_nullifiers_ptr, nfBytes) + // The counts are Rust-supplied: use checked multiplication so + // a corrupt row degrades to an empty linkage instead of a + // trapped overflow crashing the callback path. + func byteLen(_ count: UInt) -> UInt { + let (value, overflow) = count.multipliedReportingOverflow(by: 32) + return overflow ? 0 : value + } + let noteCmxs = data(e.note_cmxs_ptr, byteLen(UInt(e.note_cmxs_count))) + let spentNullifiers = data(e.spent_nullifiers_ptr, byteLen(UInt(e.spent_nullifiers_count))) let identityId = e.has_identity_id != 0 ? dataFromTuple32(e.identity_id) : Data() snapshots.append(.init( walletId: dataFromTuple32(e.wallet_id), @@ -10959,34 +10849,8 @@ private func persistShieldedActivityCallback( )) } } - return handler.persistShieldedActivity(walletId: walletId, snapshots: snapshots) ? 0 : -1 -} - -private func removeShieldedActivityCallback( - context: UnsafeMutableRawPointer?, - walletIdPtr: UnsafePointer?, - accountIndex: UInt32, - entryIdsPtr: UnsafePointer?, - count: UInt -) -> Int32 { - guard let context, let walletIdPtr, count <= UInt(Int.max / 32), - count == 0 || entryIdsPtr != nil else { return -1 } - let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() - let walletId = Data(bytes: walletIdPtr, count: 32) - let ids: [Data] - if let entryIdsPtr { - ids = (0..?, - outPoint: UnsafePointer?, operationId: UnsafePointer?, - attemptIndex: UInt32, bytes: UnsafePointer?, bytesLen: UInt -) -> Int32 { - guard let context, let walletId, let outPoint, let operationId, let bytes, - let count = Int(exactly: bytesLen), count > 0, - count <= PlatformWalletPersistenceHandler.maxDrainAttemptBytes, - Int(attemptIndex) < PlatformWalletPersistenceHandler.maxDrainAttempts else { return -1 } - let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() - do { - try handler.persistShieldedFundingAttempt( - walletId: Data(bytes: walletId, count: 32), outPoint: Data(bytes: outPoint, count: 36), - operationId: Data(bytes: operationId, count: 16), attemptIndex: attemptIndex, - bytes: Data(bytes: bytes, count: count)) - return 0 - } catch { return -1 } -} - -private func loadShieldedFundingAttemptsCallback( - context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, outPoint: UnsafePointer?, - outEntries: UnsafeMutablePointer?>?, - outCount: UnsafeMutablePointer? -) -> Int32 { - guard let context, let walletId, let outPoint, let outEntries, let outCount else { return -1 } - outEntries.pointee = nil - outCount.pointee = 0 - let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() - do { - let attempts = try handler.loadShieldedFundingAttempts( - walletId: Data(bytes: walletId, count: 32), outPoint: Data(bytes: outPoint, count: 36)) - guard !attempts.isEmpty else { return 0 } - let entries = UnsafeMutablePointer.allocate(capacity: attempts.count) - for (index, attempt) in attempts.enumerated() { - let bytes = UnsafeMutablePointer.allocate(capacity: attempt.count) - attempt.copyBytes(to: bytes, count: attempt.count) - entries.advanced(by: index).initialize(to: ShieldedFundingAttemptFFI( - bytes: UnsafePointer(bytes), bytes_len: UInt(attempt.count))) - } - outEntries.pointee = entries - outCount.pointee = UInt(attempts.count) - return 0 - } catch { return -1 } -} - -private func loadShieldedFundingAttemptsFreeCallback( - context: UnsafeMutableRawPointer?, entries: UnsafeMutablePointer?, count: UInt -) { - guard let entries, let count = Int(exactly: count) else { return } - for index in 0..?, identityId: UnsafePointer?, blockTime: UnsafePointer? diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift index 755fb7f7e5e..f1dec9f0c87 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift @@ -85,7 +85,6 @@ final class AssetLockOperationPersistenceTests: XCTestCase { let rows = try ModelContext(migrated).fetch(FetchDescriptor()) XCTAssertEqual(rows.count, 1) XCTAssertNil(rows.first?.operationId) - XCTAssertNil(rows.first?.shieldedFundingAttemptsBlob) XCTAssertEqual(rows.first?.amountDuffs, 1000) XCTAssertEqual(DashModelContainer.schema.version, Schema.Version(4, 0, 0)) } @@ -101,82 +100,6 @@ final class AssetLockOperationPersistenceTests: XCTestCase { XCTAssertThrowsError(try handler.loadCachedAssetLocks(walletId: walletId)) } - func testSignedFundingAttemptsAreAppendOnlyAndSurviveConsumedReopen() 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("attempts.store") - let first = Data([18, 1, 2, 3]) - let second = Data([18, 4, 5, 6, 7]) - try autoreleasepool { - let container = try DashModelContainer.create(url: url) - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - XCTAssertThrowsError(try handler.loadShieldedFundingAttempts(walletId: walletId, outPoint: rawOutPoint)) - try persist(handler, status: 1, operationId: operationId) - handler.beginChangeset(walletId: walletId) - for (index, bytes) in [first, second].enumerated() { - try handler.persistShieldedFundingAttempt(walletId: walletId, outPoint: rawOutPoint, - operationId: operationId, attemptIndex: UInt32(index), bytes: bytes) - } - XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) - handler.beginChangeset(walletId: walletId) - try handler.persistShieldedFundingAttempt(walletId: walletId, outPoint: rawOutPoint, - operationId: operationId, attemptIndex: 0, bytes: first) - XCTAssertThrowsError(try handler.persistShieldedFundingAttempt(walletId: walletId, outPoint: rawOutPoint, - operationId: operationId, attemptIndex: 0, bytes: second)) - XCTAssertThrowsError(try handler.persistShieldedFundingAttempt(walletId: walletId, outPoint: rawOutPoint, - operationId: operationId, attemptIndex: 3, bytes: second)) - XCTAssertFalse(handler.endChangeset(walletId: walletId, success: false)) - try persist(handler, status: 4, operationId: nil) - } - let reopened = try DashModelContainer.create(url: url) - let handler = PlatformWalletPersistenceHandler(modelContainer: reopened, network: .testnet) - XCTAssertEqual(try handler.loadShieldedFundingAttempts(walletId: walletId, outPoint: rawOutPoint), [first, second]) - XCTAssertEqual(try handler.loadCachedAssetLocks(walletId: walletId).first?.statusRaw, 4) - } - - func testMalformedAttemptArchiveFailsRecoveryRead() throws { - let container = try DashModelContainer.createInMemory() - let context = ModelContext(container) - let record = PersistentAssetLock(outPointHex: outPointHex, walletId: walletId, - transactionBytes: Data([1]), fundingTypeRaw: 5, identityIndexRaw: 0, - amountDuffs: 1000, statusRaw: 4, operationId: operationId) - record.shieldedFundingAttemptsBlob = Data([0]) - context.insert(record) - try context.save() - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - XCTAssertThrowsError(try handler.loadShieldedFundingAttempts(walletId: walletId, outPoint: rawOutPoint)) - } - - func testShouldBoundAttemptArchivesBeforeRecovery() throws { - struct Archive: Encodable { let version = 1; let attempts: [Data] } - let limit = 4 * 1024 * 1024 - for (attempts, valid) in [ - (Array(repeating: Data([1]), count: 64), true), - ([Data(repeating: 1, count: limit)], true), - (Array(repeating: Data([1]), count: 65), false), - ([Data(repeating: 1, count: limit), Data([2])], false), - ([Data(repeating: 1, count: limit + 1)], false) - ] { - let container = try DashModelContainer.createInMemory() - let context = ModelContext(container) - let record = PersistentAssetLock(outPointHex: outPointHex, walletId: walletId, - transactionBytes: Data([1]), fundingTypeRaw: 5, identityIndexRaw: 0, - amountDuffs: 1000, statusRaw: 4, operationId: operationId) - let encoder = PropertyListEncoder() - encoder.outputFormat = .binary - record.shieldedFundingAttemptsBlob = try encoder.encode(Archive(attempts: attempts)) - context.insert(record) - try context.save() - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - if valid { - XCTAssertEqual(try handler.loadShieldedFundingAttempts(walletId: walletId, outPoint: rawOutPoint), attempts) - } else { - XCTAssertThrowsError(try handler.loadShieldedFundingAttempts(walletId: walletId, outPoint: rawOutPoint)) - } - } - } - func testShouldPreserveProvenDrainCompletionAfterReopenAndStaleSnapshot() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index c7b74c037c6..7b51fce43d9 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -68,8 +68,6 @@ final class InvitationPersistenceTests: XCTestCase { // may ride this store's rounds — genuinely attested. | PlatformWalletPersistenceCapabilities.dashpayPayments | PlatformWalletPersistenceCapabilities.assetLockOperationIds - | PlatformWalletPersistenceCapabilities.shieldedFundingAttempts - | PlatformWalletPersistenceCapabilities.shieldedActivityReconciliation XCTAssertEqual( capabilities.version, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedActivityReconciliationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedActivityReconciliationTests.swift deleted file mode 100644 index d8029b76f99..00000000000 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedActivityReconciliationTests.swift +++ /dev/null @@ -1,376 +0,0 @@ -import Foundation -import DashSDKFFI -import SwiftData -import XCTest -@testable import SwiftDashSDK - -@MainActor -final class ShieldedActivityReconciliationTests: XCTestCase { - private let walletId = Data(repeating: 42, count: 32) - private let otherWalletId = Data(repeating: 43, count: 32) - private let outPointHex = String(repeating: "31", count: 32) + ":0" - - private func activity(_ id: UInt8, wallet: Data? = nil, account: UInt32 = 0, - notes: [UInt8] = [1], amount: UInt64 = 100) -> PlatformWalletPersistenceHandler.ShieldedActivitySnapshot { - .init(walletId: wallet ?? walletId, accountIndex: account, - entryId: Data(repeating: id, count: 32), kindTag: 6, direction: 0, status: 1, - amount: amount, fee: 0, hasFee: false, blockHeight: 1, hasBlockHeight: true, - createdAtMs: 0, minNotePosition: 0, hasMinNotePosition: true, - identityId: Data(), counterparty: Data(), memo: Data(), - noteCmxs: notes.reduce(into: Data()) { $0.append(Data(repeating: $1, count: 32)) }, - spentNullifiers: Data()) - } - - private func lock(_ status: Int) -> 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: Data(repeating: 1, count: 16)) - } - - private func seed(_ handler: PlatformWalletPersistenceHandler) { - handler.beginChangeset(walletId: walletId) - XCTAssertTrue(handler.persistShieldedActivity(walletId: walletId, - snapshots: [activity(9, notes: [1, 2], amount: 300), activity(9, account: 1)])) - XCTAssertTrue(handler.persistAssetLocks(walletId: walletId, upserts: [lock(0)], removed: [])) - XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) - XCTAssertTrue(handler.persistShieldedActivity(walletId: otherWalletId, - snapshots: [activity(9, wallet: otherWalletId)])) - } - - private func replace(_ handler: PlatformWalletPersistenceHandler) throws { - handler.beginChangeset(walletId: walletId) - let remove = try XCTUnwrap(handler.makePersistenceCallbacksExtension().on_remove_shielded_activity_fn) - let removedIds = Data(repeating: 9, count: 32) - let result = walletId.withUnsafeBytes { walletBytes in - removedIds.withUnsafeBytes { idBytes in - remove(Unmanaged.passUnretained(handler).toOpaque(), - walletBytes.bindMemory(to: UInt8.self).baseAddress, 0, - idBytes.bindMemory(to: UInt8.self).baseAddress, 1) - } - } - XCTAssertEqual(result, 0) - XCTAssertTrue(handler.persistShieldedActivity(walletId: walletId, - snapshots: [activity(1), activity(2, notes: [2], amount: 200)])) - XCTAssertTrue(handler.persistAssetLocks(walletId: walletId, upserts: [lock(4)], removed: [])) - XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) - } - - func testReplacementAndCompletionSurviveReopenWithoutTouchingOtherAccountsOrWallets() 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) - seed(handler) - try replace(handler) - try replace(handler) // An uncertain completion can replay idempotently. - } - let container = try DashModelContainer.create(url: url) - let rows = try ModelContext(container).fetch(FetchDescriptor()) - XCTAssertEqual(rows.count, 4) - let own = rows.filter { $0.walletId == walletId && $0.accountIndex == 0 } - XCTAssertEqual(Set(own.map(\.entryId)), Set([Data(repeating: 1, count: 32), Data(repeating: 2, count: 32)])) - XCTAssertEqual(own.reduce(0) { $0 + $1.amount }, 300) - XCTAssertEqual(rows.filter { $0.accountIndex == 1 }.count, 1) - XCTAssertEqual(rows.filter { $0.walletId == otherWalletId }.count, 1) - let handler = PlatformWalletPersistenceHandler(modelContainer: container) - XCTAssertEqual(try handler.loadCachedAssetLocks(walletId: walletId).first?.statusRaw, 4) - let restored = handler.loadShieldedActivity() - defer { handler.loadShieldedActivityFree(entries: restored.entries.map(UnsafeRawPointer.init)) } - XCTAssertFalse(restored.errored) - XCTAssertEqual(restored.count, 4) - } - - func testActivityUpsertFailureRollsBackCompletionAndAllowsRetryAfterReopen() throws { - try assertHistoryFailureRollsBack(removal: false) - } - - func testActivityRemovalFailureRollsBackCompletionAndAllowsRetryAfterReopen() throws { - try assertHistoryFailureRollsBack(removal: true) - } - - private func assertHistoryFailureRollsBack(removal: Bool) 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) - seed(PlatformWalletPersistenceHandler(modelContainer: container)) - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: nil, - modelFetcher: FetchFaultInjector(faulting: PersistentShieldedActivity.self)) - handler.beginChangeset(walletId: walletId) - XCTAssertTrue(handler.persistAssetLocks(walletId: walletId, upserts: [lock(4)], removed: [])) - if removal { - XCTAssertThrowsError(try handler.removeShieldedActivity(walletId: walletId, accountIndex: 0, - entryIds: [Data(repeating: 9, count: 32)])) - } else { - XCTAssertFalse(handler.persistShieldedActivity(walletId: walletId, snapshots: [activity(1)])) - } - XCTAssertFalse(handler.endChangeset(walletId: walletId, success: false)) - } - let reopened = try DashModelContainer.create(url: url) - let handler = PlatformWalletPersistenceHandler(modelContainer: reopened) - XCTAssertEqual(try handler.loadCachedAssetLocks(walletId: walletId).first?.statusRaw, 0) - let rows = try ModelContext(reopened).fetch(FetchDescriptor()) - XCTAssertEqual(rows.count, 3) - XCTAssertEqual(rows.first { $0.walletId == walletId && $0.accountIndex == 0 }?.amount, 300) - try replace(handler) - XCTAssertEqual(try handler.loadCachedAssetLocks(walletId: walletId).first?.statusRaw, 4) - } - - func testActivityRestoreFailsOnUnreadableHistoryAndWalletScope() throws { - let container = try DashModelContainer.createInMemory() - for model in [PersistentShieldedActivity.self, PersistentWallet.self] as [any PersistentModel.Type] { - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet, - modelFetcher: FetchFaultInjector(faulting: model)) - let restored = handler.loadShieldedActivity() - XCTAssertTrue(restored.errored) - XCTAssertNil(restored.entries) - XCTAssertEqual(restored.count, 0) - } - } - - func testActivityRestoreRejectsCorruptLinkageInsteadOfReturningPartialHistory() throws { - let container = try DashModelContainer.createInMemory() - let handler = PlatformWalletPersistenceHandler(modelContainer: container) - seed(handler) - let context = ModelContext(container) - let rows = try context.fetch(FetchDescriptor()) - rows[0].noteCmxs = Data([1]) - try context.save() - let reader = PlatformWalletPersistenceHandler(modelContainer: container) - let restored = reader.loadShieldedActivity() - XCTAssertTrue(restored.errored) - XCTAssertNil(restored.entries) - XCTAssertEqual(restored.count, 0) - } - - - private enum RestoreKind: CaseIterable { - case notes, outgoing, syncStates, viewingKeys, activity - - var model: any PersistentModel.Type { - switch self { - case .notes: return PersistentShieldedNote.self - case .outgoing: return PersistentShieldedOutgoingNote.self - case .syncStates: return PersistentShieldedSyncState.self - case .viewingKeys: return PersistentShieldedViewingKey.self - case .activity: return PersistentShieldedActivity.self - } - } - } - - /// Exercise the actual callbacks Rust consumes, including their ownership - /// contract: a failed load must expose neither an allocation nor a prefix. - private func restore(_ kind: RestoreKind, with handler: PlatformWalletPersistenceHandler) - -> (code: Int32, count: UInt, hasEntries: Bool) { - let callbacks = handler.makeCallbacks() - defer { callbacks.release_fn?(callbacks.context) } - func load( - _ type: Entry.Type, - read: (UnsafeMutablePointer?>, UnsafeMutablePointer) -> Int32, - free: (UnsafePointer?, UInt) -> Void - ) -> (code: Int32, count: UInt, hasEntries: Bool) { - var entries: UnsafePointer? - var count: UInt = 99 // The callback must clear stale output on failure. - let code = read(&entries, &count) - defer { if let entries { free(entries, count) } } - return (code, count, entries != nil) - } - switch kind { - case .notes: - return load(ShieldedNoteRestoreFFI.self, - read: { callbacks.on_load_shielded_notes_fn!(callbacks.context, $0, $1) }, - free: { callbacks.on_load_shielded_notes_free_fn!(callbacks.context, $0, $1) }) - case .outgoing: - return load(ShieldedOutgoingNoteRestoreFFI.self, - read: { callbacks.on_load_shielded_outgoing_notes_fn!(callbacks.context, $0, $1) }, - free: { callbacks.on_load_shielded_outgoing_notes_free_fn!(callbacks.context, $0, $1) }) - case .syncStates: - return load(ShieldedSubwalletSyncStateFFI.self, - read: { callbacks.on_load_shielded_sync_states_fn!(callbacks.context, $0, $1) }, - free: { callbacks.on_load_shielded_sync_states_free_fn!(callbacks.context, $0, $1) }) - case .viewingKeys: - return load(ShieldedViewingKeyRestoreFFI.self, - read: { callbacks.on_load_shielded_viewing_keys_fn!(callbacks.context, $0, $1) }, - free: { callbacks.on_load_shielded_viewing_keys_free_fn!(callbacks.context, $0, $1) }) - case .activity: - return load(ShieldedActivityRestoreFFI.self, - read: { callbacks.on_load_shielded_activity_fn!(callbacks.context, $0, $1) }, - free: { callbacks.on_load_shielded_activity_free_fn!(callbacks.context, $0, $1) }) - } - } - - private func assertRestoreFails(_ kind: RestoreKind, with handler: PlatformWalletPersistenceHandler, - file: StaticString = #filePath, line: UInt = #line) { - let result = restore(kind, with: handler) - XCTAssertNotEqual(result.code, 0, "\(kind)", file: file, line: line) - XCTAssertEqual(result.count, 0, "\(kind)", file: file, line: line) - XCTAssertFalse(result.hasEntries, "\(kind)", file: file, line: line) - } - - private func note(_ id: UInt8, wallet: Data? = nil) -> PersistentShieldedNote { - PersistentShieldedNote(walletId: wallet ?? walletId, accountIndex: 0, - position: UInt64(id), cmx: Data(repeating: id, count: 32), - nullifier: Data(repeating: id + 50, count: 32), blockHeight: 1, - isSpent: false, value: UInt64(id) * 100, noteData: Data(repeating: id, count: 115)) - } - - func testMalformedUnrelatedNoteRejectsCallbackAndPreservesAggregateAfterReopen() throws { - // A is the drain's output. B is an unrelated receipt in the same scan - // aggregate. No partial snapshot may let A's completion discard B. - let fields: [ReferenceWritableKeyPath] = [ - \.walletId, \.cmx, \.nullifier, \.noteData, - ] - for field in fields { - 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) - seed(PlatformWalletPersistenceHandler(modelContainer: container)) - let context = ModelContext(container) - context.insert(PersistentWallet(walletId: walletId, network: .testnet)) - context.insert(note(1)) - let unrelated = note(2) - unrelated[keyPath: field] = Data([2]) - context.insert(unrelated) - try context.save() - let reader = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - assertRestoreFails(.notes, with: reader) - } - try autoreleasepool { - let reopened = try DashModelContainer.create(url: url) - let reader = PlatformWalletPersistenceHandler(modelContainer: reopened, network: .testnet) - assertRestoreFails(.notes, with: reader) - let context = ModelContext(reopened) - let histories = try context.fetch(FetchDescriptor()) - let aggregate = try XCTUnwrap(histories.first { $0.walletId == walletId && $0.accountIndex == 0 }) - XCTAssertEqual(aggregate.entryId, Data(repeating: 9, count: 32)) - XCTAssertEqual(aggregate.amount, 300) - XCTAssertEqual(aggregate.noteCmxs, Data(repeating: 1, count: 32) + Data(repeating: 2, count: 32)) - XCTAssertEqual(try reader.loadCachedAssetLocks(walletId: walletId).first?.statusRaw, 0) - let rows = try context.fetch(FetchDescriptor()) - XCTAssertEqual(rows.count, 2) - let unrelated = try XCTUnwrap(rows.first { $0.position == 2 }) - unrelated[keyPath: field] = note(2)[keyPath: field] - try context.save() - let repaired = PlatformWalletPersistenceHandler(modelContainer: reopened, network: .testnet) - let restored = restore(.notes, with: repaired) - XCTAssertEqual(restored.code, 0) - XCTAssertEqual(restored.count, 2) - XCTAssertTrue(restored.hasEntries) - } - } - } - - func testOutgoingRestoreRejectsMalformedRowsWithoutReturningValidPrefix() throws { - let fields: [ReferenceWritableKeyPath] = [ - \.walletId, \.cmx, \.recipient, - ] - for field in fields { - let container = try DashModelContainer.createInMemory() - let context = ModelContext(container) - context.insert(PersistentWallet(walletId: walletId, network: .testnet)) - for id: UInt8 in [1, 2] { - let row = PersistentShieldedOutgoingNote(walletId: walletId, accountIndex: 0, - cmx: Data(repeating: id, count: 32), recipient: Data(repeating: id, count: 43), - value: 100, memo: Data(), blockHeight: 1) - if id == 2 { row[keyPath: field] = Data([2]) } - context.insert(row) - } - try context.save() - assertRestoreFails(.outgoing, with: PlatformWalletPersistenceHandler( - modelContainer: container, network: .testnet)) - } - } - - func testSyncStateRestoreRejectsMalformedWalletBeforeNetworkFiltering() throws { - let container = try DashModelContainer.createInMemory() - let context = ModelContext(container) - context.insert(PersistentWallet(walletId: walletId, network: .testnet)) - context.insert(PersistentShieldedSyncState(walletId: walletId, accountIndex: 0, lastSyncedIndex: 2)) - context.insert(PersistentShieldedSyncState(walletId: Data([2]), accountIndex: 1, lastSyncedIndex: 4)) - try context.save() - assertRestoreFails(.syncStates, with: PlatformWalletPersistenceHandler( - modelContainer: container, network: .testnet)) - } - - func testActivityAndViewingKeyRestoreRejectMalformedWalletBeforeFiltering() throws { - let container = try DashModelContainer.createInMemory() - seed(PlatformWalletPersistenceHandler(modelContainer: container)) - let context = ModelContext(container) - context.insert(PersistentWallet(walletId: walletId, network: .testnet)) - let histories = try context.fetch(FetchDescriptor()) - let history = try XCTUnwrap(histories.first { $0.walletId == walletId }) - history.walletId = Data([2]) - context.insert(PersistentShieldedViewingKey(walletId: walletId, accountIndex: 0, - fvkBytes: Data(repeating: 1, count: 96))) - context.insert(PersistentShieldedViewingKey(walletId: Data([2]), accountIndex: 1, - fvkBytes: Data(repeating: 2, count: 96))) - try context.save() - for kind in [RestoreKind.activity, .viewingKeys] { - assertRestoreFails(kind, with: PlatformWalletPersistenceHandler( - modelContainer: container, network: .testnet)) - } - } - - func testShieldedRestorePropagatesTableAndNetworkScopeFetchFailures() throws { - let container = try DashModelContainer.createInMemory() - for kind in RestoreKind.allCases { - for model in [kind.model, PersistentWallet.self] { - let fetcher = FetchFaultInjector(faulting: model) - assertRestoreFails(kind, with: PlatformWalletPersistenceHandler( - modelContainer: container, network: .testnet, modelFetcher: fetcher)) - XCTAssertTrue(fetcher.observedReads.contains(String(describing: model))) - } - } - } - - func testShieldedRestoreRejectsMalformedWalletScope() throws { - let container = try DashModelContainer.createInMemory() - let context = ModelContext(container) - context.insert(PersistentWallet(walletId: Data([1]), network: .testnet)) - try context.save() - for kind in RestoreKind.allCases { - assertRestoreFails(kind, with: PlatformWalletPersistenceHandler( - modelContainer: container, network: .testnet)) - } - } - - func testValidScanRestoreStillScopesByNetworkAndAllowsEmptySnapshots() throws { - let container = try DashModelContainer.createInMemory() - let context = ModelContext(container) - context.insert(PersistentWallet(walletId: walletId, network: .testnet)) - context.insert(PersistentWallet(walletId: otherWalletId, network: .mainnet)) - for (id, wallet) in [(UInt8(1), walletId), (UInt8(2), otherWalletId)] { - context.insert(note(id, wallet: wallet)) - context.insert(PersistentShieldedOutgoingNote(walletId: wallet, accountIndex: 0, - cmx: Data(repeating: id, count: 32), recipient: Data(repeating: id, count: 43), - value: 100, memo: Data(), blockHeight: 1)) - context.insert(PersistentShieldedSyncState(walletId: wallet, accountIndex: 0, lastSyncedIndex: 2)) - context.insert(PersistentShieldedViewingKey(walletId: wallet, accountIndex: 0, - fvkBytes: Data(repeating: id, count: 96))) - } - try context.save() - for kind in [RestoreKind.notes, .outgoing, .syncStates, .viewingKeys] { - let scoped = restore(kind, with: PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet)) - XCTAssertEqual(scoped.code, 0) - XCTAssertEqual(scoped.count, 1) - XCTAssertTrue(scoped.hasEntries) - let unscoped = restore(kind, with: PlatformWalletPersistenceHandler(modelContainer: container)) - XCTAssertEqual(unscoped.code, 0) - XCTAssertEqual(unscoped.count, 2) - let empty = restore(kind, with: PlatformWalletPersistenceHandler( - modelContainer: try DashModelContainer.createInMemory(), network: .testnet)) - XCTAssertEqual(empty.code, 0) - XCTAssertEqual(empty.count, 0) - XCTAssertFalse(empty.hasEntries) - } - } -} From 74a75db0f1c09ad34ecf9d3dde4f9a5db802d6a9 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 15 Oct 2026 09:22:07 +0200 Subject: [PATCH 15/24] docs(sdk): drop signed-attempt persistence from the drain parity entry Co-Authored-By: Claude Fable 5.1 --- docs/sdk/sdk-parity-manifest.json | 32 ++++++++++--------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index 5dfa1d506d1..5efead27286 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -9,8 +9,6 @@ "invitations", "pending_contact_crypto", "provider_transactions", - "shielded_activity_reconciliation", - "shielded_funding_attempts", "shielded_viewing_keys", "tracked_asset_locks", "unsigned_token_storage", @@ -1614,7 +1612,7 @@ }, { "id": "shielded.core_wallet_drain", - "title": "Pooled Core-to-shielded Max with authenticated restart recovery", + "title": "Pooled Core-to-shielded Max with restart-safe recovery", "area": "recovery", "shared_apis": [ "platform_wallet_manager_shielded_wallet_drain_preview", @@ -1625,8 +1623,6 @@ "asset_lock_funding_indices", "asset_lock_operation_ids", "atomic_changesets", - "shielded_activity_reconciliation", - "shielded_funding_attempts", "tracked_asset_locks", "wallet_restore" ], @@ -1635,13 +1631,13 @@ "sdk": "supported", "example_app": "unsupported", "restart": "tested", - "reason": "Swift exposes preview, execution, and status with durable operation UUIDs and append-only signed funding attempts. Restart recovery verifies exact signed outputs against authenticated shielded scan data before durably completing the operation. Unit tests cover store reopen, failed persistence, foreign or partial evidence, and earlier randomized retries. An iOS wallet testnet process-death test confirmed recovery after dispatch and enabled a subsequent Max transfer. Legacy test operations without saved attempts remain pending. SwiftExampleApp does not yet expose this flow." + "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, operation-ID callbacks, and signed-attempt persistence callbacks are not implemented." + "reason": "The typed input-limit error is mapped, but JNI drain wrappers and operation-ID callbacks are not implemented." } }, "verification": [ @@ -1672,25 +1668,17 @@ { "host": "shared", "kind": "unit", - "file": "packages/rs-platform-wallet/src/wallet/shielded/drain_recovery.rs", - "id": "should_reconcile_drain_after_dispatch_crash_using_an_earlier_exact_attempt", - "command": "cargo test -p platform-wallet --features shielded --lib drain_recovery::tests", + "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": "swift", - "kind": "unit", - "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift", - "id": "testSignedFundingAttemptsAreAppendOnlyAndSurviveConsumedReopen", - "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": "swift", + "host": "shared", "kind": "unit", - "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedActivityReconciliationTests.swift", - "id": "testReplacementAndCompletionSurviveReopenWithoutTouchingOtherAccountsOrWallets", - "command": "cd packages/swift-sdk && xcodebuild test -scheme SwiftDashSDK -sdk iphonesimulator -destination \"platform=iOS Simulator,name=iPhone 17\" ARCHS=arm64 -only-testing:SwiftDashSDKTests/ShieldedActivityReconciliationTests", + "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 } ] From f4d485a51cb00829f0aa234c852070391e01205b Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 15 Oct 2026 10:25:45 +0200 Subject: [PATCH 16/24] fix(platform-wallet): complete a drain only when the proof attests full consumption The landed Type18's verified proof result was discarded, so a drain wrote its terminal `DrainCompleted` receipt without checking what the proof said about the lock. `build_and_broadcast_shielded` now returns that verdict with the landed actions and a drain completes only on `StoredAssetLockInfo::FullyConsumed`; anything else leaves the operation pending with its outpoint. The generic Type18 path keeps base behaviour. Also drop the stale signed-attempt wording from the apply invariants. Co-Authored-By: Claude Fable 5.1 --- .../rs-platform-wallet/src/wallet/apply.rs | 4 +- .../wallet/shielded/fund_from_asset_lock.rs | 117 +++++++++++++++--- 2 files changed, 104 insertions(+), 17 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 50fe85f9864..613a7dffc1e 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -12,8 +12,8 @@ //! 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, shorten signed-attempt history or regress a consumed -//! tombstone. A conflicting UUID cannot rebind the existing operation. +//! 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. 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 4b1d4d7f53c..cdf7a585320 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 @@ -31,6 +31,7 @@ use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::transition::put_settings::PutSettings; 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; @@ -468,7 +469,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(), @@ -486,7 +487,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!( @@ -546,7 +547,7 @@ impl PlatformWallet { .asset_locks .tracked_operation_id(&proof_out_point) .await; - let Some(landed_actions) = self + let Some(landed) = self .asset_locks .settle_asset_lock_submit_result( submit_result, @@ -571,7 +572,7 @@ 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: finalize the tracked asset lock. The shielded note itself @@ -579,10 +580,23 @@ impl PlatformWallet { // 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; the next resume completes it from - // Platform's already-consumed verdict. + // A drain writes its durable completion receipt only when the + // verified proof attests the lock was consumed in full. An error + // here keeps the operation Pending with its outpoint; the next + // resume completes it from Platform's verdict. (Some(operation_id), _) => { + if !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(), + )); + } self.asset_locks .complete_wallet_drain(proof_out_point, operation_id) .await?; @@ -817,16 +831,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, @@ -840,7 +883,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, @@ -877,9 +920,13 @@ where // 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. - st.broadcast_and_wait_for_affected_state::(&sdk, settings) + 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. @@ -962,6 +1009,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(); From 319a1b14522cb56cd4bdf14af88924d40cb5be5e Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 15 Oct 2026 11:03:56 +0200 Subject: [PATCH 17/24] fix(platform-wallet): check the drain's consumption verdict before recording activity A drain left pending by a non-FullyConsumed proof verdict must not carry a Confirmed ShieldFromAssetLock history row for a bundle a later resume may replace. Move the guard ahead of the activity record; the generic Type18 path is unchanged. Co-Authored-By: Claude Fable 5.1 --- .../wallet/shielded/fund_from_asset_lock.rs | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) 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 cdf7a585320..333eab503d4 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 @@ -565,6 +565,24 @@ impl PlatformWallet { 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), // `direction in`, amount = the real shielded note value (dummy @@ -580,23 +598,9 @@ impl PlatformWallet { // 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 only when the - // verified proof attests the lock was consumed in full. An error - // here keeps the operation Pending with its outpoint; the next - // resume completes it from Platform's verdict. + // A drain writes its durable completion receipt. An error here + // keeps the operation Pending with its outpoint. (Some(operation_id), _) => { - if !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(), - )); - } self.asset_locks .complete_wallet_drain(proof_out_point, operation_id) .await?; From 5a6d66226408c98b941f86c2cc17f937939fe61e Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Fri, 16 Oct 2026 12:37:15 +0200 Subject: [PATCH 18/24] fix(platform-wallet): use the non-deprecated secp256k1 secret-key constructor in the drain fixture CI denies warnings; `SecretKey::from_byte_array` is deprecated in secp256k1 0.33 in favour of `from_secret_bytes`. Co-Authored-By: Claude Fable 5.1 --- .../src/wallet/shielded/wallet_drain_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 7a02f79dc5a..2a3d9ab0787 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs @@ -44,7 +44,7 @@ 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_byte_array([0x11; 32]).unwrap(); + 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, From 54ff77479c3eaed7fa643826154bc6f1e585662c Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Fri, 16 Oct 2026 16:01:16 +0200 Subject: [PATCH 19/24] fix(platform-wallet): admit wallet drains on inline-committing persistence only Every drain write (checkpoint, rejection removal, completion) treats a successful `store` 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. `require_wallet_drain_persistence` now also requires `store_commits_inline`, so every drain entry point refuses a buffered backend with a typed persistence error before any Core funds move. The buffered branches in the rejection cleanup and the completion write are unreachable now and were removed. The shipped backends are unaffected: the FFI persister commits inline whenever callbacks are installed and SQLite does in `FlushMode::Immediate`. The drain test fixtures run in inline-commit mode; the new regression covers the refusal and that the same backend in inline mode is admitted. Co-Authored-By: Claude Fable 5.1 --- docs/sdk/sdk-parity-manifest.json | 8 +++ .../src/wallet/asset_lock/build.rs | 34 ++++++---- .../src/wallet/asset_lock/operation.rs | 23 +++++-- .../wallet/shielded/fund_from_asset_lock.rs | 17 +++-- .../src/wallet/shielded/wallet_drain_tests.rs | 65 +++++++++++++++++-- 5 files changed, 117 insertions(+), 30 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index 5efead27286..aa26292d4b4 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -1680,6 +1680,14 @@ "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/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 8d91f4776c5..114e6711042 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -966,6 +966,9 @@ impl AssetLockManager { 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 under the build's // wallet-manager guard; without this serialization, build A's snapshot @@ -1261,14 +1264,11 @@ impl AssetLockManager { "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() { - if !self.persister.store_commits_inline() { - return Err(PlatformWalletError::Persistence(format!( - "could not durably remove rejected drain asset lock: {error}" - ))); - } - // The removal is already committed. A failed host - // notification must not strand its context or inputs. tracing::warn!(%out_point, %error, "rejected drain removed inline; flush notification failed"); } } else { @@ -1607,6 +1607,9 @@ mod tests { 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() } } @@ -4630,8 +4633,9 @@ mod tests { #[tokio::test] async fn should_release_rejected_drain_only_when_removal_committed() { - for commits_inline in [false, true] { - for fail_remove_store in [false, true] { + 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 { @@ -4725,11 +4729,11 @@ mod tests { #[cfg(feature = "shielded")] #[tokio::test] async fn should_retry_rolled_back_drain_checkpoint_without_rebuilding() { - for inline in [false, true] { + { let (wallet_manager, wallet_id, _, signer) = funded_wallet_manager(StandardAccountType::BIP44Account).await; let persistence = Arc::new(CapturingPersistence { - commits_inline: inline, + 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) @@ -4927,11 +4931,13 @@ mod tests { .state, super::AssetLockOperationState::Pending ); - // A failed buffered flush leaves only the unpublished checkpoint. - // Explicit retry must make that exact row durable before tracking it. + // 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 - .is_empty()); + .values() + .any(|locks| locks.contains_key(&point))); assert_eq!( manager.begin_wallet_drain(&operation).await.unwrap(), Some(point) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs index 4e93e9dc8e1..09947ba0782 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/operation.rs @@ -183,12 +183,10 @@ impl AssetLockManager { ..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() { - if !self.persister.store_commits_inline() { - return Err(PlatformWalletError::Persistence(error.to_string())); - } - // The row is already committed. A failed notification must - // not suppress its live publication. tracing::warn!(%out_point, %error, "drain completion committed inline; flush notification failed"); } lock.status = AssetLockStatus::DrainCompleted; @@ -221,6 +219,14 @@ impl AssetLockManager { .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; @@ -230,6 +236,13 @@ impl AssetLockManager { 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(()) } 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 333eab503d4..a88b9e55325 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 @@ -1259,6 +1259,10 @@ mod tests { ) { 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 @@ -1432,16 +1436,17 @@ mod tests { assert!(persisted_statuses(&ctx).is_empty()); assert_eq!(operation.snapshot().state, AssetLockOperationState::Pending); - // A buffered backend whose flush fails has not committed: stay Pending. - *ctx.persistence - .fail_next_flush - .lock() - .expect("recording persistence mutex") = Some(PersistenceErrorKind::Transient); + // 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(_)) + 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 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 index 2a3d9ab0787..4bbaf85510d 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs @@ -210,7 +210,9 @@ async fn wallet_fixture( staged: Mutex::new(BTreeMap::new()), fail_store: AtomicBool::new(false), fail_flush: AtomicBool::new(false), - commits_inline: 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( @@ -583,9 +585,8 @@ async fn should_complete_drain_when_an_inline_commit_flush_notification_fails() 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; - for (fail_store, fail_flush) in [(true, false), (false, true)] { - persistence.fail_store.store(fail_store, Ordering::SeqCst); - persistence.fail_flush.store(fail_flush, Ordering::SeqCst); + { + persistence.fail_store.store(true, Ordering::SeqCst); assert!(matches!( wallet .asset_locks @@ -609,7 +610,6 @@ async fn should_keep_drain_pending_when_completion_persistence_fails() { assert_eq!(status.out_point, Some(lock.out_point)); } persistence.fail_store.store(false, Ordering::SeqCst); - persistence.fail_flush.store(false, Ordering::SeqCst); wallet .asset_locks .complete_wallet_drain(lock.out_point, OPERATION_ID) @@ -626,6 +626,61 @@ async fn should_keep_drain_pending_when_completion_persistence_fails() { ); } +/// 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()); + + 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(); From 6177182b0cbab4354c0c2fb3c4636f33d1b91942 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Fri, 16 Oct 2026 16:25:44 +0200 Subject: [PATCH 20/24] fix(platform-wallet): apply the drain persistence admission on every resume path The inline-commit and `WALLET_DRAIN` checks were selected through the drain's own entry points, but the generic exact-outpoint resume (the "Finish now" path, identity-registration resumes and the readiness-deferred retry) reached an operation-tagged lock without them, so a tagged row restored onto a buffered backend could dispatch and submit before the completion write was refused. Check the lock's operation association in `resume_asset_lock` itself, before any dispatch or lifecycle write. Untagged legacy locks are unaffected. The buffered-backend regression now also drives the generic resume and `resume_asset_lock` directly. Co-Authored-By: Claude Fable 5.1 --- .../src/wallet/asset_lock/sync/recovery.rs | 8 ++++++ .../src/wallet/shielded/wallet_drain_tests.rs | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+) 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 44ff287b04d..b703cdcf414 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 @@ -978,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 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 index 4bbaf85510d..17471c1f36a 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs @@ -661,6 +661,31 @@ async fn should_refuse_drain_on_a_buffered_persistence_backend() { .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), From 73f1bc9372208ceb69d30f4917389af4f91985c3 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Fri, 16 Oct 2026 17:14:12 +0200 Subject: [PATCH 21/24] test(platform-wallet): keep the missing-wallet drain regression reaching the durable read With drains admitted on inline-committing backends only, the default capturing persistence was refused in preflight and the test no longer exercised the missing-wallet protection it names. Use an admitted backend with no restored wallet and pin the error to the durable-snapshot message. Co-Authored-By: Claude Fable 5.1 --- .../src/wallet/asset_lock/build.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 114e6711042..f04a541e86a 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -3949,19 +3949,29 @@ mod tests { wallet_id, Arc::new(Notify::new()), broadcaster.clone(), - WalletPersister::new(wallet_id, Arc::new(CapturingPersistence::default())), + // 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(_)) + 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(_)) + Err(PlatformWalletError::Persistence(message)) + if message.contains("wallet missing from durable snapshot") )); assert_eq!( operation.snapshot().state, From 849f036e5e57bd5c61b35f6b3674abc37dd65b17 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Fri, 16 Oct 2026 23:53:12 +0200 Subject: [PATCH 22/24] fix(platform-wallet): cap a wallet drain at the asset-lock input limit, not the standard-tx limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An asset lock may carry at most `max_asset_lock_transaction_inputs` (100) inputs: Dash Core's relay policy drops a larger lock silently (`assetlocktx-too-many-inputs`) and Platform consensus validates the same bound. The drain only inherited key-wallet's 500-input standard-transaction limit, so a 101–500-input balance built and broadcast a lock that could never land (seen on mainnet in support case 32500). Read the cap from the protocol version, judge the built drain payload against it in the same abandon path as the drain floor (reservation released, nothing on the wire) and refuse with the typed `AssetLockTooManyInputs`; have the preview report `InputLimitMayBeExceeded` above it before sizing the payload. Tests, registry and host fixture texts follow. Scoped to drains; dashpay/rust-dashcore#1191 brings the same bound to every asset-lock build in key-wallet. Co-Authored-By: Claude Fable 5.1 --- .../dashsdk/errors/DashSdkErrorTest.kt | 2 +- .../ERROR_CODE_REGISTRY.md | 2 +- packages/rs-platform-wallet-ffi/src/error.rs | 5 +- packages/rs-platform-wallet/src/error.rs | 4 +- .../src/wallet/asset_lock/build.rs | 54 +++++++++++++------ .../src/wallet/asset_lock/mod.rs | 17 ++++++ .../src/wallet/shielded/wallet_drain.rs | 15 ++++-- .../SwiftDashSDKTests/WalletDrainTests.swift | 4 +- 8 files changed, 76 insertions(+), 27 deletions(-) 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 978d7c6221d..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 @@ -11,7 +11,7 @@ class DashSdkErrorTest { @Test fun shouldPreserveAssetLockInputCapWithoutRetryingAPartialDrain() { - val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 60, "501 exceeds 500") + 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) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index b517ee0bf9c..a916a4eb433 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -176,7 +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 exceeds the builder input cap; no partial drain was broadcast. Rust, Swift and Kotlin retain the typed refusal. Renumbered from 59 after #5322 claimed it | +| 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/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 86ae3219c5d..0bc67f532c9 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -609,8 +609,9 @@ pub enum PlatformWalletFFIResultCode { /// [`Self::ErrorShutdownIncomplete`]: restart the host process. ErrorSpvProcessRestartRequired = 59, - /// An all-input asset lock exceeds the builder's input cap. Nothing was - /// broadcast; the host must not silently substitute a partial drain. + /// 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. diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 641d062c42b..0187c66d9d3 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -590,7 +590,9 @@ pub enum PlatformWalletError { )] AssetLockInsufficientFunds { available: u64, required: u64 }, - /// A whole-balance asset lock cannot fit into one standard transaction. + /// 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 }, 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 f04a541e86a..ee90f7ccde5 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -31,7 +31,7 @@ use crate::ASSET_LOCK_FUNDING_SOURCES; use super::manager::{AssetLockManager, DEFAULT_FEE_PER_KB}; use super::tracked::{AssetLockStatus, TrackedAssetLock}; -use super::{AssetLockOperationContext, AssetLockOperationState}; +use super::{max_asset_lock_inputs, AssetLockOperationContext, AssetLockOperationState}; // --------------------------------------------------------------------------- // Asset lock transaction building @@ -1042,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 @@ -1076,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); } } @@ -4200,8 +4220,8 @@ mod tests { } #[tokio::test] - async fn should_wallet_drain_refuse_501_inputs_before_dispatch_and_allow_500() { - for count in [501, 500] { + 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, @@ -4227,12 +4247,12 @@ mod tests { Some(&operation), ) .await; - if count == 501 { + if count == 101 { assert!(matches!( result, Err(PlatformWalletError::AssetLockTooManyInputs { - count: 501, - max: 500 + count: 101, + max: 100 }) )); assert_eq!(manager.broadcaster.calls(), 0); @@ -4244,7 +4264,7 @@ mod tests { .transaction .input .len(), - 500 + 100 ); } } 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 6557e4b797b..58f75ddb1a3 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs @@ -17,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/shielded/wallet_drain.rs b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs index 40acfd62a97..0a62a358515 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs @@ -18,7 +18,8 @@ use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoIn use crate::error::PlatformWalletError; use crate::wallet::asset_lock::manager::DEFAULT_FEE_PER_KB; use crate::wallet::asset_lock::{ - AssetLockOperationContext, AssetLockOperationRegistration, AssetLockOperationSnapshot, + max_asset_lock_inputs, AssetLockOperationContext, AssetLockOperationRegistration, + AssetLockOperationSnapshot, }; use crate::wallet::core::resolved_funding_accounts; use crate::wallet::PlatformWallet; @@ -118,6 +119,14 @@ impl PlatformWallet { 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. @@ -253,13 +262,13 @@ mod tests { .status, WalletDrainPreviewStatus::InsufficientFunds ); - let large = wallet_with_outputs(&vec![10_000; 501]).await; + 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, 501); + assert_eq!(quote.candidate_input_count, 101); assert_eq!(quote.estimated_l1_fee_duffs, None); assert_eq!(quote.estimated_recipient_credits, None); } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift index ebcdf44cebe..ccfcc9e3793 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift @@ -48,11 +48,11 @@ final class WalletDrainTests: XCTestCase { } func testShouldPreserveTypedInputCapRefusal() { - let error = PlatformWalletError(code: .errorAssetLockTooManyInputs, message: "501 exceeds 500") + 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, "501 exceeds 500") + XCTAssertEqual(detail, "101 exceeds 100") XCTAssertEqual(PlatformWalletResultCode.errorAssetLockTooManyInputs.rawValue, 60) } From 6f65ff5fab8264bed3c0455f3a7a2ec6c791a1fc Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sat, 17 Oct 2026 00:56:54 +0200 Subject: [PATCH 23/24] fix(swift-sdk): keep drain status authoritative in Rust while a call is in flight `shieldedWalletDrainStatus` returned a synthesized pending-without-outpoint answer for the whole duration of an in-flight call with the same UUID, hiding the exact outpoint Rust had persisted, a durable completion of a repeated UUID and the native read errors. Always marshal the native snapshot. While a call is active, pass it through unchanged and substitute pending-without-outpoint only for a native `notSent`, which is exactly the interval before the detached execution has entered the Rust registry (the FFI registers the operation on entry). Covered by `testShouldKeepNativeStatusAuthoritativeWhileACallIsActive`. Co-Authored-By: Claude Fable 5.1 --- .../PlatformWalletManagerWalletDrain.swift | 17 ++++++++++++---- .../SwiftDashSDKTests/WalletDrainTests.swift | 20 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerWalletDrain.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerWalletDrain.swift index 98b41998d84..1173e075d20 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerWalletDrain.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerWalletDrain.swift @@ -208,10 +208,8 @@ extension PlatformWalletManager { let handle = self.handle let callIdentity = WalletDrainCallIdentity( manager: handle, walletId: walletId, operationId: operationId) - if Self.walletDrainCalls[callIdentity] != nil { - return WalletDrainOutcome(state: .pending, outpoint: nil) - } - return try await withWalletDrainNativeOperation("shieldedWalletDrainStatus") { handle in + 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 @@ -221,6 +219,17 @@ extension PlatformWalletManager { 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. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift index ccfcc9e3793..5c2918eb4f9 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift @@ -79,4 +79,24 @@ final class WalletDrainTests: XCTestCase { } } } + + 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) + } } From 7a83ef814c498f2fd3e173ff1b1222d82e5f10ac Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 19 Oct 2026 11:55:53 +0200 Subject: [PATCH 24/24] refactor(platform-wallet): localize drain settlement and narrow recipient API --- .../src/wallet/asset_lock/orchestration.rs | 37 +---------------- .../wallet/shielded/fund_from_asset_lock.rs | 40 ++++++++++++++++++- ...PlatformWalletManagerShieldedFunding.swift | 2 +- .../PlatformWalletManagerWalletDrain.swift | 11 +++-- .../SwiftDashSDKTests/WalletDrainTests.swift | 2 +- 5 files changed, 49 insertions(+), 43 deletions(-) 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 2d3ae657b26..96fe2dba014 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -454,8 +454,7 @@ impl AssetLockManager { /// so it cannot prove that the requested operation completed. Promote the /// lock to an SPV-backed ChainLock proof, durably retain it as /// consumption-unknown, and preserve the typed host signal. Successful and - /// unrelated results pass through unchanged. Wallet drains settle through - /// [`Self::settle_asset_lock_submit_result`] instead. + /// unrelated results pass through unchanged. /// /// `chain_lock_timeout` bounds the IS→CL promotion. `None` does **not** /// mean "wait forever" here: it selects @@ -539,40 +538,6 @@ impl AssetLockManager { Err(PlatformWalletError::AssetLockAlreadyConsumed(*out_point)) } - /// 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. - #[cfg(any(feature = "shielded", test))] - pub(crate) 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) - } - /// Rebuild a ChainLock proof for a lock whose IS-lock never propagated, /// **persist it**, and hand back the proof with the derivation path the /// caller needs to sign. 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 a88b9e55325..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 @@ -30,6 +30,7 @@ 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; @@ -45,7 +46,9 @@ 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, @@ -71,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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift index 417db7ec4e8..cc1f219e19f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedFunding.swift @@ -458,7 +458,7 @@ extension PlatformWalletManager { /// produces a synchronous, type-specific error before paying /// for the `Task.detached` setup, the resolver allocation, and /// the (potentially long-running) Halo 2 proof build. - func shieldedFundFromAssetLockPreflight( + private func shieldedFundFromAssetLockPreflight( walletId: Data, recipients: [ShieldedFundFromAssetLockRecipient] ) throws { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerWalletDrain.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerWalletDrain.swift index 1173e075d20..6bfeffb66cf 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerWalletDrain.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerWalletDrain.swift @@ -138,10 +138,14 @@ extension PlatformWalletManager { walletId: Data, operationId: UUID, fundingAccountIndex: UInt32 = 0, - recipients: [ShieldedFundFromAssetLockRecipient] + recipientRaw43: Data ) async -> WalletDrainOutcome { do { - try shieldedFundFromAssetLockPreflight(walletId: walletId, recipients: recipients) + 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 @@ -162,12 +166,11 @@ extension PlatformWalletManager { Self.walletDrainCalls.removeValue(forKey: callIdentity) } } - let recipient = recipients[0].recipientRaw43 let coreSigner = MnemonicResolver() return await Task.detached(priority: .userInitiated) { do { return try Self.withWalletDrainIDs(walletId, operationId: operationId) { walletPtr, operationPtr in - try recipient.withUnsafeBytes { raw in + try recipientRaw43.withUnsafeBytes { raw in guard let recipientPtr = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else { throw PlatformWalletError.invalidParameter("recipient baseAddress is nil") } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift index 5c2918eb4f9..2e6476b444d 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletDrainTests.swift @@ -62,7 +62,7 @@ final class WalletDrainTests: XCTestCase { let outcome = await manager.shieldedFundFromWalletDrain( walletId: Data(repeating: 1, count: 32), operationId: UUID(), - recipients: [ShieldedFundFromAssetLockRecipient(recipientRaw43: Data(repeating: 2, count: 43))] + recipientRaw43: Data(repeating: 2, count: 43) ) XCTAssertEqual(outcome.state, .pending, "local validation cannot disprove an earlier dispatch of this UUID") XCTAssertNil(outcome.outpoint)