Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
179 changes: 155 additions & 24 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ use dpp::prelude::Identifier;
use platform_wallet::{DpnsNameInfo, IdentityManagerStartState, IdentityStatus, ManagedIdentity};
use std::ffi::CStr;

/// The persisted `TransactionContext` discriminant values shared with the
/// host mirrors (`PersistentTransaction.context` on Swift): `0` mempool,
/// `1` InstantSend, `2` in a block, `3` in a chain-locked block. Every u32
/// `context_raw` decoder in this crate matches the confirmed contexts
/// against these constants — a new context value must be added here first,
/// so a grep for the constant names finds every decoder that has to learn
/// it. The sites deliberately differ in their defensive defaults (miss vs
/// `Mempool` vs no-evidence); see each match's comment.
pub(crate) const TX_CONTEXT_RAW_IN_BLOCK: u32 = 2;
pub(crate) const TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK: u32 = 3;

/// Versioned C projection of [`PersistenceCapabilities`].
///
/// `version` identifies the stable bit assignment. `reserved` must be ignored
Expand Down Expand Up @@ -2947,6 +2958,9 @@ impl PlatformWalletPersistence for FFIPersister {
return Ok(None);
}

// `context_kind` is the u8 out-param twin of the u32
// `TX_CONTEXT_RAW_*` discriminants at the top of this file — the
// values must stay in lockstep with those constants.
let context = match context_kind {
0 => TransactionContext::Mempool,
1 => {
Expand Down Expand Up @@ -4796,12 +4810,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 All @@ -4822,27 +4838,83 @@ fn build_wallet_start_state(
Ok((wallet_state, platform_address_state))
}

/// Translate the `IdentityRestoreEntryFFI` slice carried on a wallet
/// entry into the wallet-bucket portion of an
/// [`IdentityManagerStartState`].
///
/// Every entry on a `WalletRestoreEntryFFI` is wallet-owned by
/// definition, so the returned map is shaped for direct insertion
/// into `wallet_identities[entry.wallet_id]`. Out-of-wallet identities
/// (no associated wallet) come from a separate path that today simply
/// doesn't exist in SwiftData — see the report observation.
/// Decode the host mirror's report of which transaction took each outpoint
/// an unresolved asset lock spends.
///
/// The DPP `Identity` is reconstructed from the persisted scalars via
/// the `IdentityV0` shape — same approach
/// [`apply_identity_entry`](platform_wallet::IdentityManager::apply_identity_entry)
/// uses on the changeset replay path. Public keys are now pulled in
/// from the `keys` array on each `IdentityRestoreEntryFFI` (assembled
/// from the per-identity `PersistentPublicKey` rows on the Swift
/// side), so the restored `Identity.public_keys` map is populated at
/// load time. An identity with no persisted keys (e.g. an in-flight
/// 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.
/// 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. "Malformed" here means an
/// all-zero txid on either side of the row — the shape a zero-initialised
/// struct from a host that never filled the row in would take. (The 32-byte
/// arrays themselves always parse, so this check is the row validation, not
/// the `Txid` constructor.)
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 {
// A fixed 32-byte array always parses as a `Txid`, so the real
// malformed-row check is content: an all-zero txid on either side is
// the shape of a row a host zero-initialised and never filled in,
// and no genuine transaction hashes to zero.
if row.prev_txid == [0u8; 32] || row.spender_txid == [0u8; 32] {
tracing::warn!(
wallet_id = %hex::encode(entry.wallet_id),
"load: skipping asset-lock input-spend row with zeroed txid bytes"
);
continue;
}
let prev_txid = dashcore::Txid::from_slice(&row.prev_txid)
.expect("32-byte array always parses as Txid");
let spender_txid = dashcore::Txid::from_slice(&row.spender_txid)
.expect("32-byte array always parses as Txid");
// 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.
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,
TX_CONTEXT_RAW_IN_BLOCK | TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK
),
chain_locked: row.spender_context == TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK,
},
);
}
if !spends.is_empty() {
// "rows", not "conflicts": the host emits whatever spender the
// mirror linked, which for a healthy broadcast lock is the lock's
// own transaction — whether a row is a conflict is decided
// per-lock by the screen, not here.
tracing::info!(
wallet_id = %hex::encode(entry.wallet_id),
count = spends.len(),
"load: restored asset-lock input-spend rows"
);
}
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 @@ -4998,6 +5070,27 @@ fn status_from_u8(b: u8) -> Result<platform_wallet::AssetLockStatus, Persistence
})
}

