diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/TrackedAssetLock.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/TrackedAssetLock.kt index 43f00748fe8..57133a21812 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/TrackedAssetLock.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/TrackedAssetLock.kt @@ -7,6 +7,15 @@ import org.dashfoundation.dashsdk.ffi.TrackedAssetLocksNativeResult * Rust-authoritative tracked asset-lock snapshot eligible for generic * identity recovery. Invitation (3), address/shielded (4/5), consumed (4), * and malformed rows are deliberately absent. + * + * [Status.RECOVERED_FROM_CHAIN] rows ARE eligible: they are asset locks + * the restore scan rebuilt from chain-locked history (Core finality + * proven, Platform-side consumption unknown), and the registration / + * top-up recovery screens are exactly the user-driven surface allowed + * to try consuming one — Platform rejects an already-spent outpoint + * with a typed error. Do NOT feed this status into any automatic + * stuck-lock retry sweep; blind retries of historical locks are the + * failure mode the dedicated status exists to prevent. */ data class TrackedAssetLock( val outpointTxid: ByteArray, @@ -28,6 +37,13 @@ data class TrackedAssetLock( BROADCAST(1), INSTANT_SEND_LOCKED(2), CHAIN_LOCKED(3), + // 4 (CONSUMED) stays deliberately unmapped: consumed rows are + // terminal tombstones and must never surface as recoverable. + + /** Rebuilt by the restore scan from a chain-locked record — + * finality proven (chain proof attached Rust-side), consumption + * unknown. Selectable for user-driven recovery only. */ + RECOVERED_FROM_CHAIN(5), } init { diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/TrackedAssetLockTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/TrackedAssetLockTest.kt index a602c8c9fc6..6f8e2e68c71 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/TrackedAssetLockTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/TrackedAssetLockTest.kt @@ -24,15 +24,20 @@ class TrackedAssetLockTest { row(0, 0), row(1, 1), row(2, 3), row(3, 2), // invitation: never generic row(4, 2), // address top-up - row(0, 4), // consumed + row(0, 4), // consumed: terminal tombstone, never recoverable + row(1, 5), // recovered from chain: user-driven recovery allowed row(0, 2, txidSize = 31), ), ) val eligible = TrackedAssetLock.eligibleFromNative(native) - assertEquals(listOf(0, 1, 2), eligible.map { it.fundingType.raw }) - assertEquals(listOf(0, 1, 3), eligible.map { it.status.raw }) + assertEquals(listOf(0, 1, 2, 1), eligible.map { it.fundingType.raw }) + assertEquals(listOf(0, 1, 3, 5), eligible.map { it.status.raw }) + assertEquals( + TrackedAssetLock.Status.RECOVERED_FROM_CHAIN, + eligible.last().status, + ) assertTrue(eligible.all { it.outpointTxid.size == 32 }) // Mapper owns its Kotlin copy; callers can't mutate the JNI row. native.entries[0].outpointTxid[0] = 99 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 4c79526cbf0..ea37d5886d3 100644 --- a/packages/rs-platform-wallet-ffi/src/asset_lock/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/asset_lock/manager.rs @@ -21,7 +21,9 @@ pub struct TrackedAssetLockFFI { pub identity_index: u32, /// Amount in duffs. pub amount: u64, - /// Status (0=Built, 1=Broadcast, 2=InstantSendLocked, 3=ChainLocked). + /// Status (0=Built, 1=Broadcast, 2=InstantSendLocked, 3=ChainLocked, + /// 4=Consumed, 5=RecoveredFromChain — finality proven by the restore + /// scan, Platform-side consumption unknown). pub status: u32, /// Whether a proof is attached. pub has_proof: bool, @@ -76,6 +78,7 @@ pub unsafe extern "C" fn asset_lock_manager_list_tracked_locks( AssetLockStatus::InstantSendLocked => 2, AssetLockStatus::ChainLocked => 3, AssetLockStatus::Consumed => 4, + AssetLockStatus::RecoveredFromChain => 5, }, 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 a0fe6906a6f..965e6ea1b11 100644 --- a/packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/asset_lock_persistence.rs @@ -167,5 +167,6 @@ fn status_to_u8(status: &AssetLockStatus) -> u8 { AssetLockStatus::InstantSendLocked => 2, AssetLockStatus::ChainLocked => 3, AssetLockStatus::Consumed => 4, + AssetLockStatus::RecoveredFromChain => 5, } } diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 2922e8cc020..72026484c3a 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -2031,6 +2031,8 @@ impl PlatformWalletPersistence for FFIPersister { block_height: e.block_height.unwrap_or(0), has_block_height: u8::from(e.block_height.is_some()), created_at_ms: e.created_at_ms, + min_note_position: e.min_note_position.unwrap_or(0), + has_min_note_position: u8::from(e.min_note_position.is_some()), identity_id, has_identity_id, counterparty_ptr, @@ -2586,6 +2588,11 @@ impl PlatformWalletPersistence for FFIPersister { }, status, created_at_ms: ffi.created_at_ms, + min_note_position: if ffi.has_min_note_position != 0 { + Some(ffi.min_note_position) + } else { + None + }, note_cmxs, spent_nullifiers, }); @@ -4828,6 +4835,7 @@ fn status_from_u8(b: u8) -> Result AssetLockStatus::InstantSendLocked, 3 => AssetLockStatus::ChainLocked, 4 => AssetLockStatus::Consumed, + 5 => AssetLockStatus::RecoveredFromChain, other => { return Err(PersistenceError::backend(format!( "tracked asset lock: unknown status discriminant {}", diff --git a/packages/rs-platform-wallet-ffi/src/shielded_persistence.rs b/packages/rs-platform-wallet-ffi/src/shielded_persistence.rs index e6a28ca5760..3b8b546cdad 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_persistence.rs @@ -144,8 +144,18 @@ pub struct ShieldedActivityFFI { /// `1` if `block_height` is meaningful (confirmed), `0` while pending. pub has_block_height: u8, /// Created-at time in ms since the Unix epoch (display-only; - /// `block_height` is the canonical sort key). + /// `block_height` is the canonical sort key). `0` = unknown — + /// scan-derived (restored) entries carry no wall-clock provenance. pub created_at_ms: u64, + /// Chain-order key when `has_min_note_position == 1`: the smallest + /// commitment-tree position among the entry's own received notes. + /// Tree positions are exact append-only chain order — hosts use + /// this to order otherwise-undatable restored entries. Set by the + /// scan deriver; live entries (which carry a real `created_at_ms`) + /// and outgoing-only clusters report `0`/`0`. + pub min_note_position: u64, + /// `1` if `min_note_position` is meaningful. + pub has_min_note_position: u8, /// Created identity id (only meaningful when `kind_tag == 6` / /// IdentityCreate); all-zero and ignored otherwise. pub identity_id: [u8; 32], @@ -268,6 +278,8 @@ pub struct ShieldedActivityRestoreFFI { pub block_height: u64, pub has_block_height: u8, pub created_at_ms: u64, + pub min_note_position: u64, + pub has_min_note_position: u8, pub identity_id: [u8; 32], pub has_identity_id: u8, pub counterparty_ptr: *const u8, diff --git a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs index 86b59a31947..54c10e37dfb 100644 --- a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs +++ b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs @@ -50,8 +50,22 @@ pub fn migration() -> String { let account_type_check = build_check_in(crate::sqlite::schema::accounts::ACCOUNT_TYPE_LABELS); let pool_type_check = build_check_in(crate::sqlite::schema::accounts::POOL_TYPE_LABELS); - let asset_lock_status_check = - build_check_in(crate::sqlite::schema::asset_locks::ASSET_LOCK_STATUS_LABELS); + // FROZEN as of V004: the asset-lock status domain must no longer be + // interpolated from the live `ASSET_LOCK_STATUS_LABELS` const — a + // later variant addition would silently rewrite this migration's + // generated SQL and break its Refinery checksum on every database + // that already applied it (`abort_divergent` default). New status + // labels are introduced by APPENDING a migration that rebuilds the + // table with the widened CHECK (see + // `V004__asset_lock_recovered_status.rs`); this list stays + // byte-identical to what V001 shipped with. + let asset_lock_status_check = build_check_in(&[ + "built", + "broadcast", + "is_locked", + "chain_locked", + "consumed", + ]); let contact_state_check = build_check_in(crate::sqlite::schema::contacts::CONTACT_STATE_LABELS); let pending_contact_crypto_kind_check = diff --git a/packages/rs-platform-wallet-storage/migrations/V004__asset_lock_recovered_status.rs b/packages/rs-platform-wallet-storage/migrations/V004__asset_lock_recovered_status.rs new file mode 100644 index 00000000000..7a716b13d09 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V004__asset_lock_recovered_status.rs @@ -0,0 +1,54 @@ +//! Widen the `asset_locks.status` CHECK domain with +//! `recovered_from_chain` — the status the restore-scan reconstruction +//! assigns to asset locks rebuilt from chain-locked on-chain records +//! (Core finality proven, Platform-side consumption unknown). +//! +//! SQLite cannot alter a CHECK constraint in place, so this rebuilds +//! the table: create the widened twin, copy every row, drop the old +//! table, rename. `asset_locks` is a leaf table (it references +//! `wallet_metadata`; nothing references it), so the drop/rename is +//! safe under `PRAGMA foreign_keys = ON`, and the copied rows satisfy +//! the re-declared FK because they came from a table with the same +//! constraint. +//! +//! The status list below is FROZEN — like V001's, it must never track +//! the live `ASSET_LOCK_STATUS_LABELS` const, or a future variant +//! addition would rewrite this migration's generated SQL and break its +//! Refinery checksum on databases that already applied it. The +//! `asset_lock_status_labels_frozen_in_latest_migration` unit test in +//! `sqlite::schema::asset_locks` pins the live const to this list so a +//! new variant fails compilation of intent loudly: append V005+ with +//! another rebuild, never edit this file. + +pub fn migration() -> String { + "\ +CREATE TABLE asset_locks_v4 ( + wallet_id BLOB NOT NULL, + outpoint BLOB NOT NULL, + status TEXT NOT NULL CHECK (status IN ('built', 'broadcast', 'is_locked', 'chain_locked', 'consumed', 'recovered_from_chain')), + account_index INTEGER NOT NULL, + identity_index INTEGER NOT NULL, + amount_duffs INTEGER NOT NULL, + lifecycle_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, outpoint), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + +-- Orphan policy: a row whose wallet was deleted while FK enforcement +-- happened to be off is unreachable garbage (every read path keys +-- through wallet_metadata), but copying it into the FK-declared twin +-- under PRAGMA foreign_keys = ON would abort this whole migration with +-- 'FOREIGN KEY constraint failed'. Drop such rows explicitly — the +-- same outcome the declared ON DELETE CASCADE would have produced had +-- enforcement been on when the wallet was deleted. +DELETE FROM asset_locks + WHERE wallet_id NOT IN (SELECT wallet_id FROM wallet_metadata); + +INSERT INTO asset_locks_v4 (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob) + SELECT wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob FROM asset_locks; + +DROP TABLE asset_locks; + +ALTER TABLE asset_locks_v4 RENAME TO asset_locks;" + .to_string() +} 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 af89cdc1e4d..ab3ee0e206b 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 @@ -66,25 +66,38 @@ pub fn apply( Ok(()) } -/// Single source of truth for the `asset_locks.status` TEXT-column -/// domain. +/// Test-only drift guard for the `asset_locks.status` TEXT-column +/// domain **as the writer sees it** (production code never reads this +/// — the writer maps through [`status_str`] and the on-disk CHECK +/// lives frozen inside the migrations). /// /// Mirrors every variant of /// [`platform_wallet::wallet::asset_lock::tracked::AssetLockStatus`] -/// (writer side: [`status_str`]). The migration in -/// `migrations/V001__initial.rs` interpolates this array into the -/// `CHECK (status IN (...))` clause so an unknown label is rejected at -/// insert time rather than landing as silent garbage. The -/// `asset_lock_status_labels_match_enum` unit test below enforces -/// set-equality between this array and the writer's output — drift (a -/// renamed/added variant) becomes a failing test, not a runtime -/// divergence between Rust and SQLite. +/// (writer side: [`status_str`]). The on-disk `CHECK (status IN (...))` +/// clause rejects an unknown label at insert time rather than letting +/// it land as silent garbage — but the migrations do NOT interpolate +/// this const: each migration freezes its own copy of the domain, +/// because a generated-SQL change breaks that migration's Refinery +/// checksum on every database that already applied it +/// (`abort_divergent` default). `V001__initial.rs` carries the original +/// five labels; `V004__asset_lock_recovered_status.rs` rebuilt the +/// table with the current six. +/// +/// Two unit tests below keep the three copies honest: +/// - `asset_lock_status_labels_match_enum` — this array ⇔ the writer's +/// codomain ([`status_str`]); +/// - `asset_lock_status_labels_frozen_in_latest_migration` — this array +/// ⇔ the latest migration's frozen list, so ADDING a variant fails +/// with instructions to append a new table-rebuild migration (V005+) +/// instead of editing a shipped one. +#[cfg(test)] pub(crate) const ASSET_LOCK_STATUS_LABELS: &[&str] = &[ "built", "broadcast", "is_locked", "chain_locked", "consumed", + "recovered_from_chain", ]; fn status_str(s: &AssetLockStatus) -> &'static str { @@ -94,6 +107,7 @@ fn status_str(s: &AssetLockStatus) -> &'static str { AssetLockStatus::InstantSendLocked => "is_locked", AssetLockStatus::ChainLocked => "chain_locked", AssetLockStatus::Consumed => "consumed", + AssetLockStatus::RecoveredFromChain => "recovered_from_chain", } } @@ -198,6 +212,7 @@ mod tests { AssetLockStatus::InstantSendLocked, AssetLockStatus::ChainLocked, AssetLockStatus::Consumed, + AssetLockStatus::RecoveredFromChain, ]; for v in &variants { match v { @@ -205,7 +220,8 @@ mod tests { | AssetLockStatus::Broadcast | AssetLockStatus::InstantSendLocked | AssetLockStatus::ChainLocked - | AssetLockStatus::Consumed => {} + | AssetLockStatus::Consumed + | AssetLockStatus::RecoveredFromChain => {} } } variants @@ -224,4 +240,28 @@ mod tests { from_const, from_writer ); } + + /// Pins the live label set to the domain frozen in the LATEST + /// asset-lock migration (`V004__asset_lock_recovered_status.rs`). + /// Shipped migrations interpolate nothing — their generated SQL is + /// checksummed by Refinery, so widening the domain means APPENDING + /// a new table-rebuild migration (V005+) with the new frozen list + /// and updating this pin, never editing V001/V004 in place. + /// + /// IF THIS FAILS: do NOT edit a shipped migration (its Refinery + /// checksum would diverge on already-migrated databases). Append a + /// new migration that rebuilds `asset_locks` with the widened + /// CHECK, then update this pin to the new migration's list. + #[test] + fn asset_lock_status_labels_frozen_in_latest_migration() { + let frozen_in_v004 = [ + "built", + "broadcast", + "is_locked", + "chain_locked", + "consumed", + "recovered_from_chain", + ]; + assert_eq!(ASSET_LOCK_STATUS_LABELS, &frozen_in_v004); + } } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs index 8b90ce8b957..6523c13027b 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs @@ -227,3 +227,123 @@ fn tc044_load_empty_is_empty() { let state = platform_wallet::changeset::PlatformWalletPersistence::load(&persister).unwrap(); assert!(state.is_empty()); } + +/// V003 → V004 upgrade path: a database created at the prior release +/// schema (through V003) upgrades in place — the asset_locks rebuild +/// keeps existing rows byte-for-byte and widens the status CHECK to +/// admit `recovered_from_chain`, which the V003 schema rejects. +/// +/// This is the regression test for the review finding that V001's +/// generated CHECK must never change (Refinery `abort_divergent` would +/// brick every already-migrated database): the domain widens by +/// APPENDING V004, and this test drives exactly the sequence an +/// existing install experiences. +#[test] +fn tc045_v004_widens_asset_lock_status_on_existing_db() { + use rusqlite::params; + + let mut conn = rusqlite::Connection::open_in_memory().expect("open in-memory db"); + conn.pragma_update(None, "foreign_keys", true) + .expect("enable foreign keys"); + + // 1. Stand the database up at the PRIOR release schema (V003). + let to_v003 = mig::runner().set_target(refinery::Target::Version(3)); + to_v003.run(&mut conn).expect("migrate to V003"); + + // 2. Populate it the way a live wallet would have. + let wallet_id = [42u8; 32]; + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![wallet_id.as_slice()], + ) + .expect("insert wallet"); + let outpoint_a = [1u8; 36]; + conn.execute( + "INSERT INTO asset_locks (wallet_id, outpoint, status, account_index, identity_index, \ + amount_duffs, lifecycle_blob) VALUES (?1, ?2, 'chain_locked', 0, 4, 1000000, X'01')", + params![wallet_id.as_slice(), outpoint_a.as_slice()], + ) + .expect("insert pre-upgrade asset lock"); + + // 3. The V003 CHECK must reject the new label — that's the schema + // gap V004 exists to close. + let outpoint_b = [2u8; 36]; + let rejected = conn.execute( + "INSERT INTO asset_locks (wallet_id, outpoint, status, account_index, identity_index, \ + amount_duffs, lifecycle_blob) VALUES (?1, ?2, 'recovered_from_chain', 0, 0, 500, X'02')", + params![wallet_id.as_slice(), outpoint_b.as_slice()], + ); + assert!( + rejected.is_err(), + "the V003 CHECK domain must reject recovered_from_chain" + ); + + // 3b. Plant a legacy orphan row the way an old connection with FK + // enforcement off could have: its wallet row is gone, so copying + // it into the FK-declared twin would abort the rebuild. V004's + // explicit orphan policy must drop it instead. + conn.pragma_update(None, "foreign_keys", false) + .expect("disable foreign keys"); + let ghost_wallet = [9u8; 32]; + conn.execute( + "INSERT INTO asset_locks (wallet_id, outpoint, status, account_index, identity_index, \ + amount_duffs, lifecycle_blob) VALUES (?1, X'04', 'built', 0, 0, 1, X'04')", + params![ghost_wallet.as_slice()], + ) + .expect("insert orphan row with FK enforcement off"); + conn.pragma_update(None, "foreign_keys", true) + .expect("re-enable foreign keys"); + + // 4. Upgrade to the latest schema (applies V004's table rebuild). + mig::run(&mut conn).expect("migrate to latest despite the orphan row"); + + // 4b. The orphan is gone (same outcome the declared cascade would + // have produced), the real row below is untouched. + let orphans: i64 = conn + .query_row( + "SELECT COUNT(*) FROM asset_locks WHERE wallet_id = ?1", + params![ghost_wallet.as_slice()], + |row| row.get(0), + ) + .expect("count orphans"); + assert_eq!(orphans, 0, "V004 must drop legacy orphan rows, not abort"); + + // 5. The pre-upgrade row survived the rebuild intact... + let (status, identity_index, amount): (String, i64, i64) = conn + .query_row( + "SELECT status, identity_index, amount_duffs FROM asset_locks WHERE outpoint = ?1", + params![outpoint_a.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("pre-upgrade row survives"); + assert_eq!( + (status.as_str(), identity_index, amount), + ("chain_locked", 4, 1_000_000) + ); + + // 6. ...the widened domain admits the new label... + conn.execute( + "INSERT INTO asset_locks (wallet_id, outpoint, status, account_index, identity_index, \ + amount_duffs, lifecycle_blob) VALUES (?1, ?2, 'recovered_from_chain', 0, 0, 500, X'02')", + params![wallet_id.as_slice(), outpoint_b.as_slice()], + ) + .expect("recovered_from_chain must insert after V004"); + + // 7. ...garbage labels stay rejected, and the rebuilt table kept its + // FK: deleting the wallet cascades to both rows. + let garbage = conn.execute( + "INSERT INTO asset_locks (wallet_id, outpoint, status, account_index, identity_index, \ + amount_duffs, lifecycle_blob) VALUES (?1, X'03', 'bogus', 0, 0, 1, X'03')", + params![wallet_id.as_slice()], + ); + assert!(garbage.is_err(), "unknown labels must still be rejected"); + conn.execute( + "DELETE FROM wallet_metadata WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + ) + .expect("delete wallet"); + let remaining: i64 = conn + .query_row("SELECT COUNT(*) FROM asset_locks", [], |row| row.get(0)) + .expect("count"); + assert_eq!(remaining, 0, "ON DELETE CASCADE must survive the rebuild"); +} 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 d2fd05b4a3d..bafa6ffca72 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs @@ -454,6 +454,74 @@ fn tc010_asset_lock_roundtrip() { drop(tmp); } +/// TC-010b: a `RecoveredFromChain` lock — the restore-scan +/// reconstruction's status, admitted by the V004 CHECK widening — +/// round-trips through the writer's TEXT status mapping and the +/// lifecycle blob, chain proof included. +#[test] +fn tc010b_recovered_from_chain_lock_roundtrip() { + use dashcore::hashes::Hash; + use dashcore::{OutPoint, Transaction, Txid}; + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry}; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + + let txid = Txid::from_byte_array([0x43; 32]); + let outpoint = OutPoint { txid, vout: 0 }; + let entry = AssetLockEntry { + out_point: outpoint, + transaction: Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 0, + funding_type: AssetLockFundingType::AssetLockShieldedAddressTopUp, + identity_index: 0, + amount_duffs: 500_000, + status: AssetLockStatus::RecoveredFromChain, + proof: Some(dpp::prelude::AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 411_495, + out_point: outpoint, + })), + }; + let mut locks = AssetLockChangeSet::default(); + locks.asset_locks.insert(outpoint, entry.clone()); + + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xFB); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + asset_locks: Some(locks), + ..Default::default() + }, + ) + .expect("recovered_from_chain must satisfy the widened CHECK"); + drop(persister); + + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let bucketed = platform_wallet_storage::sqlite::schema::asset_locks::load_state( + &p2.lock_conn_for_test(), + &w, + ) + .unwrap(); + let tracked = &bucketed[&0][&outpoint]; + assert_eq!(tracked.status, AssetLockStatus::RecoveredFromChain); + match &tracked.proof { + Some(dpp::prelude::AssetLockProof::Chain(chain)) => { + assert_eq!(chain.core_chain_locked_height, 411_495); + } + other => panic!("chain proof must survive the roundtrip, got {other:?}"), + } + drop(tmp); +} + /// TC-012: DashPay profile + payment overlay round-trip through the /// dashpay_* tables via bincode-serde blobs. #[test] diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 2b417e4c3be..c6de98fdae9 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -944,6 +944,10 @@ pub struct AssetLockEntry { /// Current status on Core chain. pub status: AssetLockStatus, /// The asset lock proof, available once IS-locked or ChainLocked. + #[cfg_attr( + feature = "serde", + serde(with = "crate::changeset::serde_adapters::optional_asset_lock_proof") + )] pub proof: Option, } diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 0151687ea88..2849d10f1e5 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -51,9 +51,12 @@ use tokio::sync::RwLock; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; -use crate::changeset::changeset::{CoreChangeSet, HighestUsedIndexes, PlatformWalletChangeSet}; +use crate::changeset::changeset::{ + AssetLockChangeSet, CoreChangeSet, HighestUsedIndexes, PlatformWalletChangeSet, +}; use crate::changeset::merge::Merge; use crate::changeset::traits::PlatformWalletPersistence; +use crate::wallet::asset_lock::sync::reconstruction; use crate::wallet::platform_wallet::PlatformWalletInfo; /// Maximum number of `WalletEvent`s folded into a single @@ -315,7 +318,7 @@ async fn run_wallet_event_adapter

( break; }; - let mut batch: BTreeMap = BTreeMap::new(); + let mut batch: BTreeMap = BTreeMap::new(); let mut closed = false; { let wallet_id = event.wallet_id(); @@ -324,7 +327,10 @@ async fn run_wallet_event_adapter

( // recording the IS lock), `build_core_changeset` takes a brief // read lock on the manager. let core = build_core_changeset(&wallet_manager, &event).await; - batch.entry(wallet_id).or_default().merge(core); + let asset_locks = reconstruct_asset_locks_for_event(&wallet_manager, &event).await; + let entry = batch.entry(wallet_id).or_default(); + entry.core.merge(core); + entry.asset_locks.merge(asset_locks); } // Fold in whatever else is already buffered. `try_recv` never waits, @@ -336,7 +342,11 @@ async fn run_wallet_event_adapter

( Ok(event) => { let wallet_id = event.wallet_id(); let core = build_core_changeset(&wallet_manager, &event).await; - batch.entry(wallet_id).or_default().merge(core); + let asset_locks = + reconstruct_asset_locks_for_event(&wallet_manager, &event).await; + let entry = batch.entry(wallet_id).or_default(); + entry.core.merge(core); + entry.asset_locks.merge(asset_locks); folded += 1; } Err(TryRecvError::Empty) => break, @@ -396,7 +406,7 @@ async fn run_wallet_event_adapter

( /// contradict itself. fn commit_batch

( persister: &P, - batch: BTreeMap, + batch: BTreeMap, folded: usize, fault: &mut AdapterFaultState, sync_fault: &AtomicBool, @@ -406,7 +416,14 @@ where P: PlatformWalletPersistence + ?Sized, { let mut diag = BatchDiagnostics::new(folded, batch.len()); - for (wallet_id, mut core) in batch { + for ( + wallet_id, + WalletBatch { + mut core, + asset_locks, + }, + ) in batch + { // Hold this wallet's durable watermark at the last fully persisted // height once it has faulted. Records/UTXOs still persist — only the // height advance is suppressed. @@ -424,7 +441,7 @@ where diag.record_frozen(h); } } - if core.is_empty_no_records() { + if core.is_empty_no_records() && Merge::is_empty(&asset_locks) { // SyncHeightAdvanced for an unknown wallet, empty BlockProcessed, a // watermark-only batch stripped by the fault guard above, etc. — // nothing to persist. Skip the round-trip. @@ -435,6 +452,11 @@ where let offered_height = core.synced_height; let cs = PlatformWalletChangeSet { core: Some(core), + // Tracked-asset-lock rows reconstructed from this drain's + // records (see `reconstruct_asset_locks_for_event`) ride the + // same store round-trip so the row and the record that + // implies it land atomically. + asset_locks: (!Merge::is_empty(&asset_locks)).then_some(asset_locks), ..PlatformWalletChangeSet::default() }; match persister.store(wallet_id, cs) { @@ -496,6 +518,65 @@ fn freeze_synced_height_if_faulted(core: &mut CoreChangeSet, persistence_faulted } } +/// Per-wallet fold of one drain: the projected core rows plus any +/// tracked-asset-lock entries reconstructed from the same events. Both +/// sub-changesets are committed in a single `store()` per wallet. +#[derive(Default)] +struct WalletBatch { + core: CoreChangeSet, + asset_locks: AssetLockChangeSet, +} + +/// Rebuild missing tracked asset locks from the records an event +/// carries (see [`crate::wallet::asset_lock::sync::reconstruction`]). +/// +/// This is what repopulates the tracked-asset-lock set — and through it +/// the host's persisted mirror (e.g. the swift-sdk `PersistentAssetLock` +/// store) — after a wallet restore: the restore scan re-emits every +/// historical asset-lock funding tx as a `BlockProcessed` record filed +/// under the funding account whose pool its credit output pays. +/// `TransactionDetected` is included for live off-chain detections (a +/// same-seed wallet on another device broadcasting an asset lock). +/// +/// The lock-free `is_reconstruction_candidate` pre-filter keeps the +/// wallet-manager write lock off the hot path: plain payment records +/// (the overwhelming majority of scan traffic) never qualify. Locks +/// tracked live by the build pipeline are never overwritten +/// (insert-if-absent inside `reconstruct_tracked_asset_locks`). +async fn reconstruct_asset_locks_for_event( + wallet_manager: &Arc>>, + event: &WalletEvent, +) -> AssetLockChangeSet { + let (wallet_id, candidates): (WalletId, Vec<&TransactionRecord>) = match event { + WalletEvent::TransactionDetected { + wallet_id, record, .. + } => ( + *wallet_id, + std::iter::once(&**record) + .filter(|r| reconstruction::is_reconstruction_candidate(r)) + .collect(), + ), + WalletEvent::BlockProcessed { + wallet_id, + inserted, + updated, + .. + } => ( + *wallet_id, + inserted + .iter() + .chain(updated.iter()) + .filter(|r| reconstruction::is_reconstruction_candidate(r)) + .collect(), + ), + _ => return AssetLockChangeSet::default(), + }; + if candidates.is_empty() { + return AssetLockChangeSet::default(); + } + reconstruction::reconstruct_tracked_asset_locks(wallet_manager, &wallet_id, &candidates).await +} + /// Project an upstream [`WalletEvent`] into a [`CoreChangeSet`] suitable /// for atomic persistence. async fn build_core_changeset( @@ -1220,6 +1301,7 @@ mod tests { synced_height: Option, last_processed_height: Option, n_records: usize, + n_asset_locks: usize, rejected: bool, } @@ -1257,6 +1339,11 @@ mod tests { synced_height: core.and_then(|c| c.synced_height), last_processed_height: core.and_then(|c| c.last_processed_height), n_records: core.map(|c| c.records.len()).unwrap_or(0), + n_asset_locks: changeset + .asset_locks + .as_ref() + .map(|a| a.asset_locks.len()) + .unwrap_or(0), rejected, }); if rejected { @@ -1670,6 +1757,200 @@ mod tests { } } + /// End-to-end restore-scan shape through the real adapter loop: a + /// `BlockProcessed` event whose inserted record is an asset-lock tx + /// filed under a funding account must (a) repopulate the wallet's + /// in-memory `tracked_asset_locks` and (b) carry the reconstructed + /// row to the persister in the same `store()` as the core record. + /// This is the path that rebuilds the host's persisted asset-lock + /// mirror after a wipe & recover. + #[tokio::test] + async fn block_processed_asset_lock_record_reconstructs_and_persists() { + use dashcore::hashes::Hash as _; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::transaction_router::TransactionType; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use tokio::sync::Notify; + + use super::spawn_wallet_event_adapter; + use crate::test_support::{ + funded_wallet_manager, AlwaysRejectedBroadcaster, NoopTestPersister, + }; + use crate::wallet::asset_lock::manager::AssetLockManager; + use crate::wallet::asset_lock::tracked::AssetLockStatus; + use crate::wallet::persister::WalletPersister; + + // A wallet whose identity-registration funding account has a + // real address pool, plus an asset-lock tx whose credit output + // pays into it (built by the production builder). + let (wallet_manager, wallet_id, _generation, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(dashcore::Network::Testnet) + .build() + .expect("mock sdk"), + ); + let asset_lock_manager = AssetLockManager::new( + sdk, + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + Arc::new(AlwaysRejectedBroadcaster), + WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ), + ); + let (tx, _path) = asset_lock_manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await + .expect("build asset lock"); + + // The record a restore scan would file under the funding account. + let record = TransactionRecord::new( + tx.clone(), + AccountType::IdentityRegistration, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 4321, + dashcore::BlockHash::all_zeros(), + 1_650_000_000, + )), + TransactionType::AssetLock, + TransactionDirection::Internal, + vec![], + vec![], + 0, + ); + + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = Arc::new(ProbePersister::new(obs_tx)); + let (event_tx, event_rx) = unbounded_channel(); + let cancel = CancellationToken::new(); + let sync_fault = Arc::new(AtomicBool::new(false)); + let handle = spawn_wallet_event_adapter( + Arc::clone(&wallet_manager), + Arc::clone(&persister), + event_rx, + Arc::clone(&sync_fault), + cancel.clone(), + ); + + event_tx + .send(WalletEvent::BlockProcessed { + wallet_id, + height: 4321, + chain_lock: None, + inserted: vec![record], + updated: vec![], + matured: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: vec![], + }) + .expect("send event"); + + let observed = obs_rx.recv().await.expect("adapter must store the batch"); + assert_eq!(observed.wallet_id, wallet_id); + assert_eq!(observed.n_records, 1, "the core record must persist"); + assert_eq!( + observed.n_asset_locks, 1, + "the reconstructed asset-lock row must ride the same store()" + ); + + let out_point = dashcore::OutPoint::new(tx.txid(), 0); + { + let wm = wallet_manager.read().await; + let lock = wm + .get_wallet_info(&wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&out_point) + .expect("reconstructed in-memory entry"); + assert_eq!(lock.status, AssetLockStatus::RecoveredFromChain); + assert_eq!(lock.amount, 1_000_000); + } + + cancel.cancel(); + handle.await.expect("adapter task joins"); + } + + /// A drain whose only payload is reconstructed asset-lock rows (no + /// core rows at all) must still reach the store — the empty-skip + /// predicate considers both sub-changesets. + #[test] + fn asset_locks_only_batch_reaches_store() { + use dashcore::hashes::Hash as _; + use dashcore::OutPoint; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + + use crate::changeset::changeset::AssetLockEntry; + use crate::wallet::asset_lock::tracked::AssetLockStatus; + + let wallet_id = [3u8; 32]; + let out_point = OutPoint::new(dashcore::Txid::all_zeros(), 0); + let mut asset_locks = super::AssetLockChangeSet::default(); + asset_locks.asset_locks.insert( + out_point, + AssetLockEntry { + out_point, + transaction: dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount_duffs: 1, + status: AssetLockStatus::RecoveredFromChain, + proof: None, + }, + ); + + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = ProbePersister::new(obs_tx); + let sync_fault = AtomicBool::new(false); + let mut fault = AdapterFaultState::default(); + let mut freeze_logged = false; + + let mut batch = BTreeMap::new(); + batch.insert( + wallet_id, + super::WalletBatch { + core: CoreChangeSet::default(), + asset_locks, + }, + ); + commit_batch( + &persister, + batch, + 1, + &mut fault, + &sync_fault, + &mut freeze_logged, + ); + + let observed = obs_rx + .try_recv() + .expect("an asset-locks-only batch must not be skipped"); + assert_eq!(observed.n_asset_locks, 1); + assert!(!observed.rejected); + } + // ── Batch-diagnostic reporting (dashpay/platform#4290 review) ── // // The per-drain `wallet-event batch: ...` line is read off a mainnet @@ -1687,7 +1968,7 @@ mod tests { // including the fail-closed guard) so the assertions cover the shipped // code, not a restatement of it. - use super::{commit_batch, BatchDiagnostics}; + use super::{commit_batch, AssetLockChangeSet, BatchDiagnostics, WalletBatch}; /// A changeset that both proposes a watermark and carries a record-bearing /// field, so it survives `is_empty_no_records()` and actually reaches @@ -1703,9 +1984,15 @@ mod tests { fn one_wallet_batch( wallet_id: WalletId, core: CoreChangeSet, - ) -> BTreeMap { + ) -> BTreeMap { let mut batch = BTreeMap::new(); - batch.insert(wallet_id, core); + batch.insert( + wallet_id, + WalletBatch { + core, + asset_locks: AssetLockChangeSet::default(), + }, + ); batch } @@ -1900,8 +2187,8 @@ mod tests { let mut freeze_logged = false; let mut batch = BTreeMap::new(); - batch.insert(healthy, watermark_with_rows(10, 10)); - batch.insert(rejecting, watermark_with_rows(20, 20)); + batch.extend(one_wallet_batch(healthy, watermark_with_rows(10, 10))); + batch.extend(one_wallet_batch(rejecting, watermark_with_rows(20, 20))); let diag = commit_batch( &persister, diff --git a/packages/rs-platform-wallet/src/changeset/serde_adapters.rs b/packages/rs-platform-wallet/src/changeset/serde_adapters.rs index 23d163ecd88..b163db30b60 100644 --- a/packages/rs-platform-wallet/src/changeset/serde_adapters.rs +++ b/packages/rs-platform-wallet/src/changeset/serde_adapters.rs @@ -93,3 +93,51 @@ pub mod address_funds { }) } } + +/// Adapter for `Option`. +/// +/// `AssetLockProof`'s own serde impl is not self-describing-format +/// agnostic (deserializing it requires `deserialize_any`, which +/// bincode-serde rejects with `AnyNotSupported`), so a proof-carrying +/// `AssetLockEntry` written to the SQLite `lifecycle_blob` could never +/// be read back. Encode the proof as opaque bytes via dpp's own +/// bincode `Encode`/`Decode` instead — the exact encoding the FFI +/// layer already uses for proof round-trips (swift-sdk +/// `PersistentAssetLock.proofBytes`), so every persistence surface +/// speaks one proof wire format. +/// +/// Blob compatibility: `None` encodes identically to the old derive +/// (`Option` tag byte 0). Old `Some` blobs were unreadable to begin +/// with (the decode failed before this adapter existed), so no +/// decodable data changes meaning. +pub mod optional_asset_lock_proof { + use super::*; + use dpp::prelude::AssetLockProof; + + pub fn serialize( + value: &Option, + serializer: S, + ) -> Result { + let bytes: Option> = match value { + Some(proof) => Some( + dpp::bincode::encode_to_vec(proof, dpp::bincode::config::standard()) + .map_err(serde::ser::Error::custom)?, + ), + None => None, + }; + bytes.serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let bytes = Option::>::deserialize(deserializer)?; + bytes + .map(|b| { + dpp::bincode::decode_from_slice(&b, dpp::bincode::config::standard()) + .map(|(proof, _)| proof) + .map_err(serde::de::Error::custom) + }) + .transpose() + } +} diff --git a/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs b/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs index e1d9a1f9781..3ac11cca4b1 100644 --- a/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs @@ -235,6 +235,7 @@ mod activity_changeset_tests { block_height: None, status, created_at_ms: 0, + min_note_position: None, note_cmxs: vec![[id; 32]], spent_nullifiers: vec![], } diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index ef0c780e3eb..e0daf2bd638 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -652,6 +652,7 @@ impl PlatformWalletManager

{ AssetLockStatus::InstantSendLocked => 2, AssetLockStatus::ChainLocked => 3, AssetLockStatus::Consumed => 4, + AssetLockStatus::RecoveredFromChain => 5, }; let (instant_lock_present, chain_lock_height) = match &lock.proof { Some(dpp::prelude::AssetLockProof::Instant(_)) => (true, 0u32), 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 32f545ed42f..9cbc65944ff 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; pub mod lock_notify_handler; pub mod manager; pub mod orchestration; -mod sync; +pub(crate) mod sync; pub mod tracked; pub use lock_notify_handler::LockNotifyHandler; diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/mod.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/mod.rs index 751ea12e0e3..1818013686e 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/mod.rs @@ -5,5 +5,6 @@ //! locks, and re-deriving private keys. mod proof; +pub(crate) mod reconstruction; mod recovery; mod tracking; 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 new file mode 100644 index 00000000000..dc536d1582c --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs @@ -0,0 +1,830 @@ +//! Rebuild tracked asset locks from on-chain history. +//! +//! `tracked_asset_locks` (and the host's persisted mirror — e.g. the +//! swift-sdk `PersistentAssetLock` store) is recorded live at +//! build/broadcast time, so it does not survive a wipe & recover: a +//! restored wallet's historical asset-lock funding transactions had no +//! tracked entry and hosts could not classify them (dashwallet-ios +//! rendered them "Internal Transfer — 0 DASH" until it grew a +//! client-side fallback). +//! +//! The classification signal *does* survive a restore: every asset-lock +//! credit output pays a one-time address derived from a purpose-specific +//! funding account (identity registration / top-up / invitation, the +//! platform-address top-up at `m/9'/coin'/5'/4'`, the shielded top-up at +//! `m/9'/coin'/5'/5'`), and the restore scan re-derives those pools and +//! files a [`TransactionRecord`] under the matching funding account +//! (key-wallet's transaction router checks all funding families for +//! `AssetLock`-type transactions). This module turns those records back +//! into [`TrackedAssetLock`] entries. +//! +//! Entries reconstructed from **chain-locked** records carry +//! [`AssetLockStatus::RecoveredFromChain`] with the chain proof +//! attached — Platform-side consumption is unknown after a restore, so +//! they must land in neither the pending nor the consumed bucket (see +//! the variant's doc). Non-final detections (mempool / unconfirmed +//! block) enter with the live pipeline's own pre-finality statuses and +//! are upgraded in place when a later record proves finality (see +//! [`recovered_status`] / [`enrich_from_record`]). +//! +//! The live entry point is the wallet-event adapter +//! ([`crate::changeset::core_bridge`]): every `TransactionDetected` / +//! `BlockProcessed` record flows through +//! [`reconstruct_tracked_asset_locks`], which inserts missing entries +//! and returns an [`AssetLockChangeSet`] the adapter persists in the +//! same store round-trip as the core rows. Insertion is +//! insert-if-absent: locks tracked live by the build pipeline (which +//! tracks *before* broadcast) always win over a reconstruction. + +use std::sync::Arc; + +use dashcore::blockdata::transaction::special_transaction::TransactionPayload; +use dashcore::OutPoint; +use key_wallet::account::AccountType; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::managed_account::transaction_record::TransactionRecord; +use key_wallet::transaction_checking::TransactionContext; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; +use key_wallet_manager::{WalletId, WalletManager}; +use tokio::sync::RwLock; + +use crate::changeset::changeset::AssetLockChangeSet; +use crate::wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; +use crate::wallet::platform_wallet::PlatformWalletInfo; + +/// Map a record's owning account to the asset-lock funding family it +/// implies. `None` for every non-funding account (standard, CoinJoin, +/// provider keys, DashPay, …) — those records never yield a +/// reconstruction. +/// +/// For [`AccountType::IdentityTopUp`] the account key *is* the +/// registration index, so the destination identity index is recovered +/// exactly. The singleton families (registration, invitation, unbound +/// top-up, address top-ups) don't encode their destination index in the +/// account — the credit-output address is just "next unused" there — so +/// the index is not recoverable from chain and reconstruction reports 0 +/// (see [`reconstruct_candidates`]). +fn funding_family(account_type: &AccountType) -> Option<(AssetLockFundingType, u32)> { + match account_type { + AccountType::IdentityRegistration => Some((AssetLockFundingType::IdentityRegistration, 0)), + AccountType::IdentityTopUp { registration_index } => { + Some((AssetLockFundingType::IdentityTopUp, *registration_index)) + } + AccountType::IdentityTopUpNotBoundToIdentity => { + Some((AssetLockFundingType::IdentityTopUpNotBound, 0)) + } + AccountType::IdentityInvitation => Some((AssetLockFundingType::IdentityInvitation, 0)), + AccountType::AssetLockAddressTopUp => { + Some((AssetLockFundingType::AssetLockAddressTopUp, 0)) + } + AccountType::AssetLockShieldedAddressTopUp => { + Some((AssetLockFundingType::AssetLockShieldedAddressTopUp, 0)) + } + _ => None, + } +} + +/// Lock-free pre-filter: does this record even *look* like an +/// asset-lock funding record for one of this wallet's funding +/// accounts? Pure over the record so the adapter can skip the +/// wallet-manager write lock for the overwhelming majority of scan +/// traffic (plain payments, coinbase, provider txs, …). +pub(crate) fn is_reconstruction_candidate(record: &TransactionRecord) -> bool { + funding_family(&record.account_type).is_some() + && matches!( + record.transaction.special_transaction_payload, + Some(TransactionPayload::AssetLockPayloadType(_)) + ) +} + +/// Status + proof for a reconstructed lock, derived from the record's +/// on-chain context. +/// +/// [`AssetLockStatus::RecoveredFromChain`] is reserved for records with +/// **proven Core finality** (`InChainLockedBlock`) — the variant's +/// invariant is "finality known, consumption unknown", and only those +/// records satisfy it. The chain proof is attached so an explicit +/// resume can consume the lock without another proof wait. +/// +/// Non-final detections (a mempool sighting from a same-seed wallet on +/// another device, a not-yet-chain-locked block) get the live +/// pipeline's own statuses for exactly that state — +/// [`AssetLockStatus::Broadcast`] / [`AssetLockStatus::InstantSendLocked`], +/// mirroring `resolve_status_with_in_memory` in `sync::recovery` — so +/// `resume_asset_lock` keeps its defensive re-broadcast for a tx that +/// may still be evicted from mempools, and [`enrich_from_record`] +/// upgrades the entry when a later record proves finality. +fn recovered_status( + record: &TransactionRecord, + out_point: OutPoint, +) -> (AssetLockStatus, Option) { + match &record.context { + TransactionContext::InChainLockedBlock(_) => match record.height() { + Some(height) => ( + AssetLockStatus::RecoveredFromChain, + Some(dpp::prelude::AssetLockProof::Chain(chain_proof( + height, out_point, + ))), + ), + // Unreachable in practice (`InChainLockedBlock` always + // carries a `BlockInfo` height), but the status must stay + // bound to proof availability: a proof-less + // `RecoveredFromChain` entry would have no repair path + // (`enrich_from_record` only upgrades pre-finality + // statuses). Enter as pre-finality instead so a later + // heighted record can still enrich it. + None => (AssetLockStatus::Broadcast, None), + }, + TransactionContext::InstantSend(_) => (AssetLockStatus::InstantSendLocked, None), + TransactionContext::Mempool | TransactionContext::InBlock(_) => { + (AssetLockStatus::Broadcast, None) + } + } +} + +fn chain_proof( + core_chain_locked_height: u32, + out_point: OutPoint, +) -> dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof { + dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof { + core_chain_locked_height, + out_point, + } +} + +/// Find the fund-bearing account (BIP44 first, then CoinJoin — same +/// family order as `funding_tx_record` in `sync::proof`) that also +/// recorded `txid`, i.e. the account whose UTXOs funded the asset +/// lock. Falls back to 0 when no sibling record exists (the lookup +/// paths that consume `account_index` degrade to the persister +/// fallback on a miss, so a wrong-but-plausible index only costs a +/// round-trip). +fn funding_account_index(info: &PlatformWalletInfo, txid: &dashcore::Txid) -> u32 { + let accounts = &info.core_wallet.accounts; + accounts + .standard_bip44_accounts + .iter() + .chain(accounts.coinjoin_accounts.iter()) + .find(|(_, account)| account.transactions().contains_key(txid)) + .map(|(index, _)| *index) + .unwrap_or(0) +} + +/// Classify one funding-account record into the tracked-asset-lock +/// entries it implies: one candidate per credit output that pays an +/// address of the record's funding account. Outpoints already present +/// in `info.tracked_asset_locks` are omitted (live-tracked locks win). +/// +/// Pure over `info` — the caller owns locking and insertion. +fn reconstruct_candidates( + info: &PlatformWalletInfo, + record: &TransactionRecord, +) -> Vec { + let Some((funding_type, identity_index)) = funding_family(&record.account_type) else { + return Vec::new(); + }; + let Some(TransactionPayload::AssetLockPayloadType(payload)) = + &record.transaction.special_transaction_payload + else { + return Vec::new(); + }; + + let accounts = &info.core_wallet.accounts; + let funding_account = match funding_type { + AssetLockFundingType::IdentityRegistration => accounts.identity_registration.as_ref(), + AssetLockFundingType::IdentityTopUp => accounts.identity_topup.get(&identity_index), + AssetLockFundingType::IdentityTopUpNotBound => accounts.identity_topup_not_bound.as_ref(), + AssetLockFundingType::IdentityInvitation => accounts.identity_invitation.as_ref(), + AssetLockFundingType::AssetLockAddressTopUp => accounts.asset_lock_address_topup.as_ref(), + AssetLockFundingType::AssetLockShieldedAddressTopUp => { + accounts.asset_lock_shielded_address_topup.as_ref() + } + }; + let Some(funding_account) = funding_account else { + // The record names an account the managed collection doesn't + // hold (raced a removal, or a load-path gap). Nothing to match + // against; the next scan round re-emits the record. + return Vec::new(); + }; + + let account_index = funding_account_index(info, &record.txid); + + payload + .credit_outputs + .iter() + .enumerate() + .filter(|(_, credit_output)| { + funding_account.contains_script_pub_key(&credit_output.script_pubkey) + }) + .filter_map(|(vout, credit_output)| { + // Asset-lock outpoints index into `credit_outputs` + // (DIP-0027), not the transaction's regular outputs. + let out_point = OutPoint::new(record.txid, vout as u32); + if info.tracked_asset_locks.contains_key(&out_point) { + return None; + } + let (status, proof) = recovered_status(record, out_point); + Some(TrackedAssetLock { + out_point, + transaction: record.transaction.clone(), + account_index, + funding_type, + identity_index, + amount: credit_output.value, + status, + proof, + }) + }) + .collect() +} + +/// Upgrade an already-tracked, still-unproven entry when a record +/// proves its funding tx chain-locked. +/// +/// Insert-if-absent protects live entries from being *replaced* by a +/// reconstruction — but it also meant a proof-less entry (a +/// reconstruction from a mempool detection, or a live `Broadcast` row +/// stranded by an app kill) could never receive the chain proof a +/// later `BlockProcessed` record carries. This closes that gap without +/// clobbering anything a live flow owns: +/// +/// - only entries whose `proof` is `None` AND whose status is +/// [`Broadcast`](AssetLockStatus::Broadcast) / +/// [`InstantSendLocked`](AssetLockStatus::InstantSendLocked) are +/// touched — the two pre-finality states every existing path +/// (`wait_for_proof`, `resume_asset_lock`) advances to +/// `ChainLocked` + chain proof on observing the same finality, so +/// this converges with, never contradicts, the live pipeline; +/// - [`Built`](AssetLockStatus::Built) (owned by an in-flight build), +/// [`Consumed`](AssetLockStatus::Consumed) (terminal), and +/// proof-carrying entries are left untouched. +/// +/// Every richer field (funding type, identity index, amount) is +/// preserved — only `status` and `proof` advance. +fn enrich_from_record( + info: &mut PlatformWalletInfo, + record: &TransactionRecord, + cs: &mut AssetLockChangeSet, +) { + let TransactionContext::InChainLockedBlock(_) = &record.context else { + return; + }; + let Some(height) = record.height() else { + return; + }; + let Some(TransactionPayload::AssetLockPayloadType(payload)) = + &record.transaction.special_transaction_payload + else { + return; + }; + for vout in 0..payload.credit_outputs.len() { + let out_point = OutPoint::new(record.txid, vout as u32); + let Some(entry) = info.tracked_asset_locks.get_mut(&out_point) else { + continue; + }; + let upgradable = entry.proof.is_none() + && matches!( + entry.status, + AssetLockStatus::Broadcast | AssetLockStatus::InstantSendLocked + ); + if !upgradable { + continue; + } + tracing::info!( + outpoint = %out_point, + height, + prior_status = ?entry.status, + "attaching chain proof to tracked asset lock from finalized scan record" + ); + entry.status = AssetLockStatus::ChainLocked; + entry.proof = Some(dpp::prelude::AssetLockProof::Chain(chain_proof( + height, out_point, + ))); + cs.asset_locks.insert(out_point, (&*entry).into()); + } +} + +/// Rebuild missing tracked-asset-lock entries from scan records. +/// +/// For each record that passes [`is_reconstruction_candidate`], match +/// its credit outputs against the owning funding account and insert an +/// entry for every outpoint not already tracked — status per +/// [`recovered_status`] (`RecoveredFromChain` + chain proof for +/// finalized records, the live pipeline's pre-finality statuses for +/// mempool/unconfirmed detections). Already-tracked outpoints are +/// never replaced, but a finalized record upgrades a still-unproven +/// entry in place (see [`enrich_from_record`]). Returns the changeset +/// describing the inserted/upgraded entries (empty when nothing +/// changed) for the caller to persist alongside whatever else it is +/// flushing. +/// +/// Callers should pre-filter with [`is_reconstruction_candidate`] and +/// skip the call entirely when no record qualifies — this function +/// takes the wallet-manager **write** lock. +pub(crate) async fn reconstruct_tracked_asset_locks( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + records: &[&TransactionRecord], +) -> AssetLockChangeSet { + let mut cs = AssetLockChangeSet::default(); + if records.is_empty() { + return cs; + } + let mut wm = wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(wallet_id) else { + return cs; + }; + for record in records { + // `reconstruct_candidates` reads `info.tracked_asset_locks` + // under the same write lock that guards the insert below, so + // insert-if-absent holds without a re-check. + for lock in reconstruct_candidates(info, record) { + tracing::info!( + outpoint = %lock.out_point, + funding_type = ?lock.funding_type, + amount = lock.amount, + status = ?lock.status, + has_proof = lock.proof.is_some(), + "reconstructed tracked asset lock from on-chain record" + ); + cs.asset_locks.insert(lock.out_point, (&lock).into()); + info.tracked_asset_locks.insert(lock.out_point, lock); + } + // Then let a finalized record upgrade what's already tracked + // but still unproven (its own inserts above carry their proof + // already, so this only ever touches pre-existing entries). + enrich_from_record(info, record, &mut cs); + } + cs +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Network, OutPoint, Transaction}; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::transaction_router::TransactionType; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use key_wallet_manager::WalletManager; + use tokio::sync::{Notify, RwLock}; + + use super::*; + use crate::changeset::{Merge, PlatformWalletPersistence}; + use crate::test_support::{ + funded_wallet_manager, AlwaysRejectedBroadcaster, NoopTestPersister, + }; + use crate::wallet::asset_lock::manager::AssetLockManager; + use crate::wallet::persister::WalletPersister; + + /// Build a real asset-lock transaction through the production + /// builder so its credit output pays a genuine address from the + /// requested funding account's pool — exactly the shape a restore + /// scan re-derives. Returns the manager Arc, wallet id, and tx. + async fn wallet_with_built_asset_lock( + funding_type: AssetLockFundingType, + identity_index: u32, + ) -> ( + Arc>>, + WalletId, + Transaction, + ) { + let (wallet_manager, wallet_id, _generation, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(Network::Testnet) + .build() + .expect("mock sdk"), + ); + let manager = AssetLockManager::new( + sdk, + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + Arc::new(AlwaysRejectedBroadcaster), + WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ), + ); + let (tx, _path) = manager + .build_asset_lock_transaction(1_000_000, 0, funding_type, identity_index, &signer) + .await + .expect("build asset lock"); + (wallet_manager, wallet_id, tx) + } + + fn chainlocked_context(height: u32) -> TransactionContext { + TransactionContext::InChainLockedBlock(BlockInfo::new( + height, + BlockHash::from_slice(&[7u8; 32]).expect("block hash"), + 1_650_000_000, + )) + } + + fn record_for( + tx: &Transaction, + account_type: AccountType, + context: TransactionContext, + ) -> TransactionRecord { + TransactionRecord::new( + tx.clone(), + account_type, + context, + TransactionType::AssetLock, + TransactionDirection::Internal, + vec![], + vec![], + 0, + ) + } + + /// The restore-scan shape end to end: a chain-locked funding-account + /// record for a registration asset lock reconstructs a + /// `RecoveredFromChain` entry with the credit-output amount, a + /// `ChainAssetLockProof` at the record's height, and a changeset row + /// for the persister. + #[tokio::test] + async fn registration_lock_reconstructs_from_chainlocked_record() { + let (wallet_manager, wallet_id, tx) = + wallet_with_built_asset_lock(AssetLockFundingType::IdentityRegistration, 4).await; + let record = record_for( + &tx, + AccountType::IdentityRegistration, + chainlocked_context(1234), + ); + assert!(is_reconstruction_candidate(&record)); + + let cs = reconstruct_tracked_asset_locks(&wallet_manager, &wallet_id, &[&record]).await; + + let out_point = OutPoint::new(tx.txid(), 0); + let entry = cs.asset_locks.get(&out_point).expect("changeset entry"); + assert_eq!(entry.status, AssetLockStatus::RecoveredFromChain); + assert_eq!(entry.amount_duffs, 1_000_000); + assert_eq!( + entry.funding_type, + AssetLockFundingType::IdentityRegistration + ); + // The destination identity index is NOT recoverable from chain + // for singleton funding accounts (the build passed 4; the + // credit-output address doesn't encode it) — reconstruction + // reports 0. Only `IdentityTopUp` recovers the real index (from + // the account key; covered below). + assert_eq!(entry.identity_index, 0); + match &entry.proof { + Some(dpp::prelude::AssetLockProof::Chain(chain)) => { + assert_eq!(chain.core_chain_locked_height, 1234); + assert_eq!(chain.out_point, out_point); + } + other => panic!("expected a chain proof at the record height, got {other:?}"), + } + + let wm = wallet_manager.read().await; + let lock = wm + .get_wallet_info(&wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&out_point) + .expect("in-memory tracked entry"); + assert_eq!(lock.status, AssetLockStatus::RecoveredFromChain); + } + + /// A lock the build pipeline is already tracking must never be + /// clobbered by a scan-record reconstruction (the live entry may + /// carry a fresher status and an IS proof). + #[tokio::test] + async fn live_tracked_lock_is_not_overwritten() { + let (wallet_manager, wallet_id, tx) = + wallet_with_built_asset_lock(AssetLockFundingType::IdentityRegistration, 0).await; + let out_point = OutPoint::new(tx.txid(), 0); + { + let mut wm = wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet"); + info.tracked_asset_locks.insert( + out_point, + TrackedAssetLock { + out_point, + transaction: tx.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 1_000_000, + status: AssetLockStatus::Built, + proof: None, + }, + ); + } + + let record = record_for( + &tx, + AccountType::IdentityRegistration, + chainlocked_context(99), + ); + let cs = reconstruct_tracked_asset_locks(&wallet_manager, &wallet_id, &[&record]).await; + + assert!( + Merge::is_empty(&cs), + "an already-tracked outpoint must produce no changeset" + ); + let wm = wallet_manager.read().await; + assert_eq!( + wm.get_wallet_info(&wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&out_point) + .expect("live entry") + .status, + AssetLockStatus::Built, + "the live-tracked status must survive the scan record" + ); + } + + /// `IdentityTopUp` is the one family whose destination index IS + /// recoverable — the account key is the registration index. + #[tokio::test] + async fn topup_reconstruction_recovers_registration_index() { + const TOPUP_INDEX: u32 = 7; + let (wallet_manager, wallet_id, tx) = + wallet_with_built_asset_lock(AssetLockFundingType::IdentityTopUp, TOPUP_INDEX).await; + let record = record_for( + &tx, + AccountType::IdentityTopUp { + registration_index: TOPUP_INDEX, + }, + chainlocked_context(50), + ); + + let cs = reconstruct_tracked_asset_locks(&wallet_manager, &wallet_id, &[&record]).await; + + let entry = cs + .asset_locks + .get(&OutPoint::new(tx.txid(), 0)) + .expect("changeset entry"); + assert_eq!(entry.funding_type, AssetLockFundingType::IdentityTopUp); + assert_eq!(entry.identity_index, TOPUP_INDEX); + } + + /// A record that hasn't reached chain finality (mempool detection, + /// e.g. a same-seed wallet on another device broadcasting) still + /// reconstructs — but with the live pipeline's own pre-finality + /// status, NOT `RecoveredFromChain` (whose invariant is "finality + /// known"). `Broadcast` keeps the resume path's defensive + /// re-broadcast for a tx that may still be evicted from mempools; + /// an IS-observed record likewise maps to `InstantSendLocked`. + #[tokio::test] + async fn unconfirmed_record_reconstructs_as_broadcast() { + let (wallet_manager, wallet_id, tx) = + wallet_with_built_asset_lock(AssetLockFundingType::AssetLockShieldedAddressTopUp, 0) + .await; + let record = record_for( + &tx, + AccountType::AssetLockShieldedAddressTopUp, + TransactionContext::Mempool, + ); + + let cs = reconstruct_tracked_asset_locks(&wallet_manager, &wallet_id, &[&record]).await; + + let entry = cs + .asset_locks + .get(&OutPoint::new(tx.txid(), 0)) + .expect("changeset entry"); + assert_eq!(entry.status, AssetLockStatus::Broadcast); + assert!(entry.proof.is_none(), "no finality context ⇒ no proof"); + } + + /// A finalized record must upgrade an already-tracked, still + /// unproven entry in place (attach the chain proof, advance to + /// `ChainLocked`) — the insert-if-absent rule protects live entries + /// from replacement but must not strand them proof-less forever. + #[tokio::test] + async fn finalized_record_enriches_unproven_tracked_entry() { + let (wallet_manager, wallet_id, tx) = + wallet_with_built_asset_lock(AssetLockFundingType::IdentityRegistration, 0).await; + let out_point = OutPoint::new(tx.txid(), 0); + + // First sighting: mempool → tracked at Broadcast, no proof. + let mempool_record = record_for( + &tx, + AccountType::IdentityRegistration, + TransactionContext::Mempool, + ); + let cs = + reconstruct_tracked_asset_locks(&wallet_manager, &wallet_id, &[&mempool_record]).await; + assert_eq!( + cs.asset_locks.get(&out_point).expect("tracked").status, + AssetLockStatus::Broadcast + ); + + // Later sighting: the same tx in a chain-locked block. + let final_record = record_for( + &tx, + AccountType::IdentityRegistration, + chainlocked_context(910), + ); + let cs = + reconstruct_tracked_asset_locks(&wallet_manager, &wallet_id, &[&final_record]).await; + + let entry = cs + .asset_locks + .get(&out_point) + .expect("upgraded changeset entry"); + assert_eq!(entry.status, AssetLockStatus::ChainLocked); + match &entry.proof { + Some(dpp::prelude::AssetLockProof::Chain(chain)) => { + assert_eq!(chain.core_chain_locked_height, 910); + assert_eq!(chain.out_point, out_point); + } + other => panic!("expected the attached chain proof, got {other:?}"), + } + let wm = wallet_manager.read().await; + let lock = wm + .get_wallet_info(&wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&out_point) + .expect("in-memory entry"); + assert_eq!(lock.status, AssetLockStatus::ChainLocked); + assert!(lock.proof.is_some()); + } + + /// Enrichment must not touch entries a live flow owns: a `Built` + /// entry (in-flight build) and a `Consumed` tombstone stay exactly + /// as they are even when a finalized record for their tx arrives. + #[tokio::test] + async fn enrichment_leaves_built_and_consumed_entries_alone() { + for protected_status in [AssetLockStatus::Built, AssetLockStatus::Consumed] { + let (wallet_manager, wallet_id, tx) = + wallet_with_built_asset_lock(AssetLockFundingType::IdentityRegistration, 0).await; + let out_point = OutPoint::new(tx.txid(), 0); + { + let mut wm = wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet"); + info.tracked_asset_locks.insert( + out_point, + TrackedAssetLock { + out_point, + transaction: tx.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 1_000_000, + status: protected_status.clone(), + proof: None, + }, + ); + } + let record = record_for( + &tx, + AccountType::IdentityRegistration, + chainlocked_context(910), + ); + let cs = reconstruct_tracked_asset_locks(&wallet_manager, &wallet_id, &[&record]).await; + assert!( + Merge::is_empty(&cs), + "{protected_status:?} must not be enriched" + ); + let wm = wallet_manager.read().await; + assert_eq!( + wm.get_wallet_info(&wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&out_point) + .expect("entry") + .status, + protected_status + ); + } + } + + /// The lock-free pre-filter must reject everything that can't + /// reconstruct: funding-family records without an asset-lock + /// payload, and asset-lock payloads filed under non-funding + /// accounts. + #[tokio::test] + async fn candidate_prefilter_rejects_non_funding_and_non_asset_lock() { + let (_wm, _wallet_id, tx) = + wallet_with_built_asset_lock(AssetLockFundingType::IdentityRegistration, 0).await; + + // Asset-lock payload, but filed under a fund-bearing account — + // that's the UTXO-debit side of the tx, not the credit side. + let standard = record_for( + &tx, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + chainlocked_context(10), + ); + assert!(!is_reconstruction_candidate(&standard)); + + // Funding account, but a plain payment transaction. + let plain_tx = Transaction { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let plain = record_for( + &plain_tx, + AccountType::IdentityRegistration, + chainlocked_context(10), + ); + assert!(!is_reconstruction_candidate(&plain)); + } + + /// Credit outputs paying scripts outside the funding account's pool + /// (someone else's asset lock that happened to be filed here, or a + /// multi-credit-output tx with foreign outputs) reconstruct + /// nothing. + #[tokio::test] + async fn foreign_credit_output_yields_nothing() { + use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; + use dashcore::{ScriptBuf, TxOut}; + + let (wallet_manager, wallet_id, _tx) = + wallet_with_built_asset_lock(AssetLockFundingType::IdentityRegistration, 0).await; + let foreign_tx = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType( + AssetLockPayload { + version: 1, + credit_outputs: vec![TxOut { + value: 5_000, + script_pubkey: ScriptBuf::from(vec![0x76, 0xa9, 0x14, 0xab, 0x88, 0xac]), + }], + }, + )), + }; + let record = record_for( + &foreign_tx, + AccountType::IdentityRegistration, + chainlocked_context(10), + ); + assert!( + is_reconstruction_candidate(&record), + "the cheap pre-filter can't check pool membership — only the locked pass can" + ); + + let cs = reconstruct_tracked_asset_locks(&wallet_manager, &wallet_id, &[&record]).await; + assert!( + Merge::is_empty(&cs), + "foreign credit outputs must not reconstruct locks" + ); + } + + /// A reconstructed chain-locked lock is explicitly resumable: the + /// attached proof feeds `resume_asset_lock` without another proof + /// wait, and the status advances to `ChainLocked` on the way out. + #[tokio::test] + async fn recovered_lock_resumes_from_attached_proof() { + let (wallet_manager, wallet_id, tx) = + wallet_with_built_asset_lock(AssetLockFundingType::IdentityRegistration, 0).await; + let record = record_for( + &tx, + AccountType::IdentityRegistration, + chainlocked_context(77), + ); + let _cs = reconstruct_tracked_asset_locks(&wallet_manager, &wallet_id, &[&record]).await; + + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(Network::Testnet) + .build() + .expect("mock sdk"), + ); + let manager = AssetLockManager::new( + sdk, + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + Arc::new(AlwaysRejectedBroadcaster), + WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ), + ); + let out_point = OutPoint::new(tx.txid(), 0); + let (proof, _path) = manager + .resume_asset_lock(&out_point, Some(Duration::from_secs(1))) + .await + .expect("resume must consume the attached chain proof, not wait"); + match proof { + dpp::prelude::AssetLockProof::Chain(chain) => { + assert_eq!(chain.core_chain_locked_height, 77); + } + other => panic!("expected the reconstructed chain proof, got {other:?}"), + } + } +} 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 c0e81a90dbc..5a89934bf8e 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 @@ -307,6 +307,34 @@ impl AssetLockManager { self.validate_or_upgrade_proof(proof, account_index, out_point) .await? } + AssetLockStatus::RecoveredFromChain => { + // Reconstructed from a chain-locked record after a + // restore — Platform-side consumption is unknown. An + // explicit resume is allowed to try consuming it: + // Platform rejects an already-spent outpoint with a + // typed error, and a genuinely unspent lock is real + // recoverable value. The reconstruction path only + // assigns this status to finalized records and attaches + // the chain proof at creation (non-final detections + // enter as `Broadcast`/`InstantSendLocked` and take + // those arms, re-broadcast included), so the proof is + // present by construction; the `None` arm is a + // defensive fallback for a row whose persisted proof + // was lost, and its wait resolves from the already + // chain-locked record rather than blocking on new + // network events. + match existing_proof { + Some(proof) => { + self.validate_or_upgrade_proof(proof, account_index, out_point) + .await? + } + None => { + let proof = self.wait_for_proof(out_point, timeout).await?; + self.validate_or_upgrade_proof(proof, account_index, out_point) + .await? + } + } + } AssetLockStatus::Consumed => { // Terminal tombstone — the asset lock was already // burned by a successful identity registration / top-up. 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 7ccf2642232..7cffce10d97 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs @@ -55,6 +55,29 @@ pub enum AssetLockStatus { /// (amount, identity index, funding tx) but is excluded from any /// "still actionable" predicate. Consumed, + /// Reconstructed from a **chain-locked** on-chain record rather + /// than tracked live through the build → broadcast → proof + /// pipeline — the restore-scan path + /// (`wallet::asset_lock::sync::reconstruction`) emits this for + /// finalized asset-lock transactions whose credit outputs pay this + /// wallet's funding accounts. Non-final detections (mempool / + /// unconfirmed-block sightings) never get this status; they enter + /// as [`Broadcast`](Self::Broadcast) / + /// [`InstantSendLocked`](Self::InstantSendLocked) like any other + /// pre-finality lock. + /// + /// Core-side finality is therefore guaranteed (a + /// `ChainAssetLockProof` from the record's height is attached at + /// creation), but **Platform-side consumption is unknown**: the + /// lock may have long since funded an identity / address top-up, + /// or it may be genuinely unspent stranded value. Neither + /// `ChainLocked` (which UIs read as "in flight") nor `Consumed` + /// (which claims success) would be truthful, so this is its own + /// state, excluded from both the pending and the consumed + /// predicates. An explicit `resume_asset_lock` may consume it — + /// Platform is the arbiter and rejects an already-spent outpoint + /// with a typed error. + RecoveredFromChain, } /// A tracked asset lock. Private keys are NOT stored here — they're diff --git a/packages/rs-platform-wallet/src/wallet/shielded/activity.rs b/packages/rs-platform-wallet/src/wallet/shielded/activity.rs index b0ec8179d56..61d7f513db4 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/activity.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/activity.rs @@ -158,16 +158,37 @@ pub struct ShieldedActivityEntry { pub counterparty: Option>, /// 36-byte `DashMemo` when present and non-zero; `None` otherwise. pub memo: Option>, - /// Block height the operation confirmed at. `None` while pending or - /// when the live confirm couldn't read it from the proof metadata — - /// the scan deriver fills it in later. **Canonical sort key** (desc), - /// with pendings (None) floated to the top. + /// Block height the operation confirmed at. `None` while pending, + /// when the live confirm couldn't read it from the proof metadata, + /// or — permanently — on scan-derived (restored) entries: the + /// note-fetch proof carries no per-note inclusion height, so the + /// real height is unknowable client-side and is modeled as absent + /// rather than stamped with the discovering batch's proof-anchor + /// (scan-tip) height, which two devices would disagree on. + /// **Canonical sort key** (desc) — see [`sort_activity_for_display`] + /// for how the `None` bands order. pub block_height: Option, /// Confirmation status. pub status: ShieldedActivityStatus, - /// `SystemTime` (ms since epoch) at record time. Display-only and the - /// sort tiebreak after `block_height`; never the primary sort key. + /// `SystemTime` (ms since epoch) at record time for live-recorded + /// entries. **`0` = unknown**: scan-derived (restored) entries have + /// no wall-clock provenance — the chain data carries no per-note + /// block time — so they carry the honest sentinel instead of the + /// scan moment (which dated weeks-old restored history "today" and + /// differed between devices). Display-only and a sort tiebreak; + /// never the primary sort key. Hosts must render `0` as an unknown + /// date, never as the epoch. pub created_at_ms: u64, + /// Chain-order key for scan-derived (restored) entries whose date + /// and height are unknowable: the smallest commitment-tree position + /// among the entry's own received notes. Tree positions are + /// append-only chain order, so sorting by this reproduces the exact + /// on-chain sequence of otherwise-undatable history — identically + /// on every device. `None` on live-recorded entries (which carry a + /// real record time instead) and on the rare outgoing-only cluster + /// (OVK-recovered sends don't persist a position). + #[cfg_attr(feature = "serde", serde(default))] + pub min_note_position: Option, /// cmxs of the visible outputs that fed [`Self::id`] (own notes + /// recovered sends). Linkage for status confirmation and dedupe. /// Stored as `Vec<[u8;32]>` (serde derive covers `[u8;32]`). @@ -242,12 +263,17 @@ pub struct ScanDeriveInput { pub struct DerivedActivity { /// Entries for clusters with no existing row — new history. pub new_entries: Vec, - /// `(entry_id, block_height)` sightings for clusters whose id - /// already has a row: the cluster was observed on-chain at that - /// height. The caller upgrades a still-`Pending` (or height-less) - /// stored row to `Confirmed` at the height — preserving the live - /// entry's richer fields — and ignores sightings for rows that are - /// already confirmed. + /// `(entry_id, observed_at_height)` sightings for clusters whose id + /// already has a row: the cluster was observed on-chain **at or + /// before** that height (the discovering batch's proof-anchor + /// height — an upper bound, not the inclusion height, which the + /// note-fetch proof doesn't carry). The caller upgrades a + /// still-`Pending` (or height-less) stored row to `Confirmed` at + /// the height — preserving the live entry's richer fields — and + /// ignores sightings for rows that are already confirmed. For the + /// live flows this band serves, the pass runs near-tip so the bound + /// is within a few blocks of inclusion; scan-derived NEW entries + /// never carry it (see [`derive_activity_from_scan_data`]). pub confirmations: Vec<([u8; 32], u64)>, } @@ -413,7 +439,30 @@ pub fn derive_activity_from_scan_data( ) -> DerivedActivity { let clusters = cluster_events(input); let mut out = DerivedActivity::default(); - let now_ms = ShieldedActivityEntry::now_ms(); + // Scan-derived entries deliberately carry NO height and NO + // timestamp (dashwallet-ios restored-history acceptance criteria): + // + // - `block_height: None` — the cluster key is the discovering + // batch's proof-anchor height (the tip the response was proven + // at), NOT the operation's inclusion height. The note-fetch proof + // carries no per-note mined height, nullifier items are stored + // with empty values, and the on-chain anchors-by-height index is + // pruned to a recent window — so for restored history the real + // height is unknowable client-side. Stamping the anchor height + // dated the same entry at two different heights on two devices + // restoring the same wallet; absence is the honest value. + // - `created_at_ms: 0` (unknown) — chain data carries no per-note + // block time either, and stamping the scan clock grouped + // weeks-old restored transfers under "today". Live-recorded + // entries keep their genuine record time; only this + // reconstruction path uses the sentinel. + // + // Both values are device-independent, so two restores of the same + // wallet produce byte-identical entries for the same id. The + // cluster height is still used ABOVE as the batch grouping key and + // in `confirmations` (an "observed at-or-before" bound for + // upgrading live pending rows) — it just never masquerades as an + // inclusion height on a derived entry. // Own-nullifier → (value, cmx) lookup for the rho linkage: a cluster // note whose `rho` is one of our own nullifiers is provably the @@ -449,6 +498,11 @@ pub fn derive_activity_from_scan_data( continue; } let id = compute_activity_id(&visible_cmxs); + // Exact chain-order key: tree positions are append-only chain + // order, and the received notes carry theirs. Outgoing-only + // clusters (no persisted position on OVK-recovered sends) + // honestly report None. + let min_note_position = cluster.received.iter().map(|n| n.position).min(); // Overlap-based dedupe: any stored entry whose visible cmx set // intersects this cluster's cmxs already owns (part of) the // cluster. `BTreeSet` so each overlapped id is reported once even @@ -520,9 +574,10 @@ pub fn derive_activity_from_scan_data( fee, counterparty: Some(send.recipient.clone()), memo: non_zero_memo(&send.memo), - block_height: Some(height), + block_height: None, status: ShieldedActivityStatus::Confirmed, - created_at_ms: now_ms, + created_at_ms: 0, + min_note_position, note_cmxs: visible_cmxs, spent_nullifiers: linked_nullifiers, } @@ -542,9 +597,10 @@ pub fn derive_activity_from_scan_data( fee: None, counterparty: None, memo: None, - block_height: Some(height), + block_height: None, status: ShieldedActivityStatus::Confirmed, - created_at_ms: now_ms, + created_at_ms: 0, + min_note_position, note_cmxs: visible_cmxs, spent_nullifiers: linked_nullifiers, } @@ -560,9 +616,10 @@ pub fn derive_activity_from_scan_data( fee: None, counterparty: None, memo: None, - block_height: Some(height), + block_height: None, status: ShieldedActivityStatus::Confirmed, - created_at_ms: now_ms, + created_at_ms: 0, + min_note_position, note_cmxs: visible_cmxs, spent_nullifiers: Vec::new(), } @@ -578,9 +635,10 @@ pub fn derive_activity_from_scan_data( fee: None, counterparty: None, memo: None, - block_height: Some(height), + block_height: None, status: ShieldedActivityStatus::Confirmed, - created_at_ms: now_ms, + created_at_ms: 0, + min_note_position, note_cmxs: visible_cmxs, spent_nullifiers: Vec::new(), } @@ -601,9 +659,10 @@ pub fn derive_activity_from_scan_data( fee: None, counterparty: None, memo: None, - block_height: Some(height), + block_height: None, status: ShieldedActivityStatus::Confirmed, - created_at_ms: now_ms, + created_at_ms: 0, + min_note_position, note_cmxs: visible_cmxs, spent_nullifiers: Vec::new(), } @@ -625,35 +684,64 @@ pub(crate) fn non_zero_memo(memo: &[u8]) -> Option> { } } -/// Sort entries for display: `Pending` STATUS rows float to the very -/// top, then confirmed/failed by `block_height` descending (height-less -/// rows — live successes whose height the scan hasn't backfilled yet — -/// above heighted ones), then tiebreak by `created_at_ms` descending, -/// then by `id` for a total order. Mutates in place. +/// Sort entries for display, in four bands. Mutates in place. /// -/// Pending is a status, not "missing height": the live recorder flips -/// successful ops to `Confirmed` with `block_height: None` and the scan -/// fills the height later, so keying the pending band on height would -/// misfile the common success shape (the Swift UI partitions by status -/// for the same reason). +/// 1. `Pending` STATUS rows float to the very top. Pending is a status, +/// not "missing height": the live recorder flips successful ops to +/// `Confirmed` with `block_height: None`, so keying this band on +/// height would misfile the common success shape (the Swift UI +/// partitions by status for the same reason). +/// 2. Height-less rows **with a record time** — live successes whose +/// height the scan hasn't backfilled yet — newest first. They sit +/// above heighted rows because they are by construction the freshest +/// operations. +/// 3. Heighted rows, by `block_height` descending, tiebroken by +/// `created_at_ms` descending. +/// 4. Height-less rows with **no record time** (`created_at_ms == 0`) — +/// scan-derived restored history, whose real height and time are +/// unknowable client-side (see [`ShieldedActivityEntry::block_height`]). +/// Unknown age must not read as "newest", so they sink below every +/// dated/heighted row — ordered by [`min_note_position`] descending +/// (tree positions are exact chain order, so the band reads +/// newest-first like the rest of the list, identically on every +/// device), position-less entries last. +/// +/// A final `id` tiebreak makes the whole order total and deterministic. +/// +/// [`min_note_position`]: ShieldedActivityEntry::min_note_position pub fn sort_activity_for_display(entries: &mut [ShieldedActivityEntry]) { + // Band rank per the doc above; lower sorts first. + fn band(e: &ShieldedActivityEntry) -> u8 { + if e.status == ShieldedActivityStatus::Pending { + 0 + } else if e.block_height.is_some() { + 2 + } else if e.created_at_ms > 0 { + 1 + } else { + 3 + } + } entries.sort_by(|a, b| { - let a_pending = a.status == ShieldedActivityStatus::Pending; - let b_pending = b.status == ShieldedActivityStatus::Pending; - match (a_pending, b_pending) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => match (a.block_height, b.block_height) { - (None, Some(_)) => std::cmp::Ordering::Less, - (Some(_), None) => std::cmp::Ordering::Greater, + band(a) + .cmp(&band(b)) + .then_with(|| match (a.block_height, b.block_height) { (Some(ah), Some(bh)) => bh.cmp(&ah), + _ => std::cmp::Ordering::Equal, + }) + // Tiebreak: more recent record time first. + .then_with(|| b.created_at_ms.cmp(&a.created_at_ms)) + // Chain order (band 4's primary key; a no-op elsewhere, + // where heights/record times already decided): later tree + // position first, position-less entries last. + .then_with(|| match (a.min_note_position, b.min_note_position) { + (Some(ap), Some(bp)) => bp.cmp(&ap), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => std::cmp::Ordering::Equal, - }, - } - // Tiebreak: more recent record time first. - .then_with(|| b.created_at_ms.cmp(&a.created_at_ms)) - // Final total order so the sort is deterministic. - .then_with(|| a.id.cmp(&b.id)) + }) + // Final total order so the sort is deterministic. + .then_with(|| a.id.cmp(&b.id)) }); } @@ -805,7 +893,16 @@ mod tests { assert_eq!(d[0].kind, ShieldedActivityKind::Received); assert_eq!(d[0].direction, ShieldedDirection::In); assert_eq!(d[0].amount, 1_000); - assert_eq!(d[0].block_height, Some(50)); + // Scan-derived entries carry NO height and NO record time — the + // note's stored height is the discovering batch's proof-anchor + // (scan-tip) height, not an inclusion height, and there is no + // wall-clock provenance for restored history. Both sentinels + // are device-independent (restored-history acceptance criteria). + assert_eq!(d[0].block_height, None); + assert_eq!(d[0].created_at_ms, 0); + // What they DO carry is the exact chain-order key: the received + // note's tree position. + assert_eq!(d[0].min_note_position, Some(0)); } #[test] @@ -863,9 +960,8 @@ mod tests { let d = derive_activity_from_scan_data(&input, &BTreeMap::new()).new_entries; let e = d .iter() - .find(|e| e.block_height == Some(400)) + .find(|e| e.kind == ShieldedActivityKind::ShieldedSpend) .expect("self-change cluster entry"); - assert_eq!(e.kind, ShieldedActivityKind::ShieldedSpend); assert_eq!(e.direction, ShieldedDirection::SelfTransfer); assert!(e.fee.is_none()); } @@ -904,14 +1000,22 @@ mod tests { outgoing: vec![outgoing(0x72, addr(0xEE), 20, 800, vec![])], own_addresses: vec![addr(0x01)], }; - let mut d = derive_activity_from_scan_data(&input, &BTreeMap::new()).new_entries; - sort_activity_for_display(&mut d); + let d = derive_activity_from_scan_data(&input, &BTreeMap::new()).new_entries; assert_eq!(d.len(), 2); - // After display sort, the more recent (h=20 Sent) comes first. - assert_eq!(d[0].kind, ShieldedActivityKind::Sent); - assert_eq!(d[0].block_height, Some(20)); - assert_eq!(d[1].kind, ShieldedActivityKind::Received); - assert_eq!(d[1].block_height, Some(10)); + // One entry per batch cluster; both carry the honest unknown + // sentinels (no inclusion height / record time is recoverable). + let sent = d + .iter() + .find(|e| e.kind == ShieldedActivityKind::Sent) + .expect("send cluster entry"); + let received = d + .iter() + .find(|e| e.kind == ShieldedActivityKind::Received) + .expect("receive cluster entry"); + assert_eq!(sent.block_height, None); + assert_eq!(received.block_height, None); + assert_eq!(sent.created_at_ms, 0); + assert_eq!(received.created_at_ms, 0); } #[test] @@ -927,7 +1031,7 @@ mod tests { existing.insert([0x70u8; 32], live_id); let d = derive_activity_from_scan_data(&input, &existing); assert_eq!(d.new_entries.len(), 1); - assert_eq!(d.new_entries[0].block_height, Some(20)); + assert_eq!(d.new_entries[0].kind, ShieldedActivityKind::Sent); assert_eq!( d.confirmations, vec![(live_id, 10)], @@ -957,6 +1061,7 @@ mod tests { ShieldedActivityStatus::Confirmed }, created_at_ms: created, + min_note_position: None, note_cmxs: vec![[id; 32]], spent_nullifiers: vec![], }; @@ -986,6 +1091,79 @@ mod tests { assert_eq!(v[4].block_height, Some(100)); } + /// Scan-derived restored rows (no height AND no record time) must + /// sink below every dated/heighted row: unknown age must never read + /// as "newest". Within the band they order by `min_note_position` + /// descending — tree positions are exact chain order — so restored + /// history reads newest-first like the rest of the list, and + /// identically on every device restoring the same wallet. + #[test] + fn display_sort_sinks_unknown_age_scan_derived_rows_in_chain_order() { + let mk = |height: Option, created: u64, position: Option, id: u8| { + ShieldedActivityEntry { + id: [id; 32], + kind: ShieldedActivityKind::Sent, + direction: ShieldedDirection::Out, + amount: 1, + fee: None, + counterparty: None, + memo: None, + block_height: height, + status: ShieldedActivityStatus::Confirmed, + created_at_ms: created, + min_note_position: position, + note_cmxs: vec![[id; 32]], + spent_nullifiers: vec![], + } + }; + let mut v = vec![ + // Scan-derived rows with positions deliberately out of id + // order: id 9 holds the EARLIER position, so chain order + // (position desc) must place id 2 first — proving the sort + // keys on position, not id. + mk(None, 0, Some(7), 9), + mk(Some(100), 1, None, 1), // settled + mk(None, 0, Some(41), 2), // scan-derived, later in chain + mk(None, 0, None, 6), // scan-derived, position-less (send-only cluster) + mk(None, 5, None, 3), // fresh live success, height not yet backfilled + ]; + sort_activity_for_display(&mut v); + assert_eq!( + (v[0].block_height, v[0].created_at_ms), + (None, 5), + "fresh live success stays on top of the settled bands" + ); + assert_eq!(v[1].block_height, Some(100)); + assert_eq!( + (v[2].id[0], v[3].id[0], v[4].id[0]), + (2, 9, 6), + "unknown-age rows sink to the bottom in chain order (position \ + desc), position-less rows last" + ); + } + + /// The restored-history determinism criterion: deriving the same + /// wallet data twice (two devices restoring the same seed) must + /// produce byte-identical entries — same ids, same (absent) heights, + /// same (unknown) timestamps — with no scan-moment dependence. + #[test] + fn scan_derivation_is_deterministic_across_devices() { + let input = ScanDeriveInput { + notes: vec![own_note(0x70, 0x71, 10, 2_000, false)], + outgoing: vec![outgoing(0x72, addr(0xEE), 20, 800, vec![])], + own_addresses: vec![addr(0x01)], + }; + let device_a = derive_activity_from_scan_data(&input, &BTreeMap::new()).new_entries; + // "Second device": same persisted chain data, different scan + // moment — nothing in the derivation may read a clock. + let device_b = derive_activity_from_scan_data(&input, &BTreeMap::new()).new_entries; + assert_eq!(device_a, device_b); + for e in &device_a { + assert_eq!(e.block_height, None); + assert_eq!(e.created_at_ms, 0); + } + } + #[test] fn kind_tags_are_stable_and_distinct() { use std::collections::BTreeSet as Set; @@ -1046,9 +1224,8 @@ mod tests { let d = derive_activity_from_scan_data(&input, &BTreeMap::new()).new_entries; let spend = d .iter() - .find(|e| e.block_height == Some(113)) + .find(|e| e.kind == ShieldedActivityKind::ShieldedSpend) .expect("spend cluster entry"); - assert_eq!(spend.kind, ShieldedActivityKind::ShieldedSpend); assert_eq!(spend.direction, ShieldedDirection::Out); assert_eq!( spend.amount, 10_000_000_000, @@ -1077,9 +1254,8 @@ mod tests { let d = derive_activity_from_scan_data(&input, &BTreeMap::new()).new_entries; let sent = d .iter() - .find(|e| e.block_height == Some(20)) + .find(|e| e.kind == ShieldedActivityKind::Sent) .expect("send cluster entry"); - assert_eq!(sent.kind, ShieldedActivityKind::Sent); assert_eq!(sent.amount, 10_000_000_000); assert_eq!( sent.fee, @@ -1107,9 +1283,8 @@ mod tests { let d = derive_activity_from_scan_data(&input, &BTreeMap::new()).new_entries; let e = d .iter() - .find(|e| e.block_height == Some(30)) + .find(|e| e.kind == ShieldedActivityKind::ShieldedSpend) .expect("self-pay cluster entry"); - assert_eq!(e.kind, ShieldedActivityKind::ShieldedSpend); assert_eq!(e.direction, ShieldedDirection::SelfTransfer); assert_eq!(e.amount, 1_000_000); } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/activity_recorder.rs b/packages/rs-platform-wallet/src/wallet/shielded/activity_recorder.rs index 175a81576a7..a9ce087631a 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/activity_recorder.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/activity_recorder.rs @@ -130,6 +130,10 @@ pub fn build_pending_entry( block_height: None, status: ShieldedActivityStatus::Pending, created_at_ms: ShieldedActivityEntry::now_ms(), + // Live entries order by their real record time; the chain-order + // key is the scan deriver's (positions aren't known at record + // time — the notes are discovered by a later scan). + min_note_position: None, note_cmxs, spent_nullifiers, }) @@ -190,6 +194,7 @@ mod tests { block_height: None, status: ShieldedActivityStatus::Pending, created_at_ms: 123, + min_note_position: None, note_cmxs: vec![[7u8; 32]], spent_nullifiers: vec![[3u8; 32]], }; @@ -226,6 +231,7 @@ mod tests { block_height: None, status: ShieldedActivityStatus::Pending, created_at_ms: 0, + min_note_position: None, note_cmxs: vec![[1u8; 32]], spent_nullifiers: vec![], }; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index fd6a306c7b0..a938d3f210d 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -1623,7 +1623,7 @@ impl NetworkShieldedCoordinator { .into_iter() .flat_map(|entry| entry.note_cmxs.into_iter().map(move |c| (c, entry.id))) .collect(); - let mut confirmations = derived.confirmations; + let confirmations = derived.confirmations; for entry in derived.new_entries { let overlapped: std::collections::BTreeSet<[u8; 32]> = entry .note_cmxs @@ -1632,9 +1632,15 @@ impl NetworkShieldedCoordinator { .copied() .collect(); if !overlapped.is_empty() { - if let Some(height) = entry.block_height { - confirmations.extend(overlapped.into_iter().map(|eid| (eid, height))); - } + // A live recorder raced a richer row in between the + // read snapshot and here. Scan-derived entries carry + // no height (their batch's proof-anchor height is + // not an inclusion height — see + // `derive_activity_from_scan_data`), so there is no + // sighting to record from this side; the NEXT pass's + // deriver sees the same cluster overlap and emits + // the `(id, observed_at_height)` sighting that + // flips a still-Pending raced row. continue; } store.save_activity(*id, &entry).map_err(|e| { @@ -1667,9 +1673,22 @@ impl NetworkShieldedCoordinator { // therefore `Pending || block_height.is_none()`, which // also catches those Failed-no-height rows; only a // Confirmed-with-height row is final. + // Scan-derived rows (`created_at_ms == 0`) are + // permanently height-less BY DESIGN — their inclusion + // height is unknowable client-side (see the entry's + // `block_height` doc). Without this exemption, the very + // next pass's sighting for the row's own cluster would + // match the height-less gate below and stamp the + // discovering batch's proof-anchor (scan-tip) height + // back onto the row — reintroducing the exact artifact + // the deriver stopped writing. Live rows keep both + // arms: the Pending flip and the height backfill (their + // sighting arrives near-tip, so the bound is within a + // few blocks of inclusion). + let is_scan_derived = stored.created_at_ms == 0; let needs_upgrade = stored.status == super::activity::ShieldedActivityStatus::Pending - || stored.block_height.is_none(); + || (stored.block_height.is_none() && !is_scan_derived); if !needs_upgrade { continue; } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index a79ed4e2d16..775dd309c79 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -2990,6 +2990,7 @@ mod record_activity_status_tests { block_height: None, status: ShieldedActivityStatus::Pending, created_at_ms: 1, + min_note_position: None, note_cmxs: vec![[0x01; 32]], spent_nullifiers: vec![], } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 3777ed21b89..e49aac7a445 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -71,6 +71,12 @@ pub struct ShieldedNote { /// Stamped per-batch — the SAME height OVK-recovered outgoing notes /// from that chunk get — so the activity deriver can cluster one /// bundle's incoming change and outgoing send together by height. + /// + /// This is an *observed at-or-before* bound (the tip the fetch was + /// proven at), NOT the note's inclusion height, which the proof + /// doesn't carry. It is a grouping key only — never surface it as + /// when the note was mined (scan-derived activity entries carry + /// `block_height: None` for exactly this reason). pub block_height: u64, /// Whether the nullifier was seen on-chain (spent). pub is_spent: bool, @@ -112,7 +118,10 @@ pub struct ShieldedOutgoingNote { /// rather than `[u8; 36]` so the persisted shape stays flexible if /// the memo size ever changes; always 36 bytes for a recovered note. pub memo: Vec, - /// Block height at which the sent note appeared on-chain. + /// Proven platform height of the chunk fetch that recovered the + /// note — the same per-batch *observed at-or-before* bound (and + /// grouping key) as [`ShieldedNote::block_height`]; NOT the height + /// the send was mined at. pub block_height: u64, } @@ -1181,6 +1190,7 @@ mod tests { block_height: height, status, created_at_ms: 0, + min_note_position: None, note_cmxs: vec![[id; 32]], spent_nullifiers: vec![], } diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 638217d6db4..0d85f012861 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -3705,6 +3705,14 @@ unsafe extern "C" fn tramp_load_shielded_activity( block_height, has_block_height, created_at_ms, + // TODO(kotlin-shielded-chain-order): the Kotlin + // store doesn't persist the scan deriver's + // chain-order key yet (the persist bridge doesn't + // carry it either), so "absent" is the honest + // restore value — the Rust sort falls back to the + // deterministic id order for these rows. + min_note_position: 0, + has_min_note_position: 0, identity_id, has_identity_id, counterparty_ptr: ptr::null(), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift index 361c3ce4cc8..1ddf368a9b5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift @@ -114,10 +114,15 @@ public final class PersistentAssetLock { /// Discriminant of [`AssetLockStatus`]: /// 0 = Built, 1 = Broadcast, 2 = InstantSendLocked, 3 = ChainLocked, - /// 4 = Consumed. 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). + /// 4 = Consumed, 5 = RecoveredFromChain. 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). + /// + /// `5` (RecoveredFromChain) rows are written by the SDK's + /// restore-scan reconstruction: the lock is confirmed on chain but + /// its Platform-side consumption is unknown, so UIs must treat it + /// as neither pending (1…3) nor done (4). public var statusRaw: Int /// Bincode-encoded `AssetLockProof` (`dpp::bincode::config::standard()`). diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift index 2331f000483..a1b8e9b8acf 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift @@ -59,14 +59,32 @@ public final class PersistentShieldedActivity { /// Exact fee in credits when `hasFee == true`; otherwise unknown. public var fee: UInt64 public var hasFee: Bool - /// Block height when `hasBlockHeight == true` (confirmed); otherwise - /// pending. Canonical sort key (desc) with pendings floated up. + /// Block height when `hasBlockHeight == true`. Absent while pending + /// — and **permanently absent on scan-derived (restored) entries**: + /// the note-fetch proof carries no per-note inclusion height, so + /// the SDK models the height as unknown rather than stamping the + /// scan-tip height (which differed between devices restoring the + /// same wallet). Canonical sort key (desc) with pendings floated up. public var blockHeight: UInt64 public var hasBlockHeight: Bool /// Record time in ms since the Unix epoch (display-only / sort - /// tiebreak). + /// tiebreak). **`0` = unknown**: scan-derived (restored) entries + /// have no wall-clock provenance, so the SDK writes the sentinel + /// instead of the scan moment. Render `0` as an unknown date, never + /// as the epoch and never as "now". public var createdAtMs: UInt64 + /// Chain-order key when `hasMinNotePosition == true`: the smallest + /// commitment-tree position among the entry's own received notes. + /// Tree positions are exact append-only chain order, so this orders + /// scan-derived restored entries (whose date and height are + /// unknown — `createdAtMs == 0`, no block height) in their true + /// on-chain sequence, identically on every device. Absent on + /// live-recorded entries (which order by their real `createdAtMs`). + /// Defaults cover rows persisted before this field existed. + public var minNotePosition: UInt64 = 0 + public var hasMinNotePosition: Bool = false + /// Created identity id (32 bytes) when `kindTag == 6` /// (IdentityCreate); empty otherwise. public var identityId: Data @@ -99,6 +117,8 @@ public final class PersistentShieldedActivity { blockHeight: UInt64, hasBlockHeight: Bool, createdAtMs: UInt64, + minNotePosition: UInt64 = 0, + hasMinNotePosition: Bool = false, identityId: Data, counterparty: Data, memo: Data, @@ -117,6 +137,8 @@ public final class PersistentShieldedActivity { self.blockHeight = blockHeight self.hasBlockHeight = hasBlockHeight self.createdAtMs = createdAtMs + self.minNotePosition = minNotePosition + self.hasMinNotePosition = hasMinNotePosition self.identityId = identityId self.counterparty = counterparty self.memo = memo diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift index a1d56bbc338..5188293dbb4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/AssetLock/ManagedAssetLockManager.swift @@ -54,6 +54,17 @@ public final class ManagedAssetLockManager: @unchecked Sendable { /// locked amount); a Consumed lock cannot fund another /// identity. case consumed = 4 + /// Reconstructed from on-chain history after a wallet restore + /// (the SDK's restore-scan reconstruction) rather than tracked + /// live through the build pipeline. Core-side finality is + /// known, but Platform-side consumption is UNKNOWN — the lock + /// may have funded an identity long ago, or be unspent + /// stranded value. Deliberately outside both the pending + /// window (`broadcast`…`chainLocked`) and `consumed`: UIs must + /// render neither "in flight" nor "done" for these rows. An + /// explicit resume may consume one; Platform rejects an + /// already-spent outpoint with a typed error. + case recoveredFromChain = 5 } /// A tracked asset lock. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index a665f6d1e9f..d1a89a4c895 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3260,6 +3260,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let blockHeight: UInt64 let hasBlockHeight: Bool let createdAtMs: UInt64 + /// Chain-order key (commitment-tree position) when + /// `hasMinNotePosition` — orders scan-derived restored entries + /// whose date/height are unknown. See `PersistentShieldedActivity`. + let minNotePosition: UInt64 + let hasMinNotePosition: Bool let identityId: Data let counterparty: Data let memo: Data @@ -3293,6 +3298,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { 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 @@ -3313,6 +3320,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { blockHeight: snap.blockHeight, hasBlockHeight: snap.hasBlockHeight, createdAtMs: snap.createdAtMs, + minNotePosition: snap.minNotePosition, + hasMinNotePosition: snap.hasMinNotePosition, identityId: snap.identityId, counterparty: snap.counterparty, memo: snap.memo, @@ -3746,6 +3755,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { block_height: row.blockHeight, has_block_height: row.hasBlockHeight ? 1 : 0, created_at_ms: row.createdAtMs, + min_note_position: row.minNotePosition, + has_min_note_position: row.hasMinNotePosition ? 1 : 0, identity_id: identityTuple, has_identity_id: row.identityId.count == 32 ? 1 : 0, counterparty_ptr: cpLen > 0 ? UnsafePointer(cpPtr) : nil, @@ -7386,6 +7397,8 @@ private func persistShieldedActivityCallback( blockHeight: e.block_height, hasBlockHeight: e.has_block_height != 0, createdAtMs: e.created_at_ms, + minNotePosition: e.min_note_position, + hasMinNotePosition: e.has_min_note_position != 0, identityId: identityId, counterparty: counterparty, memo: memo,