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
756 changes: 725 additions & 31 deletions packages/rs-platform-wallet-ffi/src/core_wallet_types.rs

Large diffs are not rendered by default.

143 changes: 141 additions & 2 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ use crate::wallet_registration_persistence::AccountAddressPoolFFI;
use crate::wallet_restore_types::{
AccountSpecFFI, AccountTypeTagFFI, ContactProfileRestoreEntryFFI, IdentityKeyRestoreFFI,
IdentityRestoreEntryFFI, LoadWalletListFreeFn, PaymentRestoreEntryFFI,
ProviderPlatformNodeKeyFFI, StandardAccountTypeTagFFI, UnresolvedAssetLockTxRecordFFI,
UtxoRestoreEntryFFI, WalletRestoreEntryFFI,
ProviderPlatformNodeKeyFFI, ProviderSpecialTxRestoreEntryFFI, StandardAccountTypeTagFFI,
UnresolvedAssetLockTxRecordFFI, UtxoRestoreEntryFFI, WalletRestoreEntryFFI,
};
use dpp::address_funds::PlatformAddress;
use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0;
Expand Down Expand Up @@ -3557,6 +3557,30 @@ fn build_wallet_start_state(
}
}

// Re-stage provider special transactions onto the provider-key
// accounts so #876 retention keeps them and the masternode list
// survives a restart (mirrors the asset-lock tx-record restore above).
let provider_special_recs: &[ProviderSpecialTxRestoreEntryFFI] =
if entry.provider_special_txs.is_null() || entry.provider_special_txs_count == 0 {
&[]
} else {
unsafe {
slice::from_raw_parts(entry.provider_special_txs, entry.provider_special_txs_count)
}
};
if !provider_special_recs.is_empty() {
let stats = restore_provider_special_txs(&mut wallet_info, provider_special_recs)?;
if stats.restored > 0 || stats.dropped() > 0 {
tracing::info!(
wallet_id = %hex::encode(entry.wallet_id),
restored = stats.restored,
dropped_decode = stats.dropped_decode,
dropped_no_account = stats.dropped_no_account,
"load: provider special-tx restore complete"
);
}
}

// TODO: this per-account reconstruction mirrors the SQLite backend's
// `platform_addrs::build_per_account`. Deferred dedup — once a shared
// helper crate hosts the reconstruction, both backends should call it
Expand Down Expand Up @@ -4684,6 +4708,121 @@ fn restore_unresolved_asset_lock_tx_records(
Ok(stats)
}

/// Re-stage persisted provider special transactions onto the wallet's
/// provider-key accounts at load, so rust-dashcore #876 retention keeps
/// them resident and the masternode-list aggregation survives a restart.
///
/// Mirrors [`restore_unresolved_asset_lock_tx_records`] (decode bytes →
/// rebuild `TransactionContext` from scalars → build a `TransactionRecord`
/// → raw `transactions_mut().insert`) but routes by provider-key account
/// TYPE rather than a BIP44 index: provider involvement is payload-based,
/// so the record is inserted onto EVERY present provider-key account. That
/// is retention-safe (#876 retention is evaluated at drop time by
/// account-is-provider-keys + payload-is-provider, both true on any
/// provider-key account) and the masternode aggregation dedups by txid, so
/// over-placement can't inflate counts — this avoids trusting a persisted
/// routing tag or re-running the `check_transaction` matcher at load.
fn restore_provider_special_txs(
wallet_info: &mut ManagedWalletInfo,
records: &[ProviderSpecialTxRestoreEntryFFI],
) -> Result<UnresolvedRestoreStats, PersistenceError> {
use dashcore::hashes::Hash;
use dashcore::transaction::TransactionPayload;
use key_wallet::account::AccountType;
use key_wallet::managed_account::transaction_record::{
TransactionDirection, TransactionRecord,
};
use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType};

let mut stats = UnresolvedRestoreStats::default();
for rec in records {
let tx_bytes = unsafe { slice_from_raw(rec.tx_bytes, rec.tx_bytes_len) };
let tx: dashcore::Transaction = match dashcore::consensus::encode::deserialize(tx_bytes) {
Ok(t) => t,
Err(e) => {
stats.dropped_decode += 1;
tracing::warn!(error = %e, "load: skipping provider special tx with undecodable bytes");
continue;
}
};

// Tag the rebuilt record with the payload's provider type. A
// non-provider payload here means the row was mis-staged; skip it.
let tx_type = match &tx.special_transaction_payload {
Some(TransactionPayload::ProviderRegistrationPayloadType(_)) => {
TransactionType::ProviderRegistration
}
Some(TransactionPayload::ProviderUpdateServicePayloadType(_)) => {
TransactionType::ProviderUpdateService
}
Some(TransactionPayload::ProviderUpdateRegistrarPayloadType(_)) => {
TransactionType::ProviderUpdateRegistrar
}
Some(TransactionPayload::ProviderUpdateRevocationPayloadType(_)) => {
TransactionType::ProviderUpdateRevocation
}
_ => {
stats.dropped_no_account += 1;
continue;
}
};

let context = match rec.context_raw {
ctx @ (2 | 3) => {
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: {}",
e
))
})?;
let info = BlockInfo::new(rec.block_height, block_hash, rec.block_timestamp as u32);
if ctx == 2 {
TransactionContext::InBlock(info)
} else {
TransactionContext::InChainLockedBlock(info)
}
}
_ => TransactionContext::Mempool,
};