/// Translate the `IdentityRestoreEntryFFI` slice carried on a wallet
/// entry into the wallet-bucket portion of an
/// [`IdentityManagerStartState`].
///
/// Every entry on a `WalletRestoreEntryFFI` is wallet-owned by
/// definition, so the returned map is shaped for direct insertion
/// into `wallet_identities[entry.wallet_id]`. Out-of-wallet identities
/// (no associated wallet) come from a separate path that today simply
/// doesn't exist in SwiftData — see the report observation.
///
/// The DPP `Identity` is reconstructed from the persisted scalars via
/// the `IdentityV0` shape — same approach
/// [`apply_identity_entry`](platform_wallet::IdentityManager::apply_identity_entry)
/// uses on the changeset replay path. Public keys are now pulled in
/// from the `keys` array on each `IdentityRestoreEntryFFI` (assembled
/// from the per-identity `PersistentPublicKey` rows on the Swift
/// side), so the restored `Identity.public_keys` map is populated at
/// load time. An identity with no persisted keys (e.g. an in-flight
/// 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.
fn build_wallet_identity_bucket(
entry: &WalletRestoreEntryFFI,
) -> Result<BTreeMap<u32, ManagedIdentity>, PersistenceError> {
Expand Down Expand Up @@ -5731,7 +5824,7 @@ fn restore_unresolved_asset_lock_tx_records(
// lock at `Built` / `Broadcast` has by definition not yet
// observed IS-lock or block confirmation).
let context = match rec.context_raw {
2 => {
TX_CONTEXT_RAW_IN_BLOCK => {
let block_hash = dashcore::BlockHash::from_slice(&rec.block_hash).map_err(|e| {
PersistenceError::backend(format!(
"load: malformed block_hash on unresolved asset-lock tx record: {}",
Expand All @@ -5744,7 +5837,7 @@ fn restore_unresolved_asset_lock_tx_records(
rec.block_timestamp as u32,
))
}
3 => {
TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK => {
let block_hash = dashcore::BlockHash::from_slice(&rec.block_hash).map_err(|e| {
PersistenceError::backend(format!(
"load: malformed block_hash on unresolved asset-lock tx record: {}",
Expand Down Expand Up @@ -5889,7 +5982,7 @@ fn restore_provider_special_txs(
};

let context = match rec.context_raw {
ctx @ (2 | 3) => {
ctx @ (TX_CONTEXT_RAW_IN_BLOCK | TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK) => {
let block_hash = dashcore::BlockHash::from_slice(&rec.block_hash).map_err(|e| {
PersistenceError::backend(format!(
"load: malformed block_hash on provider special tx record: {}",
Expand All @@ -5904,7 +5997,7 @@ fn restore_provider_special_txs(
if rec.has_block_position {
info = info.with_position(rec.block_position);
}
if ctx == 2 {
if ctx == TX_CONTEXT_RAW_IN_BLOCK {
TransactionContext::InBlock(info)
} else {
TransactionContext::InChainLockedBlock(info)
Expand Down Expand Up @@ -5958,6 +6051,44 @@ mod tests {
//! exercising the in-memory mutation against synthetic input.

use super::*;
use crate::wallet_restore_types::AssetLockInputSpendFFI;

// --- 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 ---

Expand Down
50 changes: 50 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,33 @@ 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.
///
/// The host emits whatever spender the mirror linked — INCLUDING the lock's
/// own transaction (the normal broadcast case) — because at emission time it
/// holds a flat outpoint set with no per-lock association. Consumers filter
/// out the lock's own txid themselves; a row is a conflict only relative to
/// a particular lock. The iOS host additionally emits only spends its mirror
/// marked settled (in-block), so `spender_context` values `0` / `1` are
/// decoded defensively but do not occur from that host today.
#[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 @@ -651,6 +678,29 @@ pub struct WalletRestoreEntryFFI {
/// re-apply a fresh chainlock.
pub last_applied_chain_lock_bytes: *const u8,
pub last_applied_chain_lock_bytes_len: usize,
/// The spenders the persisted state records for the outpoints the
/// unresolved asset locks spend — the lock's own spend included, see
/// [`AssetLockInputSpendFFI`].
///
/// 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. `null` / `0` when
/// there are none.
///
/// ABI note: these two fields sit at the TAIL of the struct on purpose,
/// and any future addition must go below them. This struct crosses the
/// boundary as a bare pointer with no size or version tag, so appending
/// is the only layout change that keeps every earlier field at its old
/// offset; inserting mid-struct would shift the fields after it and turn
/// a stale host/library pairing into silently misread memory. (In-tree
/// builds regenerate the header in lockstep; this discipline is for the
/// pairing nobody planned.)
pub asset_lock_input_spends: *const AssetLockInputSpendFFI,
pub asset_lock_input_spends_count: usize,
}

// SAFETY: Pointers are Swift-owned and lifetime-scoped to the callback.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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::OutPoint;
use key_wallet::wallet::ManagedWalletInfo;
use key_wallet::Wallet;
Expand All @@ -33,4 +34,11 @@ 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>>,
/// What the host mirror recorded as the spender of each outpoint those
/// asset locks spend (the lock's own spend included — consumers filter),
/// as [`RestoredSpend`] rows. This is the evidence the double-spend
/// screen cannot obtain for itself at load time, since the in-memory
/// transaction history it reads is empty then; `RestoredSpend::in_block`
/// is the settlement gate and `chain_locked` the only finality claim.
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
Loading
Loading