diff --git a/key-wallet-ffi/FFI_API.md b/key-wallet-ffi/FFI_API.md index 8e8528272..566d3aa19 100644 --- a/key-wallet-ffi/FFI_API.md +++ b/key-wallet-ffi/FFI_API.md @@ -4,7 +4,7 @@ This document provides a comprehensive reference for all FFI (Foreign Function I **Auto-generated**: This documentation is automatically generated from the source code. Do not edit manually. -**Total Functions**: 260 +**Total Functions**: 261 ## Table of Contents @@ -140,7 +140,7 @@ Functions: 63 ### Account Management -Functions: 108 +Functions: 109 | Function | Description | Module | |----------|-------------|--------| @@ -231,7 +231,7 @@ Functions: 108 | `managed_account_collection_summary_data` | Get structured account collection summary data for managed collection ... | managed_account_collection | | `managed_account_collection_summary_free` | Free a managed account collection summary and all its allocated memory #... | managed_account_collection | | `managed_core_account_free` | Free a managed account handle # Safety - `account` must be a valid pointer... | managed_account | -| `managed_core_account_free_transactions` | Free transactions array returned by managed_core_account_get_transactions ... | managed_account | +| `managed_core_account_free_transactions` | Free a transactions array returned by `managed_core_account_get_transactions`... | managed_account | | `managed_core_account_get_account_type` | Get the account type of a managed account # Safety - `account` must be a... | managed_account | | `managed_core_account_get_address_pool` | Get an address pool from a managed account by type This function returns... | managed_account | | `managed_core_account_get_balance` | Get the balance of a managed account | managed_account | @@ -239,6 +239,7 @@ Functions: 108 | `managed_core_account_get_index` | Get the account index from a managed account Returns the primary account... | managed_account | | `managed_core_account_get_internal_address_pool` | Get the internal address pool from a managed account This function returns... | managed_account | | `managed_core_account_get_network` | Get the network of a managed account # Safety - `account` must be a valid... | managed_account | +| `managed_core_account_get_special_transactions` | Get the transactions of a managed account that carry a DIP-2 special payload... | managed_account | | `managed_core_account_get_transaction_count` | Get the number of transactions in a managed account Only available with the... | managed_account | | `managed_core_account_get_transactions` | Get all transactions from a managed account Returns an array of... | managed_account | | `managed_core_account_get_utxo_count` | Get the number of UTXOs in a managed account | managed_account | @@ -3066,10 +3067,10 @@ managed_core_account_free_transactions(transactions: *mut FFITransactionRecord, ``` **Description:** -Free transactions array returned by managed_core_account_get_transactions Only available with the `keep-finalized-transactions` Cargo feature, in which configuration `managed_core_account_get_transactions` is also available — the two functions are paired. # Safety - `transactions` must be a pointer returned by `managed_core_account_get_transactions` - `count` must be the count returned by `managed_core_account_get_transactions` - This function must only be called once per allocation +Free a transactions array returned by `managed_core_account_get_transactions` or `managed_core_account_get_special_transactions`. # Safety - `transactions` must be a pointer returned by one of the paired getters - `count` must be the count returned alongside it - This function must only be called once per allocation **Safety:** -- `transactions` must be a pointer returned by `managed_core_account_get_transactions` - `count` must be the count returned by `managed_core_account_get_transactions` - This function must only be called once per allocation +- `transactions` must be a pointer returned by one of the paired getters - `count` must be the count returned alongside it - This function must only be called once per allocation **Module:** `managed_account` @@ -3187,6 +3188,22 @@ Get the network of a managed account # Safety - `account` must be a valid poin --- +#### `managed_core_account_get_special_transactions` + +```c +managed_core_account_get_special_transactions(account: *const FFIManagedCoreAccount, transactions_out: *mut *mut FFITransactionRecord, count_out: *mut usize,) -> bool +``` + +**Description:** +Get the transactions of a managed account that carry a DIP-2 special payload (provider registrations / updates, asset locks, …). Unlike `managed_core_account_get_transactions` this is available in every feature configuration: provider-relevant records are retained in memory even after their block is chainlocked (see `ManagedCoreKeysAccount::drop_finalized_transaction` in `key-wallet`), so a provider-key account's registration history — including each masternode's service IP — remains queryable without the `keep-finalized-transactions` feature. Returns records in txid order. Each record's `special_transaction_payload` field is non-null. # Safety - `account` must be a valid pointer to an FFIManagedCoreAccount instance - `transactions_out` must be a valid pointer to receive the transactions array pointer - `count_out` must be a valid pointer to receive the count - The caller must free the returned array using `managed_core_account_free_transactions` + +**Safety:** +- `account` must be a valid pointer to an FFIManagedCoreAccount instance - `transactions_out` must be a valid pointer to receive the transactions array pointer - `count_out` must be a valid pointer to receive the count - The caller must free the returned array using `managed_core_account_free_transactions` + +**Module:** `managed_account` + +--- + #### `managed_core_account_get_transaction_count` ```c diff --git a/key-wallet-ffi/src/lib.rs b/key-wallet-ffi/src/lib.rs index dbe36f76b..755d67f67 100644 --- a/key-wallet-ffi/src/lib.rs +++ b/key-wallet-ffi/src/lib.rs @@ -16,6 +16,7 @@ pub mod managed_account; pub mod managed_account_collection; pub mod managed_wallet; pub mod mnemonic; +pub mod special_payload; pub mod transaction; pub mod transaction_checking; pub mod tx_decode; diff --git a/key-wallet-ffi/src/managed_account.rs b/key-wallet-ffi/src/managed_account.rs index b835d4dd9..84f9a99c2 100644 --- a/key-wallet-ffi/src/managed_account.rs +++ b/key-wallet-ffi/src/managed_account.rs @@ -7,12 +7,12 @@ use dash_network::ffi::FFINetwork; use dashcore::hashes::Hash; use std::os::raw::{c_char, c_uint}; -#[cfg(feature = "keep-finalized-transactions")] use std::ptr::slice_from_raw_parts_mut; use std::sync::Arc; use crate::address_pool::{FFIAddressPool, FFIAddressPoolType}; use crate::error::{FFIError, FFIErrorCode}; +use crate::special_payload::FFISpecialTransactionPayload; use crate::types::{ FFIAccountKind, FFIInputDetail, FFIOutputDetail, FFITransactionContext, FFITransactionDirection, FFITransactionType, @@ -894,6 +894,9 @@ pub struct FFITransactionRecord { pub tx_len: usize, /// Optional label (null if not set) pub label: *mut c_char, + /// Typed DIP-2 special-transaction payload (null for classical + /// transactions). Owned by this record and freed with it. + pub special_transaction_payload: *mut FFISpecialTransactionPayload, } impl From<&TransactionRecord> for FFITransactionRecord { @@ -943,6 +946,12 @@ impl From<&TransactionRecord> for FFITransactionRecord { Box::into_raw(output_slice) as *mut FFIOutputDetail }; + // Typed special-transaction payload (ProRegTx service IP etc.) + let special_transaction_payload = match &value.transaction.special_transaction_payload { + Some(payload) => Box::into_raw(Box::new(FFISpecialTransactionPayload::from(payload))), + None => std::ptr::null_mut(), + }; + FFITransactionRecord { txid, net_amount, @@ -958,6 +967,7 @@ impl From<&TransactionRecord> for FFITransactionRecord { tx_data, tx_len, label, + special_transaction_payload, } } } @@ -995,6 +1005,12 @@ impl Drop for FFITransactionRecord { self.label = std::ptr::null_mut(); } + + if !self.special_transaction_payload.is_null() { + let _ = unsafe { Box::from_raw(self.special_transaction_payload) }; + + self.special_transaction_payload = std::ptr::null_mut(); + } } } @@ -1041,17 +1057,64 @@ pub unsafe extern "C" fn managed_core_account_get_transactions( true } -#[cfg(feature = "keep-finalized-transactions")] -/// Free transactions array returned by managed_core_account_get_transactions +/// Get the transactions of a managed account that carry a DIP-2 special +/// payload (provider registrations / updates, asset locks, …). +/// +/// Unlike `managed_core_account_get_transactions` this is available in +/// every feature configuration: provider-relevant records are retained +/// in memory even after their block is chainlocked (see +/// `ManagedCoreKeysAccount::drop_finalized_transaction` in `key-wallet`), +/// so a provider-key account's registration history — including each +/// masternode's service IP — remains queryable without the +/// `keep-finalized-transactions` feature. +/// +/// Returns records in txid order. Each record's +/// `special_transaction_payload` field is non-null. /// -/// Only available with the `keep-finalized-transactions` Cargo feature, in -/// which configuration `managed_core_account_get_transactions` is also -/// available — the two functions are paired. +/// # Safety +/// +/// - `account` must be a valid pointer to an FFIManagedCoreAccount instance +/// - `transactions_out` must be a valid pointer to receive the transactions array pointer +/// - `count_out` must be a valid pointer to receive the count +/// - The caller must free the returned array using `managed_core_account_free_transactions` +#[no_mangle] +pub unsafe extern "C" fn managed_core_account_get_special_transactions( + account: *const FFIManagedCoreAccount, + transactions_out: *mut *mut FFITransactionRecord, + count_out: *mut usize, +) -> bool { + if account.is_null() || transactions_out.is_null() || count_out.is_null() { + return false; + } + + let account = &*account; + let ffi_tx = account + .keys_account() + .transactions() + .values() + .filter(|record| record.transaction.special_transaction_payload.is_some()) + .map(FFITransactionRecord::from) + .collect::>(); + + if ffi_tx.is_empty() { + *transactions_out = std::ptr::null_mut(); + *count_out = 0; + return true; + } + + *count_out = ffi_tx.len(); + *transactions_out = Box::into_raw(ffi_tx.into_boxed_slice()) as *mut FFITransactionRecord; + true +} + +/// Free a transactions array returned by +/// `managed_core_account_get_transactions` or +/// `managed_core_account_get_special_transactions`. /// /// # Safety /// -/// - `transactions` must be a pointer returned by `managed_core_account_get_transactions` -/// - `count` must be the count returned by `managed_core_account_get_transactions` +/// - `transactions` must be a pointer returned by one of the paired getters +/// - `count` must be the count returned alongside it /// - This function must only be called once per allocation #[no_mangle] pub unsafe extern "C" fn managed_core_account_free_transactions( @@ -1768,8 +1831,10 @@ mod tests { }; use dash_network::ffi::FFINetwork; use dashcore::Transaction; + #[cfg(feature = "keep-finalized-transactions")] + use key_wallet::managed_account::transaction_record::{OutputDetail, OutputRole}; use key_wallet::managed_account::transaction_record::{ - OutputDetail, OutputRole, TransactionDirection, TransactionRecord, + TransactionDirection, TransactionRecord, }; use key_wallet::transaction_checking::transaction_context::TransactionContext; use key_wallet::transaction_checking::transaction_router::TransactionType; @@ -2638,6 +2703,7 @@ mod tests { // Create label label: CString::new("Payment for coffee").unwrap().into_raw(), + special_transaction_payload: std::ptr::null_mut(), }; // Second record: empty sub-arrays @@ -2668,6 +2734,7 @@ mod tests { tx_data: std::ptr::null_mut(), tx_len: 0, label: std::ptr::null_mut(), + special_transaction_payload: std::ptr::null_mut(), }; records.push(r0); @@ -2682,4 +2749,177 @@ mod tests { managed_core_account_free_transactions(records_ptr, count); } } + + /// Masternode-registration transaction wrapping the mainnet-shape + /// ProRegTx payload fixture (service `54.148.58.128:9999`). + fn proreg_transaction() -> Transaction { + use dashcore::blockdata::transaction::special_transaction::TransactionPayload; + + Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::ProviderRegistrationPayloadType( + crate::special_payload::mainnet_shape_proreg_payload(), + )), + } + } + + /// Acceptance round-trip for issue #875: a stored `ProRegTx` record + /// exposes its typed payload — service address included — without the + /// consumer decoding `tx_data`. + #[test] + fn test_transaction_record_exposes_typed_proreg_payload() { + use dashcore::hashes::hex::FromHex; + + let tx = proreg_transaction(); + let record = TransactionRecord::new( + tx, + key_wallet::AccountType::ProviderOwnerKeys, + TransactionContext::Mempool, + TransactionType::ProviderRegistration, + TransactionDirection::Internal, + vec![], + vec![], + 0, + ); + + let ffi = FFITransactionRecord::from(&record); + assert!(!ffi.special_transaction_payload.is_null()); + let payload = unsafe { &*ffi.special_transaction_payload }; + assert_eq!(payload.payload_type, 1, "DIP-2 type 1 = ProRegTx"); + assert!(!payload.provider_registration.is_null()); + + let reg = unsafe { &*payload.provider_registration }; + let service = + unsafe { std::ffi::CStr::from_ptr(reg.service_address) }.to_str().expect("utf8"); + assert_eq!(service, "54.148.58.128:9999"); + assert_eq!(reg.service_port, 9999); + let expected_hash = + <[u8; 20]>::from_hex("70993555a01f7e8d6179d6135b5c56809d2d1d36").expect("hash160"); + assert_eq!(reg.owner_key_hash, expected_hash); + assert_eq!(reg.voting_key_hash, expected_hash); + + // The raw bytes escape hatch still round-trips alongside the + // typed payload. + let tx_bytes = unsafe { std::slice::from_raw_parts(ffi.tx_data, ffi.tx_len) }; + let decoded: Transaction = + dashcore::consensus::deserialize(tx_bytes).expect("tx_data must stay decodable"); + assert_eq!(decoded.txid().to_byte_array(), ffi.txid); + } + + /// `managed_core_account_get_special_transactions` surfaces + /// payload-carrying records from a provider keys account in every + /// feature configuration (this is how retained `ProRegTx` records + /// stay queryable without `keep-finalized-transactions`). + #[test] + fn test_get_special_transactions_returns_proreg_record() { + use key_wallet::transaction_checking::BlockInfo; + + unsafe { + let mut error = FFIError::default(); + let manager = wallet_manager_create(FFINetwork::Testnet, &mut error); + assert!(!manager.is_null()); + + let mnemonic = CString::new(TEST_MNEMONIC).unwrap(); + assert!(wallet_manager_add_wallet_from_mnemonic_with_options( + manager, + mnemonic.as_ptr(), + ptr::null(), + &mut error, + )); + + let mut wallet_ids_out: *mut u8 = ptr::null_mut(); + let mut count_out: usize = 0; + assert!(wallet_manager_get_wallet_ids( + manager, + &mut wallet_ids_out, + &mut count_out, + &mut error, + )); + assert_eq!(count_out, 1); + + // Insert a chainlocked ProRegTx record into the provider + // owner-keys account plus a payload-less control record. + let proreg_tx = proreg_transaction(); + let proreg_txid = proreg_tx.txid(); + let plain_tx = Transaction { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let account_type_rust = key_wallet::AccountType::ProviderOwnerKeys; + let block_hash = dashcore::BlockHash::from_byte_array([9u8; 32]); + + let mut wallet_id_array = [0u8; 32]; + ptr::copy_nonoverlapping(wallet_ids_out, wallet_id_array.as_mut_ptr(), 32); + + let manager_ref = &*manager; + manager_ref.runtime.block_on(async { + let mut manager_guard = manager_ref.manager.write().await; + let wallet_info = + manager_guard.get_wallet_info_mut(&wallet_id_array).expect("wallet"); + let mut account = crate::address_pool::get_managed_account_by_type_mut( + &mut wallet_info.accounts, + &account_type_rust, + ) + .expect("provider owner keys account"); + for (tx, tx_type) in [ + (proreg_tx.clone(), TransactionType::ProviderRegistration), + (plain_tx.clone(), TransactionType::Standard), + ] { + let txid = tx.txid(); + let record = TransactionRecord::new( + tx, + account_type_rust, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 100, + block_hash, + 1_700_000_000, + )), + tx_type, + TransactionDirection::Internal, + vec![], + vec![], + 0, + ); + account.transactions_mut().insert(txid, record); + } + }); + + let result = managed_wallet_get_account( + manager, + wallet_ids_out, + 0, + FFIAccountKind::ProviderOwnerKeys, + ); + assert!(!result.account.is_null()); + + let mut txs_out: *mut FFITransactionRecord = ptr::null_mut(); + let mut txs_count: usize = 0; + assert!(managed_core_account_get_special_transactions( + result.account, + &mut txs_out, + &mut txs_count, + )); + assert_eq!(txs_count, 1, "only the payload-carrying record is returned"); + + let record = &*txs_out; + assert_eq!(record.txid, proreg_txid.to_byte_array()); + assert!(!record.special_transaction_payload.is_null()); + let payload = &*record.special_transaction_payload; + assert_eq!(payload.payload_type, 1); + let reg = &*payload.provider_registration; + let service = std::ffi::CStr::from_ptr(reg.service_address).to_str().expect("utf8"); + assert_eq!(service, "54.148.58.128:9999"); + + managed_core_account_free_transactions(txs_out, txs_count); + managed_core_account_free(result.account); + wallet_manager_free_wallet_ids(wallet_ids_out, count_out); + wallet_manager_free(manager); + } + } } diff --git a/key-wallet-ffi/src/special_payload.rs b/key-wallet-ffi/src/special_payload.rs new file mode 100644 index 000000000..33cfc81aa --- /dev/null +++ b/key-wallet-ffi/src/special_payload.rs @@ -0,0 +1,548 @@ +//! FFI-safe typed views of DIP-2 special-transaction payloads. +//! +//! [`FFISpecialTransactionPayload`] is a tagged representation of +//! `dashcore`'s `TransactionPayload`: `payload_type` carries the on-wire +//! DIP-2 transaction type (`1` = ProRegTx … `9` = AssetUnlockTx), and for +//! the four masternode provider payload kinds a matching typed struct +//! pointer is non-null. Other payload kinds (coinbase, quorum commitment, +//! asset lock/unlock, …) currently expose only the discriminant; typed +//! structs for them can be added later without breaking the layout +//! contract (consumers must key off `payload_type`). +//! +//! All heap-allocated fields are owned by the structs and freed by their +//! `Drop` impls, which run when the owning +//! [`FFITransactionRecord`](crate::managed_account::FFITransactionRecord) +//! is freed. + +use std::net::{IpAddr, Ipv6Addr, SocketAddr}; +use std::os::raw::c_char; + +use dashcore::blockdata::transaction::special_transaction::provider_registration::ProviderRegistrationPayload; +use dashcore::blockdata::transaction::special_transaction::provider_update_registrar::ProviderUpdateRegistrarPayload; +use dashcore::blockdata::transaction::special_transaction::provider_update_revocation::ProviderUpdateRevocationPayload; +use dashcore::blockdata::transaction::special_transaction::provider_update_service::ProviderUpdateServicePayload; +use dashcore::blockdata::transaction::special_transaction::TransactionPayload; +use dashcore::bls_sig_utils::BLSPublicKey; +use dashcore::hashes::Hash; +use dashcore::ScriptBuf; + +/// Box `script`'s bytes into a raw `(ptr, len)` pair; `(null, 0)` for an +/// empty script. +fn script_to_raw(script: &ScriptBuf) -> (*mut u8, usize) { + let bytes = script.as_bytes().to_vec().into_boxed_slice(); + let len = bytes.len(); + if len == 0 { + (std::ptr::null_mut(), 0) + } else { + (Box::into_raw(bytes) as *mut u8, len) + } +} + +/// Free a `(ptr, len)` pair produced by [`script_to_raw`]. +/// +/// # Safety +/// +/// `ptr`/`len` must come from `script_to_raw` and not have been freed yet. +unsafe fn free_raw_script(ptr: &mut *mut u8, len: &mut usize) { + if !ptr.is_null() && *len > 0 { + let slice_ptr = std::ptr::slice_from_raw_parts_mut(*ptr, *len); + let _ = Box::from_raw(slice_ptr); + } + *ptr = std::ptr::null_mut(); + *len = 0; +} + +/// Render `addr` as its canonical display string (`ip:port`), boxed as a +/// C string. +fn socket_addr_to_cstring(addr: &SocketAddr) -> *mut c_char { + std::ffi::CString::new(addr.to_string()).unwrap_or_default().into_raw() +} + +/// Normalize `addr`'s IP to 16 IPv6 octets (IPv4 addresses are mapped), +/// matching the on-wire DIP-3 service field encoding. +fn socket_addr_to_ipv6_octets(addr: &SocketAddr) -> [u8; 16] { + match addr.ip() { + IpAddr::V4(v4) => v4.to_ipv6_mapped().octets(), + IpAddr::V6(v6) => v6.octets(), + } +} + +/// Copy a BLS public key's 48 raw bytes. +fn bls_public_key_bytes(key: &BLSPublicKey) -> [u8; 48] { + *AsRef::<[u8; 48]>::as_ref(key) +} + +/// Reassemble a `SocketAddr` from 16 IPv6 octets + port, un-mapping +/// v4-mapped addresses so the display string reads `54.148.58.128:9999` +/// rather than `[::ffff:54.148.58.128]:9999`. +fn socket_addr_from_ipv6_octets(octets: [u8; 16], port: u16) -> SocketAddr { + let v6 = Ipv6Addr::from(octets); + match v6.to_ipv4_mapped() { + Some(v4) => SocketAddr::new(IpAddr::V4(v4), port), + None => SocketAddr::new(IpAddr::V6(v6), port), + } +} + +/// Typed view of a DIP-3 `ProRegTx` (provider registration) payload. +/// +/// Byte-order notes: `collateral_txid` and hash fields use the same byte +/// order as the rest of this FFI surface (`to_byte_array`, i.e. the +/// internal little-endian hash representation). `service_ip` is the raw +/// 16-byte on-wire IPv6 form with IPv4 addresses v4-mapped +/// (`::ffff:a.b.c.d`); `service_address` is the human-readable +/// `ip:port` rendering of the same data. +#[repr(C)] +pub struct FFIProviderRegistrationPayload { + /// ProRegTx payload version. + pub version: u16, + /// Masternode type: `0` = regular, `1` = high-performance (Evo). + pub masternode_type: u16, + /// Masternode operating mode (`0` currently). + pub masternode_mode: u16, + /// Collateral outpoint txid (32 bytes, `to_byte_array` order). All + /// zeros when the collateral is an output of this transaction itself. + pub collateral_txid: [u8; 32], + /// Collateral outpoint output index. + pub collateral_vout: u32, + /// Masternode service endpoint as a display string, e.g. + /// `"54.148.58.128:9999"`. Owned by this struct. + pub service_address: *mut c_char, + /// Raw service IP: 16 IPv6 octets, IPv4 addresses v4-mapped. + pub service_ip: [u8; 16], + /// Service port (host byte order). + pub service_port: u16, + /// Owner key hash160 (20 bytes). + pub owner_key_hash: [u8; 20], + /// Voting key hash160 (20 bytes). + pub voting_key_hash: [u8; 20], + /// Operator BLS public key (48 bytes). + pub operator_public_key: [u8; 48], + /// Operator reward in basis points (0–10000). + pub operator_reward: u16, + /// Payout script bytes (owned by this struct, null when empty). + pub script_payout: *mut u8, + /// Length of `script_payout`. + pub script_payout_len: usize, + /// `true` when the payload carries platform (Evo) fields; the three + /// fields below are only meaningful in that case. + pub has_platform_fields: bool, + /// Platform node ID (20 bytes, zeroed when `has_platform_fields` is false). + pub platform_node_id: [u8; 20], + /// Platform P2P port, `-1` when absent. + pub platform_p2p_port: i32, + /// Platform HTTP port, `-1` when absent. + pub platform_http_port: i32, +} + +impl From<&ProviderRegistrationPayload> for FFIProviderRegistrationPayload { + fn from(p: &ProviderRegistrationPayload) -> Self { + let (script_payout, script_payout_len) = script_to_raw(&p.script_payout); + FFIProviderRegistrationPayload { + version: p.version, + masternode_type: p.masternode_type as u16, + masternode_mode: p.masternode_mode, + collateral_txid: p.collateral_outpoint.txid.to_byte_array(), + collateral_vout: p.collateral_outpoint.vout, + service_address: socket_addr_to_cstring(&p.service_address), + service_ip: socket_addr_to_ipv6_octets(&p.service_address), + service_port: p.service_address.port(), + owner_key_hash: p.owner_key_hash.to_byte_array(), + voting_key_hash: p.voting_key_hash.to_byte_array(), + operator_public_key: bls_public_key_bytes(&p.operator_public_key), + operator_reward: p.operator_reward, + script_payout, + script_payout_len, + has_platform_fields: p.platform_node_id.is_some(), + platform_node_id: p.platform_node_id.map(|h| h.to_byte_array()).unwrap_or([0; 20]), + platform_p2p_port: p.platform_p2p_port.map_or(-1, i32::from), + platform_http_port: p.platform_http_port.map_or(-1, i32::from), + } + } +} + +impl Drop for FFIProviderRegistrationPayload { + fn drop(&mut self) { + if !self.service_address.is_null() { + let _ = unsafe { std::ffi::CString::from_raw(self.service_address) }; + self.service_address = std::ptr::null_mut(); + } + unsafe { free_raw_script(&mut self.script_payout, &mut self.script_payout_len) }; + } +} + +/// Typed view of a DIP-3 `ProUpServTx` (provider update service) payload. +/// +/// Same byte-order conventions as [`FFIProviderRegistrationPayload`]. +#[repr(C)] +pub struct FFIProviderUpdateServicePayload { + /// ProUpServTx payload version. + pub version: u16, + /// Masternode type (`0` regular / `1` Evo), `-1` when the payload + /// version predates the field. + pub masternode_type: i32, + /// ProRegTx hash of the masternode being updated (32 bytes, + /// `to_byte_array` order). + pub pro_tx_hash: [u8; 32], + /// New masternode service endpoint as a display string. Owned by + /// this struct. + pub service_address: *mut c_char, + /// Raw new service IP: 16 IPv6 octets, IPv4 addresses v4-mapped. + pub service_ip: [u8; 16], + /// New service port (host byte order). + pub service_port: u16, + /// Operator payout script bytes (owned by this struct, null when empty). + pub script_payout: *mut u8, + /// Length of `script_payout`. + pub script_payout_len: usize, + /// `true` when the payload carries platform (Evo) fields; the three + /// fields below are only meaningful in that case. + pub has_platform_fields: bool, + /// Platform node ID (20 bytes, zeroed when `has_platform_fields` is false). + pub platform_node_id: [u8; 20], + /// Platform P2P port, `-1` when absent. + pub platform_p2p_port: i32, + /// Platform HTTP port, `-1` when absent. + pub platform_http_port: i32, +} + +impl From<&ProviderUpdateServicePayload> for FFIProviderUpdateServicePayload { + fn from(p: &ProviderUpdateServicePayload) -> Self { + // The wire format stores the IP as a u128 whose little-endian + // byte view is the 16 IPv6 octets in network order (see the + // `ProviderUpdateServicePayload` consensus round-trip tests). + let service_ip = p.ip_address.to_le_bytes(); + let service = socket_addr_from_ipv6_octets(service_ip, p.port); + let (script_payout, script_payout_len) = script_to_raw(&p.script_payout); + FFIProviderUpdateServicePayload { + version: p.version, + masternode_type: p.mn_type.map_or(-1, i32::from), + pro_tx_hash: p.pro_tx_hash.to_byte_array(), + service_address: socket_addr_to_cstring(&service), + service_ip, + service_port: p.port, + script_payout, + script_payout_len, + has_platform_fields: p.platform_node_id.is_some(), + platform_node_id: p.platform_node_id.unwrap_or([0; 20]), + platform_p2p_port: p.platform_p2p_port.map_or(-1, i32::from), + platform_http_port: p.platform_http_port.map_or(-1, i32::from), + } + } +} + +impl Drop for FFIProviderUpdateServicePayload { + fn drop(&mut self) { + if !self.service_address.is_null() { + let _ = unsafe { std::ffi::CString::from_raw(self.service_address) }; + self.service_address = std::ptr::null_mut(); + } + unsafe { free_raw_script(&mut self.script_payout, &mut self.script_payout_len) }; + } +} + +/// Typed view of a DIP-3 `ProUpRegTx` (provider update registrar) payload. +#[repr(C)] +pub struct FFIProviderUpdateRegistrarPayload { + /// ProUpRegTx payload version. + pub version: u16, + /// ProRegTx hash of the masternode being updated (32 bytes, + /// `to_byte_array` order). + pub pro_tx_hash: [u8; 32], + /// Masternode operating mode (`0` currently). + pub provider_mode: u16, + /// New operator BLS public key (48 bytes). + pub operator_public_key: [u8; 48], + /// New voting key hash160 (20 bytes). + pub voting_key_hash: [u8; 20], + /// New payout script bytes (owned by this struct, null when empty). + pub script_payout: *mut u8, + /// Length of `script_payout`. + pub script_payout_len: usize, +} + +impl From<&ProviderUpdateRegistrarPayload> for FFIProviderUpdateRegistrarPayload { + fn from(p: &ProviderUpdateRegistrarPayload) -> Self { + let (script_payout, script_payout_len) = script_to_raw(&p.script_payout); + FFIProviderUpdateRegistrarPayload { + version: p.version, + pro_tx_hash: p.pro_tx_hash.to_byte_array(), + provider_mode: p.provider_mode, + operator_public_key: bls_public_key_bytes(&p.operator_public_key), + voting_key_hash: p.voting_key_hash.to_byte_array(), + script_payout, + script_payout_len, + } + } +} + +impl Drop for FFIProviderUpdateRegistrarPayload { + fn drop(&mut self) { + unsafe { free_raw_script(&mut self.script_payout, &mut self.script_payout_len) }; + } +} + +/// Typed view of a DIP-3 `ProUpRevTx` (provider update revocation) payload. +#[repr(C)] +pub struct FFIProviderUpdateRevocationPayload { + /// ProUpRevTx payload version. + pub version: u16, + /// ProRegTx hash of the masternode being revoked (32 bytes, + /// `to_byte_array` order). + pub pro_tx_hash: [u8; 32], + /// Revocation reason (`0` not specified, `1` termination of service, + /// `2` compromised keys, `3` change of keys). + pub reason: u16, +} + +impl From<&ProviderUpdateRevocationPayload> for FFIProviderUpdateRevocationPayload { + fn from(p: &ProviderUpdateRevocationPayload) -> Self { + FFIProviderUpdateRevocationPayload { + version: p.version, + pro_tx_hash: p.pro_tx_hash.to_byte_array(), + reason: p.reason, + } + } +} + +/// Tagged FFI view of a transaction's DIP-2 special payload. +/// +/// `payload_type` is the on-wire DIP-2 transaction type (`1` ProRegTx, +/// `2` ProUpServTx, `3` ProUpRegTx, `4` ProUpRevTx, `5` CbTx, `6` QcTx, +/// `7` MnHfTx, `8` AssetLockTx, `9` AssetUnlockTx; pre-DIP-0002 +/// transactions with non-standard type bytes surface their raw value). +/// For the four provider payload kinds the matching struct pointer is +/// non-null; exactly one pointer is ever non-null. All pointers are owned +/// by this struct and freed with it. +#[repr(C)] +pub struct FFISpecialTransactionPayload { + /// On-wire DIP-2 transaction type discriminant. + pub payload_type: u16, + /// Non-null iff `payload_type == 1` (ProRegTx). + pub provider_registration: *mut FFIProviderRegistrationPayload, + /// Non-null iff `payload_type == 2` (ProUpServTx). + pub provider_update_service: *mut FFIProviderUpdateServicePayload, + /// Non-null iff `payload_type == 3` (ProUpRegTx). + pub provider_update_registrar: *mut FFIProviderUpdateRegistrarPayload, + /// Non-null iff `payload_type == 4` (ProUpRevTx). + pub provider_update_revocation: *mut FFIProviderUpdateRevocationPayload, +} + +impl From<&TransactionPayload> for FFISpecialTransactionPayload { + fn from(payload: &TransactionPayload) -> Self { + let mut ffi = FFISpecialTransactionPayload { + payload_type: payload.get_type().to_u16(), + provider_registration: std::ptr::null_mut(), + provider_update_service: std::ptr::null_mut(), + provider_update_registrar: std::ptr::null_mut(), + provider_update_revocation: std::ptr::null_mut(), + }; + match payload { + TransactionPayload::ProviderRegistrationPayloadType(p) => { + ffi.provider_registration = Box::into_raw(Box::new(p.into())); + } + TransactionPayload::ProviderUpdateServicePayloadType(p) => { + ffi.provider_update_service = Box::into_raw(Box::new(p.into())); + } + TransactionPayload::ProviderUpdateRegistrarPayloadType(p) => { + ffi.provider_update_registrar = Box::into_raw(Box::new(p.into())); + } + TransactionPayload::ProviderUpdateRevocationPayloadType(p) => { + ffi.provider_update_revocation = Box::into_raw(Box::new(p.into())); + } + // Only the discriminant is exposed for the remaining payload + // kinds; typed structs can be added later. + _ => {} + } + ffi + } +} + +impl Drop for FFISpecialTransactionPayload { + fn drop(&mut self) { + if !self.provider_registration.is_null() { + let _ = unsafe { Box::from_raw(self.provider_registration) }; + self.provider_registration = std::ptr::null_mut(); + } + if !self.provider_update_service.is_null() { + let _ = unsafe { Box::from_raw(self.provider_update_service) }; + self.provider_update_service = std::ptr::null_mut(); + } + if !self.provider_update_registrar.is_null() { + let _ = unsafe { Box::from_raw(self.provider_update_registrar) }; + self.provider_update_registrar = std::ptr::null_mut(); + } + if !self.provider_update_revocation.is_null() { + let _ = unsafe { Box::from_raw(self.provider_update_revocation) }; + self.provider_update_revocation = std::ptr::null_mut(); + } + } +} + +/// Mainnet-shape ProRegTx payload for tests: owner/voting hash160 and +/// service endpoint from the masternode registration referenced in +/// issue #875. +#[cfg(test)] +pub(crate) fn mainnet_shape_proreg_payload() -> ProviderRegistrationPayload { + use dashcore::blockdata::transaction::special_transaction::provider_registration::ProviderMasternodeType; + use std::str::FromStr; + + let key_hash = dashcore::PubkeyHash::from_hex("70993555a01f7e8d6179d6135b5c56809d2d1d36") + .expect("hash160"); + ProviderRegistrationPayload { + version: 1, + masternode_type: ProviderMasternodeType::Regular, + masternode_mode: 0, + collateral_outpoint: dashcore::OutPoint { + txid: dashcore::Txid::from_hex( + "e0ab1fc3cbe921a2a026e5c9c17d1e5b1b1c460ac4b60964d97b0e6ba9a5f719", + ) + .expect("txid"), + vout: 1, + }, + service_address: SocketAddr::from_str("54.148.58.128:9999").expect("socket addr"), + owner_key_hash: key_hash, + operator_public_key: BLSPublicKey::from([0x11; 48]), + voting_key_hash: key_hash, + operator_reward: 250, + script_payout: ScriptBuf::from_hex("76a914fef33f56f709ba6b08d073932f925afedb606d0288ac") + .expect("script"), + inputs_hash: dashcore::hash_types::InputsHash::from_slice(&[0x22; 32]) + .expect("inputs hash"), + signature: vec![0x33; 65], + platform_node_id: None, + platform_p2p_port: None, + platform_http_port: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dashcore::bls_sig_utils::BLSSignature; + use dashcore::hash_types::InputsHash; + use dashcore::hashes::hex::FromHex; + use dashcore::Txid; + use std::ffi::CStr; + + #[test] + fn proreg_payload_exposes_service_and_keys() { + let payload = + TransactionPayload::ProviderRegistrationPayloadType(mainnet_shape_proreg_payload()); + let ffi = FFISpecialTransactionPayload::from(&payload); + + assert_eq!(ffi.payload_type, 1); + assert!(ffi.provider_update_service.is_null()); + assert!(ffi.provider_update_registrar.is_null()); + assert!(ffi.provider_update_revocation.is_null()); + + let reg = unsafe { &*ffi.provider_registration }; + let service = unsafe { CStr::from_ptr(reg.service_address) }.to_str().expect("utf8"); + assert_eq!(service, "54.148.58.128:9999"); + assert_eq!(reg.service_port, 9999); + let mut expected_ip = [0u8; 16]; + expected_ip[10] = 0xff; + expected_ip[11] = 0xff; + expected_ip[12..].copy_from_slice(&[54, 148, 58, 128]); + assert_eq!(reg.service_ip, expected_ip); + + let expected_hash = + <[u8; 20]>::from_hex("70993555a01f7e8d6179d6135b5c56809d2d1d36").expect("hash160"); + assert_eq!(reg.owner_key_hash, expected_hash); + assert_eq!(reg.voting_key_hash, expected_hash); + assert_eq!(reg.operator_public_key, [0x11; 48]); + assert_eq!(reg.operator_reward, 250); + assert_eq!(reg.masternode_type, 0); + assert_eq!(reg.collateral_vout, 1); + assert!(!reg.has_platform_fields); + assert_eq!(reg.platform_p2p_port, -1); + + let script = + unsafe { std::slice::from_raw_parts(reg.script_payout, reg.script_payout_len) }; + assert_eq!( + script, + Vec::::from_hex("76a914fef33f56f709ba6b08d073932f925afedb606d0288ac") + .expect("script hex") + .as_slice() + ); + } + + #[test] + fn proupserv_payload_exposes_new_service_address() { + // 54.148.58.128 as a v4-mapped IPv6, little-endian u128 view. + let mut octets = [0u8; 16]; + octets[10] = 0xff; + octets[11] = 0xff; + octets[12..].copy_from_slice(&[54, 148, 58, 128]); + + let payload = + TransactionPayload::ProviderUpdateServicePayloadType(ProviderUpdateServicePayload { + version: 1, + mn_type: None, + pro_tx_hash: Txid::from_slice(&[0x44; 32]).expect("txid"), + ip_address: u128::from_le_bytes(octets), + port: 19999, + script_payout: dashcore::ScriptBuf::new(), + inputs_hash: InputsHash::from_slice(&[0x55; 32]).expect("inputs hash"), + platform_node_id: None, + platform_p2p_port: None, + platform_http_port: None, + payload_sig: BLSSignature::from([0x66; 96]), + }); + let ffi = FFISpecialTransactionPayload::from(&payload); + + assert_eq!(ffi.payload_type, 2); + assert!(ffi.provider_registration.is_null()); + let upserv = unsafe { &*ffi.provider_update_service }; + let service = unsafe { CStr::from_ptr(upserv.service_address) }.to_str().expect("utf8"); + assert_eq!(service, "54.148.58.128:19999"); + assert_eq!(upserv.service_ip, octets); + assert_eq!(upserv.service_port, 19999); + assert_eq!(upserv.pro_tx_hash, [0x44; 32]); + assert_eq!(upserv.masternode_type, -1); + assert!(upserv.script_payout.is_null(), "empty payout script must be null"); + assert_eq!(upserv.script_payout_len, 0); + } + + #[test] + fn prouprev_payload_exposes_reason() { + let payload = TransactionPayload::ProviderUpdateRevocationPayloadType( + ProviderUpdateRevocationPayload { + version: 1, + pro_tx_hash: Txid::from_slice(&[0x77; 32]).expect("txid"), + reason: 2, + inputs_hash: InputsHash::from_slice(&[0x88; 32]).expect("inputs hash"), + payload_sig: BLSSignature::from([0x99; 96]), + }, + ); + let ffi = FFISpecialTransactionPayload::from(&payload); + + assert_eq!(ffi.payload_type, 4); + let rev = unsafe { &*ffi.provider_update_revocation }; + assert_eq!(rev.pro_tx_hash, [0x77; 32]); + assert_eq!(rev.reason, 2); + } + + #[test] + fn unsupported_payload_kind_exposes_discriminant_only() { + use dashcore::blockdata::transaction::special_transaction::coinbase::CoinbasePayload; + use dashcore::hash_types::{MerkleRootMasternodeList, MerkleRootQuorums}; + + let payload = TransactionPayload::CoinbasePayloadType(CoinbasePayload { + version: 2, + height: 100, + merkle_root_masternode_list: MerkleRootMasternodeList::from_slice(&[0; 32]) + .expect("hash"), + merkle_root_quorums: MerkleRootQuorums::from_slice(&[0; 32]).expect("hash"), + best_cl_height: None, + best_cl_signature: None, + asset_locked_amount: None, + }); + let ffi = FFISpecialTransactionPayload::from(&payload); + + assert_eq!(ffi.payload_type, 5); + assert!(ffi.provider_registration.is_null()); + assert!(ffi.provider_update_service.is_null()); + assert!(ffi.provider_update_registrar.is_null()); + assert!(ffi.provider_update_revocation.is_null()); + } +} diff --git a/key-wallet/src/managed_account/managed_core_keys_account.rs b/key-wallet/src/managed_account/managed_core_keys_account.rs index 62fa7a977..ecb974a61 100644 --- a/key-wallet/src/managed_account/managed_core_keys_account.rs +++ b/key-wallet/src/managed_account/managed_core_keys_account.rs @@ -51,7 +51,9 @@ pub struct ManagedCoreKeysAccount { /// including ones that have been chainlocked. With the feature OFF /// (the default), records of chainlocked transactions are dropped /// from this map and only their txids are retained in - /// `finalized_txids` to bound memory growth. + /// `finalized_txids` to bound memory growth — except provider-payload + /// records on provider-key accounts, which are always retained (see + /// [`Self::drop_finalized_transaction`]). transactions: BTreeMap, /// Txids of transactions that have been finalized in a chainlocked /// block and whose full records have been dropped from @@ -98,15 +100,59 @@ impl ManagedCoreKeysAccount { /// [`ManagedAccountTrait::transaction_is_finalized`] keeps /// returning `true`. /// + /// Exception: on masternode provider-key accounts, records whose + /// transaction carries a DIP-3 provider payload (`ProRegTx`, + /// `ProUpServTx`, `ProUpRegTx`, `ProUpRevTx`) are retained. Their + /// payload data — most importantly the masternode's service IP:port + /// and `proTxHash` linkage — is what the account exists to surface, + /// and it would otherwise become unreachable the moment the + /// registration is chainlocked. These records are rare (one per + /// masternode lifecycle event matching our keys), so retaining them + /// does not meaningfully grow memory. + /// /// With the feature on the full record stays in `transactions` /// indefinitely, so there's nothing to do — the function does not /// exist in that mode. #[cfg(not(feature = "keep-finalized-transactions"))] pub(crate) fn drop_finalized_transaction(&mut self, txid: &Txid) { self.finalized_txids.insert(*txid); + if self.retains_provider_payload_record(txid) { + return; + } self.transactions.remove(txid); } + /// Whether the record for `txid` must survive finalization: true only + /// on provider-key accounts for transactions carrying a DIP-3 + /// provider payload. See [`Self::drop_finalized_transaction`]. + #[cfg(not(feature = "keep-finalized-transactions"))] + fn retains_provider_payload_record(&self, txid: &Txid) -> bool { + use dashcore::blockdata::transaction::special_transaction::TransactionPayload; + + let is_provider_keys_account = matches!( + self.managed_account_type, + ManagedAccountType::ProviderVotingKeys { .. } + | ManagedAccountType::ProviderOwnerKeys { .. } + | ManagedAccountType::ProviderOperatorKeys { .. } + | ManagedAccountType::ProviderPlatformKeys { .. } + ); + if !is_provider_keys_account { + return false; + } + + self.transactions.get(txid).is_some_and(|record| { + matches!( + record.transaction.special_transaction_payload, + Some( + TransactionPayload::ProviderRegistrationPayloadType(_) + | TransactionPayload::ProviderUpdateServicePayloadType(_) + | TransactionPayload::ProviderUpdateRegistrarPayloadType(_) + | TransactionPayload::ProviderUpdateRevocationPayloadType(_) + ) + ) + }) + } + /// Promote any `InBlock` records at height `<= cl_height` to /// [`TransactionContext::InChainLockedBlock`] and return their txids. /// @@ -367,10 +413,10 @@ impl ManagedAccountTrait for ManagedCoreKeysAccount { } /// With the feature OFF, chainlocked records are dropped from - /// `transactions` and only their txids are retained in - /// `finalized_txids`. A live record can never satisfy this check - /// (it would have been pruned at the chainlock event), so the only - /// `true` answer comes from the txid set. + /// `transactions` and their txids are retained in `finalized_txids`. + /// The txid set is authoritative: even the provider-payload records + /// that [`Self::drop_finalized_transaction`] keeps alive in + /// `transactions` have their txid added to the set at finalization. #[cfg(not(feature = "keep-finalized-transactions"))] fn transaction_is_finalized(&self, txid: &Txid) -> bool { self.finalized_txids.contains(txid) diff --git a/key-wallet/src/tests/keep_finalized_transactions_tests.rs b/key-wallet/src/tests/keep_finalized_transactions_tests.rs index dcfef377a..d5100cee8 100644 --- a/key-wallet/src/tests/keep_finalized_transactions_tests.rs +++ b/key-wallet/src/tests/keep_finalized_transactions_tests.rs @@ -22,6 +22,15 @@ use crate::{ transaction_checking::{BlockInfo, TransactionContext}, wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface, }; +#[cfg(not(feature = "keep-finalized-transactions"))] +use crate::{ + managed_account::{ + address_pool::KeySource, managed_account_type::ManagedAccountType, ManagedCoreKeysAccount, + }, + transaction_checking::account_checker::{AccountMatch, CoreAccountTypeMatch}, + transaction_checking::transaction_router::TransactionType as RoutedTransactionType, + Network, +}; use dashcore::ephemerealdata::chain_lock::ChainLock; #[cfg(not(feature = "keep-finalized-transactions"))] use dashcore::ephemerealdata::instant_lock::InstantLock; @@ -213,6 +222,194 @@ async fn test_apply_chain_lock_skips_unmined_and_above_height() { assert!(!ctx.bip44_account().transaction_is_finalized(&block_txid)); } +/// Build a masternode-registration transaction whose DIP-3 payload carries a +/// service endpoint, plus the [`AccountMatch`] a provider owner-keys account +/// would report for it. +#[cfg(not(feature = "keep-finalized-transactions"))] +fn proreg_tx_and_owner_match() -> (Transaction, AccountMatch) { + use dashcore::blockdata::transaction::special_transaction::provider_registration::{ + ProviderMasternodeType, ProviderRegistrationPayload, + }; + use dashcore::blockdata::transaction::special_transaction::TransactionPayload; + use dashcore::bls_sig_utils::BLSPublicKey; + use dashcore::hash_types::InputsHash; + use dashcore::{OutPoint, PubkeyHash, ScriptBuf, Txid}; + use std::net::SocketAddr; + use std::str::FromStr; + + let key_hash = PubkeyHash::from_slice(&[0x70; 20]).expect("hash160"); + let payload = ProviderRegistrationPayload { + version: 1, + masternode_type: ProviderMasternodeType::Regular, + masternode_mode: 0, + collateral_outpoint: OutPoint { + txid: Txid::from_slice(&[0x19; 32]).expect("txid"), + vout: 1, + }, + service_address: SocketAddr::from_str("54.148.58.128:9999").expect("socket addr"), + owner_key_hash: key_hash, + operator_public_key: BLSPublicKey::from([0x11; 48]), + voting_key_hash: key_hash, + operator_reward: 0, + script_payout: ScriptBuf::new(), + inputs_hash: InputsHash::from_slice(&[0x22; 32]).expect("inputs hash"), + signature: vec![0x33; 65], + platform_node_id: None, + platform_p2p_port: None, + platform_http_port: None, + }; + let tx = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::ProviderRegistrationPayloadType( + payload, + )), + }; + let account_match = AccountMatch { + account_type_match: CoreAccountTypeMatch::ProviderOwnerKeys { + involved_addresses: vec![], + }, + received: 0, + sent: 0, + received_for_credit_conversion: 0, + }; + (tx, account_match) +} + +/// Build a keys account of the given type without key material (the +/// retention decision only looks at the account type and the payload). +#[cfg(not(feature = "keep-finalized-transactions"))] +fn keys_account(account_type: AccountType) -> ManagedCoreKeysAccount { + let managed_type = ManagedAccountType::from_account_type( + account_type, + Network::Testnet, + &KeySource::NoKeySource, + ) + .expect("managed account type"); + ManagedCoreKeysAccount::new(managed_type, Network::Testnet) +} + +/// A `ProRegTx` first seen already chainlocked (the historical-rescan case +/// from masternode-key discovery) must keep its full record on a provider +/// owner-keys account even with the feature OFF: the payload's service +/// IP / proTxHash data is what the account exists to surface. +#[cfg(not(feature = "keep-finalized-transactions"))] +#[test] +fn test_provider_payload_record_retained_on_provider_account() { + let mut account = keys_account(AccountType::ProviderOwnerKeys); + let (tx, account_match) = proreg_tx_and_owner_match(); + let txid = tx.txid(); + + let block_hash = BlockHash::from_slice(&[5u8; 32]).expect("hash"); + let _ = account.record_transaction( + &tx, + &account_match, + TransactionContext::InChainLockedBlock(BlockInfo::new(100, block_hash, 1_700_000_000)), + RoutedTransactionType::ProviderRegistration, + ); + + assert!(account.transaction_is_finalized(&txid)); + assert!(account.has_transaction(&txid)); + let record = account + .transactions() + .get(&txid) + .expect("provider payload record must be retained past finalization"); + assert!(record.transaction.special_transaction_payload.is_some()); +} + +/// The retention exception is payload-driven: a payload-less transaction +/// on the same provider account still drops at finalization. +#[cfg(not(feature = "keep-finalized-transactions"))] +#[test] +fn test_plain_record_still_dropped_on_provider_account() { + let mut account = keys_account(AccountType::ProviderOwnerKeys); + let (_, account_match) = proreg_tx_and_owner_match(); + let tx = Transaction { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let txid = tx.txid(); + + let block_hash = BlockHash::from_slice(&[6u8; 32]).expect("hash"); + let _ = account.record_transaction( + &tx, + &account_match, + TransactionContext::InChainLockedBlock(BlockInfo::new(100, block_hash, 1_700_000_000)), + RoutedTransactionType::Standard, + ); + + assert!(account.transaction_is_finalized(&txid)); + assert!(account.has_transaction(&txid)); + assert!( + !account.transactions().contains_key(&txid), + "payload-less records must still be dropped at finalization" + ); +} + +/// The retention exception is also account-driven: the same `ProRegTx` +/// record drops at finalization on a non-provider keys account. +#[cfg(not(feature = "keep-finalized-transactions"))] +#[test] +fn test_provider_payload_record_dropped_on_non_provider_account() { + let mut account = keys_account(AccountType::IdentityRegistration); + let (tx, _) = proreg_tx_and_owner_match(); + let account_match = AccountMatch { + account_type_match: CoreAccountTypeMatch::IdentityRegistration { + involved_addresses: vec![], + }, + received: 0, + sent: 0, + received_for_credit_conversion: 0, + }; + let txid = tx.txid(); + + let block_hash = BlockHash::from_slice(&[7u8; 32]).expect("hash"); + let _ = account.record_transaction( + &tx, + &account_match, + TransactionContext::InChainLockedBlock(BlockInfo::new(100, block_hash, 1_700_000_000)), + RoutedTransactionType::ProviderRegistration, + ); + + assert!(account.transaction_is_finalized(&txid)); + assert!(!account.transactions().contains_key(&txid)); +} + +/// A retained provider record must also survive the deferred-chainlock +/// path (`apply_chain_lock` promoting an `InBlock` record) with its +/// context updated, and repeated chainlocks must be idempotent. +#[cfg(not(feature = "keep-finalized-transactions"))] +#[test] +fn test_provider_payload_record_retained_through_apply_chain_lock() { + let mut account = keys_account(AccountType::ProviderOwnerKeys); + let (tx, account_match) = proreg_tx_and_owner_match(); + let txid = tx.txid(); + + let block_hash = BlockHash::from_slice(&[8u8; 32]).expect("hash"); + let _ = account.record_transaction( + &tx, + &account_match, + TransactionContext::InBlock(BlockInfo::new(50, block_hash, 1_700_000_000)), + RoutedTransactionType::ProviderRegistration, + ); + + let promoted = account.apply_chain_lock(60); + assert_eq!(promoted, vec![txid]); + assert!(account.transaction_is_finalized(&txid)); + let record = account.transactions().get(&txid).expect("record retained"); + assert!(matches!(record.context, TransactionContext::InChainLockedBlock(_))); + + // A later chainlock must not re-promote (or drop) the retained record. + let promoted_again = account.apply_chain_lock(70); + assert!(promoted_again.is_empty()); + assert!(account.transactions().contains_key(&txid)); +} + /// IS-lock first, then a chainlocked block: the record must drop only at /// the chainlock step. We also assert that the chainlock event still /// "lands" — `transaction_is_finalized` must report `true` when asked