let mut inserted = false;
for mut account in wallet_info.accounts.all_accounts_mut() {
let account_type = account.managed_account_type().to_account_type();
let is_provider = matches!(
account_type,
AccountType::ProviderVotingKeys
| AccountType::ProviderOwnerKeys
| AccountType::ProviderOperatorKeys
| AccountType::ProviderPlatformKeys
);
if !is_provider {
continue;
}
let record = TransactionRecord::new(
tx.clone(),
account_type,
context.clone(),
tx_type,
TransactionDirection::Internal,
Vec::new(),
Vec::new(),
0,
);
account.transactions_mut().insert(record.txid, record);
inserted = true;
}

if inserted {
stats.restored += 1;
} else {
// No provider-key accounts on this wallet (shouldn't happen if
// provider txs were staged) — count as dropped for diagnostics.
stats.dropped_no_account += 1;
}
}
Ok(stats)
}

#[cfg(test)]
mod tests {
//! Unit tests for the load-side helpers. Focused on the
Expand Down
187 changes: 187 additions & 0 deletions packages/rs-platform-wallet-ffi/src/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,193 @@ pub unsafe extern "C" fn platform_wallet_manager_free_account_balances(
}
}

/// Aggregate the wallet's masternodes from its retained provider special
/// transactions (ProRegTx / ProUpServTx / ProUpRegTx / ProUpRevTx),
/// grouped by proTxHash. Returns an array of
/// [`MasternodeEntryFFI`](crate::core_wallet_types::MasternodeEntryFFI),
/// one per masternode, sorted by registration order. The caller owns the
/// array and must free it via
/// [`platform_wallet_manager_free_masternodes`].
///
/// The record source (rust-dashcore #876 provider-payload retention) is
/// populated in every feature configuration; see
/// `PlatformWalletManager::provider_masternode_txs_blocking`. `out_*` are
/// set to null / 0 when the wallet has no masternodes or isn't found.
///
/// Reads the wallet manager lock via `blocking_read` — must not be called
/// from within a tokio async context.
#[no_mangle]
pub unsafe extern "C" fn platform_wallet_manager_list_masternodes(
manager_handle: Handle,
wallet_id: *const u8,
out_entries: *mut *const crate::core_wallet_types::MasternodeEntryFFI,
out_count: *mut usize,
) -> PlatformWalletFFIResult {
check_ptr!(wallet_id);
check_ptr!(out_entries);
check_ptr!(out_count);

let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]);

let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| {
manager.provider_masternode_txs_blocking(&wid)
});
// Outer Option: handle resolved. Inner Option: wallet found.
let inner = unwrap_option_or_return!(option);
let (network, txs, dml, operator_index, platform_index) = unwrap_option_or_return!(inner);

// Derive DML membership from the owned snapshot (`None` ⇒ list not
// available ⇒ Unknown status ⇒ persist layer keeps the prior value).
use crate::core_wallet_types::ListMembership;
let membership = |pro_tx_hash: &[u8; 32]| -> ListMembership {
match &dml {
None => ListMembership::ListUnavailable,
Some(map) => match map.get(pro_tx_hash) {
Some(true) => ListMembership::ValidEntry,
Some(false) => ListMembership::InvalidEntry,
None => ListMembership::Absent,
},
}
};

let aggregates = crate::core_wallet_types::aggregate_masternodes(
txs.iter().map(|(h, tx)| (*h, tx)),
membership,
);

let entries: Vec<crate::core_wallet_types::MasternodeEntryFFI> = aggregates
.iter()
.enumerate()
.map(|(idx, mn)| {
crate::core_wallet_types::masternode_entry_ffi(
mn,
idx as u32,
network,
&operator_index,
&platform_index,
)
})
.collect();
let count = entries.len();

if count == 0 {
*out_entries = std::ptr::null();
*out_count = 0;
return PlatformWalletFFIResult::ok();
}

