Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 108 additions & 2 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//! SwiftData on iOS).

use bincode::config;
use dashcore::prelude::CoreBlockHeight;
use key_wallet::account::account_collection::AccountCollection;
use key_wallet::account::{Account, AccountType, BLSAccount, EdDSAAccount, StandardAccountType};
use key_wallet::bip32::DerivationPath;
Expand Down Expand Up @@ -57,8 +58,8 @@ use crate::platform_address_types::AddressBalanceEntryFFI;
use crate::token_persistence::{TokenBalanceRemovalFFI, TokenBalanceUpsertFFI};
use crate::wallet_registration_persistence::AccountAddressPoolFFI;
use crate::wallet_restore_types::{
AccountSpecFFI, AccountTypeTagFFI, ContactProfileRestoreEntryFFI, IdentityKeyRestoreFFI,
IdentityRestoreEntryFFI, LoadWalletListFreeFn, PaymentRestoreEntryFFI,
AccountSpecFFI, AccountTypeTagFFI, AssetLockInputSpendFFI, ContactProfileRestoreEntryFFI,
IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, LoadWalletListFreeFn, PaymentRestoreEntryFFI,
ProviderSpecialTxRestoreEntryFFI, StandardAccountTypeTagFFI, UnresolvedAssetLockTxRecordFFI,
UtxoRestoreEntryFFI, WalletRestoreEntryFFI,
};
Expand Down Expand Up @@ -4796,12 +4797,14 @@ fn build_wallet_start_state(
// was interrupted by an app kill can resume from the latest
// status without rebroadcasting.
let unused_asset_locks = build_unused_asset_locks(entry)?;
let asset_lock_input_spends = build_asset_lock_input_spends(entry);

let wallet_state = ClientWalletStartState {
wallet,
wallet_info,
identity_manager,
unused_asset_locks,
asset_lock_input_spends,
};

let platform_address_state = if per_account.is_empty()
Expand Down Expand Up @@ -4843,6 +4846,72 @@ fn build_wallet_start_state(
/// registration whose key-persist round hasn't completed) loads with
/// an empty map and gets refreshed on the next sync round —
/// degraded-but-usable for that narrow case.
/// Decode the host mirror's report of which transaction took each outpoint
/// an unresolved asset lock spends.
///
/// A malformed row is skipped rather than failing the load: the map is
/// evidence for a screen that degrades to its old behaviour without it, so a
/// bad row must not cost the user their wallet.
fn build_asset_lock_input_spends(
entry: &WalletRestoreEntryFFI,
) -> BTreeMap<dashcore::OutPoint, platform_wallet::wallet::platform_wallet::RestoredSpend> {
use dashcore::hashes::Hash;

let mut spends = BTreeMap::new();
if entry.asset_lock_input_spends.is_null() || entry.asset_lock_input_spends_count == 0 {
return spends;
}
let rows = unsafe {
slice::from_raw_parts(
entry.asset_lock_input_spends,
entry.asset_lock_input_spends_count,
)
};
for row in rows {
let (Ok(prev_txid), Ok(spender_txid)) = (
dashcore::Txid::from_slice(&row.prev_txid),
dashcore::Txid::from_slice(&row.spender_txid),
) else {
tracing::warn!(
wallet_id = %hex::encode(entry.wallet_id),
"load: skipping asset-lock input-spend row with malformed txid bytes"
);
continue;
};
// Match the known discriminants exactly rather than comparing by
// order: the contract defines 0..=3, and an unknown value must
// degrade to "no evidence" rather than being read as finality. The
// screen treats `in_block` as conclusive and returns a terminal code
// the host may act on by discarding the lock, so a malformed or
// forward-versioned byte manufacturing that verdict would be unsafe.
const CONTEXT_IN_BLOCK: u32 = 2;
const CONTEXT_IN_CHAIN_LOCKED_BLOCK: u32 = 3;
spends.insert(
dashcore::OutPoint {
txid: prev_txid,
vout: row.vout,
},
platform_wallet::wallet::platform_wallet::RestoredSpend {
spender: spender_txid,
height: (row.spender_height != 0).then_some(row.spender_height),
in_block: matches!(
row.spender_context,
CONTEXT_IN_BLOCK | CONTEXT_IN_CHAIN_LOCKED_BLOCK
),
chain_locked: row.spender_context == CONTEXT_IN_CHAIN_LOCKED_BLOCK,
},
);
}
if !spends.is_empty() {
tracing::info!(
wallet_id = %hex::encode(entry.wallet_id),
count = spends.len(),
"load: restored asset-lock input-spend conflicts"
);
}
spends
}

/// Rebuild the `unused_asset_locks` map carried on
/// [`ClientWalletStartState`] from the `tracked_asset_locks` slice the
/// Swift load callback hands back. Mirrors the encoding used by
Expand Down Expand Up @@ -5959,6 +6028,43 @@ mod tests {

use super::*;

// --- asset-lock input-spend linkage decode ---

/// The context byte decides whether persisted evidence may condemn a
/// tracked lock, so only the two known block discriminants may read as
/// final. An unknown value — corrupt row, forward-versioned host — must
/// degrade to "no evidence" rather than manufacture finality.
#[test]
fn asset_lock_input_spend_context_decodes_only_known_block_discriminants() {
for (context, expect_in_block, expect_chain_locked) in [
(0u32, false, false), // mempool
(1, false, false), // InstantSend, replaceable
(2, true, false), // in a block
(3, true, true), // chain-locked block
(4, false, false), //unknown / forward-versioned
(u32::MAX, false, false),
] {
let row = AssetLockInputSpendFFI {
prev_txid: [7u8; 32],
vout: 1,
spender_txid: [9u8; 32],
spender_height: 1_532_949,
spender_context: context,
};
// The decoder reads only `wallet_id` (for the log line) and the
// spend slice, so a zeroed entry is a sound stand-in for the
// ~40 pointer fields it never touches.
let mut entry: WalletRestoreEntryFFI = unsafe { std::mem::zeroed() };
entry.asset_lock_input_spends = &row;
entry.asset_lock_input_spends_count = 1;

let spends = build_asset_lock_input_spends(&entry);
let spend = spends.values().next().expect("row decodes");
assert_eq!(spend.in_block, expect_in_block, "context={context}");
assert_eq!(spend.chain_locked, expect_chain_locked, "context={context}");
}
}

// --- persists_durably: the fail-closed durability attestation ---

unsafe extern "C" fn noop_begin(_ctx: *mut c_void, _wallet_id: *const u8) -> i32 {
Expand Down
39 changes: 39 additions & 0 deletions packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,30 @@ pub struct UnresolvedAssetLockTxRecordFFI {
pub first_seen: u64,
}

/// One outpoint an unresolved asset lock spends, together with the
/// transaction the persistence mirror recorded as having spent it.
///
/// Emitted only when that spender is a *different* transaction from the lock
/// itself: the lock spending its own input is the normal case and carries no
/// information. A spender that reached a block can never be undone, which is
/// what makes the lock provably dead rather than merely unlucky.
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct AssetLockInputSpendFFI {
/// The outpoint the asset lock spends: funding txid, then index.
pub prev_txid: [u8; 32],
pub vout: u32,
/// The transaction that actually took it.
pub spender_txid: [u8; 32],
/// Height of the block holding the spender; `0` when unknown.
pub spender_height: u32,
/// The spender's `TransactionContext` discriminant, verbatim: `0`
/// mempool, `1` InstantSend, `2` in a block, `3` in a chain-locked
/// block. The host reports what it stored; deciding which of those
/// count as final is Rust's call, not the mirror's.
pub spender_context: u32,
}

/// A persisted provider special transaction (ProRegTx / ProUpServTx /
/// ProUpRegTx / ProUpRevTx) staged back into the wallet at load so its
/// DIP-3 payload record is resident on the provider-key accounts again.
Expand Down Expand Up @@ -625,6 +649,21 @@ pub struct WalletRestoreEntryFFI {
/// unresolved asset locks.
pub unresolved_asset_lock_tx_records: *const UnresolvedAssetLockTxRecordFFI,
pub unresolved_asset_lock_tx_records_count: usize,
/// Outpoints an unresolved asset lock spends that the persisted state
/// already knows were taken by a *different* transaction.
///
/// The double-spend screen in `resume_asset_lock` reads the in-memory
/// transaction history, which this load path deliberately leaves empty
/// apart from the unresolved locks themselves — so at app-launch
/// catch-up it scans nothing and cannot fire, however dead the lock is.
/// The persistence mirror does know: the funding outpoint's row carries
/// the txid that spent it. Handing those few outpoints over is what lets
/// the screen work at the only moment it matters.
///
/// Only conflicts are listed — an outpoint spent by the lock's own
/// transaction is not one. `null` / `0` when there are none.
pub asset_lock_input_spends: *const AssetLockInputSpendFFI,
pub asset_lock_input_spends_count: usize,
/// Persisted provider special transactions (ProRegTx / ProUpServTx /
/// ProUpRegTx / ProUpRevTx) re-staged onto the wallet's provider-key
/// accounts so rust-dashcore #876 retention keeps them resident and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ use std::collections::BTreeMap;

use crate::changeset::identity_manager_start_state::IdentityManagerStartState;
use crate::wallet::asset_lock::tracked::TrackedAssetLock;
use crate::wallet::platform_wallet::RestoredSpend;
use dashcore::prelude::CoreBlockHeight;
use dashcore::OutPoint;
use dashcore::Txid;
use key_wallet::wallet::ManagedWalletInfo;
use key_wallet::Wallet;

Expand All @@ -33,4 +36,10 @@ pub struct ClientWalletStartState {
/// Asset locks that have not yet been consumed by an identity
/// registration / top-up, keyed by account index → outpoint.
pub unused_asset_locks: BTreeMap<u32, BTreeMap<OutPoint, TrackedAssetLock>>,
/// Outpoints those asset locks spend that the host mirror reports were
/// taken by a *different* transaction — the evidence the double-spend
/// screen cannot obtain for itself at load time, since the in-memory
/// transaction history it reads is empty then. Values are
/// `(spender txid, spender height, spender is chain-locked)`.
pub asset_lock_input_spends: BTreeMap<OutPoint, RestoredSpend>,
}
3 changes: 3 additions & 0 deletions packages/rs-platform-wallet/src/manager/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ impl<P: PlatformWalletPersistence + 'static> PlatformWalletManager<P> {
wallet_info,
identity_manager,
unused_asset_locks,
asset_lock_input_spends,
} = wallet_state;

// Flatten the (account → outpoint → lock) map into the flat
Expand Down Expand Up @@ -99,6 +100,7 @@ impl<P: PlatformWalletPersistence + 'static> PlatformWalletManager<P> {
generation: Arc::clone(&generation),
identity_manager: IdentityManager::from(identity_manager),
tracked_asset_locks,
restored_asset_lock_input_spends: asset_lock_input_spends,
dpns_name_states: std::collections::BTreeMap::new(),
};

Expand Down Expand Up @@ -270,6 +272,7 @@ mod idempotent_load_tests {
wallet_info: self.managed.clone(),
identity_manager: IdentityManagerStartState::default(),
unused_asset_locks: BTreeMap::new(),
asset_lock_input_spends: Default::default(),
},
);
Ok(ClientStartState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ impl<P: PlatformWalletPersistence + 'static> PlatformWalletManager<P> {
generation: Arc::clone(&generation),
identity_manager: crate::wallet::identity::IdentityManager::new(),
tracked_asset_locks: std::collections::BTreeMap::new(),
restored_asset_lock_input_spends: Default::default(),
dpns_name_states: std::collections::BTreeMap::new(),
};

Expand Down
5 changes: 5 additions & 0 deletions packages/rs-platform-wallet/src/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ pub(crate) async fn funded_wallet_manager_with_outputs(
generation: Arc::clone(&generation),
identity_manager: IdentityManager::new(),
tracked_asset_locks: BTreeMap::new(),
restored_asset_lock_input_spends: Default::default(),
dpns_name_states: BTreeMap::new(),
};

Expand Down Expand Up @@ -324,6 +325,7 @@ pub(crate) async fn funded_wallet_manager_dual_standard(
generation: Arc::clone(&generation),
identity_manager: IdentityManager::new(),
tracked_asset_locks: BTreeMap::new(),
restored_asset_lock_input_spends: Default::default(),
dpns_name_states: BTreeMap::new(),
};
let mut wm = WalletManager::<PlatformWalletInfo>::new(Network::Testnet);
Expand Down Expand Up @@ -426,6 +428,7 @@ pub(crate) async fn funded_wallet_manager_with_contact(
generation: Arc::clone(&generation),
identity_manager: IdentityManager::new(),
tracked_asset_locks: BTreeMap::new(),
restored_asset_lock_input_spends: Default::default(),
dpns_name_states: BTreeMap::new(),
};
let mut wm = WalletManager::<PlatformWalletInfo>::new(Network::Testnet);
Expand Down Expand Up @@ -502,6 +505,7 @@ pub(crate) async fn funded_coinjoin_wallet_manager() -> (
generation: Arc::clone(&generation),
identity_manager: IdentityManager::new(),
tracked_asset_locks: BTreeMap::new(),
restored_asset_lock_input_spends: Default::default(),
dpns_name_states: BTreeMap::new(),
};

Expand Down Expand Up @@ -674,6 +678,7 @@ pub(crate) async fn mnemonic_wallet_manager(
generation: Arc::new(WalletGeneration::new()),
identity_manager: IdentityManager::new(),
tracked_asset_locks: BTreeMap::new(),
restored_asset_lock_input_spends: Default::default(),
dpns_name_states: BTreeMap::new(),
};

Expand Down
1 change: 1 addition & 0 deletions packages/rs-platform-wallet/src/wallet/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@ mod tests {
generation: std::sync::Arc::new(WalletGeneration::new()),
identity_manager: IdentityManager::new(),
tracked_asset_locks: BTreeMap::new(),
restored_asset_lock_input_spends: Default::default(),
dpns_name_states: BTreeMap::new(),
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,7 @@ mod tests {
generation: std::sync::Arc::new(WalletGeneration::new()),
identity_manager: IdentityManager::new(),
tracked_asset_locks: BTreeMap::new(),
restored_asset_lock_input_spends: Default::default(),
dpns_name_states: BTreeMap::new(),
};
assert_eq!(
Expand Down
Loading
Loading