let boxed = entries.into_boxed_slice();
*out_entries = Box::into_raw(boxed) as *const _;
*out_count = count;
PlatformWalletFFIResult::ok()
}

/// Free an array returned by [`platform_wallet_manager_list_masternodes`],
/// including each entry's heap C strings.
#[no_mangle]
pub unsafe extern "C" fn platform_wallet_manager_free_masternodes(
entries: *mut crate::core_wallet_types::MasternodeEntryFFI,
count: usize,
) {
if entries.is_null() || count == 0 {
return;
}
let slice = std::slice::from_raw_parts_mut(entries, count);
for entry in slice.iter() {
for ptr in [
entry.service_address,
entry.owner_address,
entry.voting_address,
entry.payout_address,
entry.operator_pseudo_address,
entry.platform_node_address,
] {
if !ptr.is_null() {
let _ = std::ffi::CString::from_raw(ptr);
}
}
}
let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(entries, count));
}

/// Claim (withdraw) credits from a masternode's Platform identity to L1
/// via an Identity Credit Withdrawal, signed with the wallet-held OWNER
/// key. Writes the remaining balance to `out_new_balance`.
///
/// - `pro_tx_hash`: 32 bytes in WIRE order (as stored). The masternode
/// identity id is the **display-order** (reversed) form, so this fn
/// reverses before fetching — same orientation as the balance fetch.
/// - `owner_key_index`: the ProviderOwnerKeys derivation index the app
/// resolved from the persisted address join (in-memory pools may be
/// empty for imported wallets, so the index is passed in rather than
/// re-derived here).
/// - `dest_address` MUST be null for the owner-key path: Platform routes
/// an owner-key withdrawal to the registered payout address; a
/// destination can't be chosen. (`use_owner_key == false` / a TRANSFER
/// destination is a documented follow-up.)
///
/// Orchestration (all in Rust, per `swift-sdk/CLAUDE.md`):
/// 1. Resolve the wallet + masternode by `pro_tx_hash`; read its
/// `owner_key_hash`.
/// 2. `Identity::fetch_by_identifier(reversed(pro_tx_hash))`.
/// 3. GUARD: `select_owner_withdrawal_key(identity.public_keys(),
/// owner_key_hash)` — if `None`, return `InvalidIdentityData` WITHOUT
/// broadcasting (signing with an unrecognised key wastes the attempt).
/// 4. Derive the ECDSA owner private key at `owner_key_index` on the
/// ProviderOwnerKeys account; build a `Signer<IdentityPublicKey>`
/// over it; `withdraw_credits_with_signer(identity, None, amount,
/// Some(matched_owner_key), signer, None)`.
///
/// NOTE: steps 1-3 are wired below; step 4 (the internal owner-key
/// `Signer<IdentityPublicKey>` + ECDSA owner-key derivation + DPP
/// signature encoding for `ECDSA_HASH160`) is a NEW money-signing
/// component with no existing production analogue (identity ops sign via
/// an external Swift signer). It is gated behind a distinct error until it
/// can be built and verified against a real testnet claim, so the
/// end-to-end plumbing (UI → wrapper → FFI → fetch → guard → error) is
/// exercisable without risking a malformed money transition.
#[no_mangle]
pub unsafe extern "C" fn platform_wallet_manager_masternode_withdraw(
manager_handle: Handle,
wallet_id: *const u8,
pro_tx_hash: *const u8,
amount: u64,
owner_key_index: u32,
dest_address: *const std::os::raw::c_char,
use_owner_key: bool,
out_new_balance: *mut u64,
) -> PlatformWalletFFIResult {
check_ptr!(wallet_id);
check_ptr!(pro_tx_hash);
check_ptr!(out_new_balance);

use crate::error::PlatformWalletFFIResultCode;

// Owner-key path only, for now.
if !use_owner_key {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"TRANSFER-key masternode withdrawal is not yet supported; use the owner key",
);
}
if !dest_address.is_null() {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"owner-key withdrawal pays the registered payout address; dest_address must be null",
);
}

// See the doc comment: steps 1-3 (resolve → fetch → guard) plus the
// owner-key `Signer<IdentityPublicKey>` derivation + sign are the
// remaining verified-implementation work. Surface a distinct, non-fatal
// error rather than broadcasting an unverified money transition.
let _ = (manager_handle, amount, owner_key_index);
PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
"masternode owner-key withdrawal is not yet enabled (pending verified signer)",
)
}

/// Destroy a PlatformWallet handle.
#[no_mangle]
pub unsafe extern "C" fn platform_wallet_destroy(handle: Handle) -> PlatformWalletFFIResult {
Expand Down
Loading
Loading