From 057e2e15c6876ead8c4b82b34dbf812b19ea51d5 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Tue, 22 Sep 2026 12:28:50 -0400 Subject: [PATCH 1/2] fix(qortal): bound history recovery and validate outgoing accounting Share a five-second deadline across optional connection and outgoing recovery so historical metadata cannot monopolize an embedding Core's native lane. Separate outgoing accounting from display amounts, preserve known zero, and filter expired intents using scanned height before applying the history limit. Use guarded raw transaction value balances to establish the fee for confirmed restored accounting without assuming unrecovered outputs are padding. Preserve positive unknown-recipient remainders, reject conflicting stored accounting, and isolate stricter scope checks from the GUI's transfer splitting. Add deadline, accounting, expiry, fee, and encrypted-output regressions. Keep the legacy response strict when the outgoing total cannot be established. --- crates/pirate-storage-sqlite/src/models.rs | 16 + .../pirate-storage-sqlite/src/repository.rs | 400 ++++++++ crates/pirate-wallet-service/Cargo.toml | 1 + .../src/api/payment_disclosure.rs | 71 +- .../pirate-wallet-service/src/api/qortal.rs | 915 ++++++++++++++++-- docs/qortal-handoff.md | 68 +- 6 files changed, 1406 insertions(+), 65 deletions(-) diff --git a/crates/pirate-storage-sqlite/src/models.rs b/crates/pirate-storage-sqlite/src/models.rs index c655b628..96e14292 100644 --- a/crates/pirate-storage-sqlite/src/models.rs +++ b/crates/pirate-storage-sqlite/src/models.rs @@ -271,6 +271,22 @@ pub struct TransactionRecord { pub timestamp: i64, /// Net amount (positive for receive, negative for send) pub amount: i64, + /// Whether local spent notes or a stored send intent identify an outgoing leg. + pub has_outgoing: bool, + /// Locally established outgoing value excluding internal outputs and fee. + /// None means that chain confirmation, output scopes, or stored fee data + /// are insufficient; it must not be inferred from the display amount. + pub outgoing_value: Option, + /// Confirmed outgoing estimate using the default fee when no fee is stored. + /// Never a substitute for outgoing_value in an exact legacy response. + pub outgoing_value_estimate: Option, + /// Confirmed attributed spent minus internal receipts for restored history. + /// No intent is present; subtract a fee established from the raw transaction. + pub outgoing_before_fee: Option, + /// Confirmed local output attribution has no missing or dangling scope links. + pub outgoing_scope_known: bool, + /// A positive fee persisted with the transaction or its send intent. + pub stored_fee: Option, /// Transaction fee pub fee: u64, /// Memo (from first note with memo) diff --git a/crates/pirate-storage-sqlite/src/repository.rs b/crates/pirate-storage-sqlite/src/repository.rs index 79b8c7c8..9da62691 100644 --- a/crates/pirate-storage-sqlite/src/repository.rs +++ b/crates/pirate-storage-sqlite/src/repository.rs @@ -4531,6 +4531,7 @@ impl<'a> Repository<'a> { memo: Option>, saw_internal: bool, saw_unknown_scope: bool, + qortal_unknown_scope: bool, } impl TxAggregate { @@ -4547,6 +4548,7 @@ impl<'a> Repository<'a> { memo: None, saw_internal: false, saw_unknown_scope: false, + qortal_unknown_scope: false, } } } @@ -4645,6 +4647,9 @@ impl<'a> Repository<'a> { if address_id.is_none() { entry.saw_unknown_scope = true; } + if address_id.and_then(|id| address_scopes.get(&id)).is_none() { + entry.qortal_unknown_scope = true; + } if address_scope == crate::models::AddressScope::Internal { entry.saw_internal = true; } @@ -4808,6 +4813,56 @@ impl<'a> Repository<'a> { .saturating_sub(fee_i64); let outgoing_amount = entry.intent_amount.unwrap_or(chain_outgoing_amount); let has_outgoing = entry.sent > 0 || entry.intent_amount.is_some(); + // The display amount mixes intent totals (fee excluded) with net + // wallet changes (fee included). Keep the separate accounting value + // while the source information is still available. Unconfirmed + // change, unknown address scope, and inferred fees are insufficient + // to manufacture an exact outgoing metadata value. + let outgoing_value = if entry.height > 0 + && entry.sent > 0 + && !entry.qortal_unknown_scope + && stored_fee > 0 + { + entry + .sent + .checked_sub(entry.received_internal) + .and_then(|value| value.checked_sub(i64::try_from(stored_fee).ok()?)) + .and_then(|value| u64::try_from(value).ok()) + .filter(|value| { + entry + .intent_amount + .is_none_or(|intent| *value <= intent as u64) + }) + } else { + None + }; + // A restored wallet usually has no compact-block fee. Expose the + // conventional fee only as an estimate, never as an exact amount. + let outgoing_before_fee = if entry.height > 0 + && entry.sent > 0 + && !entry.qortal_unknown_scope + && entry.intent_amount.is_none() + { + entry + .sent + .checked_sub(entry.received_internal) + .and_then(|value| u64::try_from(value).ok()) + } else { + None + }; + let outgoing_value_estimate = if entry.height > 0 + && entry.sent > 0 + && !entry.qortal_unknown_scope + && stored_fee == 0 + { + entry + .sent + .checked_sub(entry.received_internal) + .and_then(|value| value.checked_sub(DEFAULT_FEE as i64)) + .and_then(|value| u64::try_from(value).ok()) + } else { + None + }; let expired = entry.height <= 0 && entry .intent_expiry_height @@ -4859,6 +4914,12 @@ impl<'a> Repository<'a> { height: entry.height, timestamp, amount: -outgoing_amount, + has_outgoing, + outgoing_value, + outgoing_value_estimate, + outgoing_before_fee, + outgoing_scope_known: entry.height > 0 && !entry.qortal_unknown_scope, + stored_fee: (stored_fee > 0).then_some(stored_fee), fee, memo: memo.clone(), expired, @@ -4869,6 +4930,12 @@ impl<'a> Repository<'a> { height: entry.height, timestamp, amount: entry.received_external, + has_outgoing: false, + outgoing_value: None, + outgoing_value_estimate: None, + outgoing_before_fee: None, + outgoing_scope_known: false, + stored_fee: None, fee: 0, memo, expired: false, @@ -4881,6 +4948,12 @@ impl<'a> Repository<'a> { height: entry.height, timestamp, amount: -transfer_amount, + has_outgoing, + outgoing_value, + outgoing_value_estimate, + outgoing_before_fee, + outgoing_scope_known: entry.height > 0 && !entry.qortal_unknown_scope, + stored_fee: (stored_fee > 0).then_some(stored_fee), fee, memo: memo.clone(), expired, @@ -4891,6 +4964,12 @@ impl<'a> Repository<'a> { height: entry.height, timestamp, amount: transfer_amount, + has_outgoing: false, + outgoing_value: None, + outgoing_value_estimate: None, + outgoing_before_fee: None, + outgoing_scope_known: false, + stored_fee: None, fee: 0, memo, expired: false, @@ -4902,6 +4981,12 @@ impl<'a> Repository<'a> { height: entry.height, timestamp, amount: -outgoing_amount, + has_outgoing, + outgoing_value, + outgoing_value_estimate, + outgoing_before_fee, + outgoing_scope_known: entry.height > 0 && !entry.qortal_unknown_scope, + stored_fee: (stored_fee > 0).then_some(stored_fee), fee, memo, expired, @@ -4913,6 +4998,12 @@ impl<'a> Repository<'a> { height: entry.height, timestamp, amount: net_amount, + has_outgoing, + outgoing_value, + outgoing_value_estimate, + outgoing_before_fee, + outgoing_scope_known: entry.height > 0 && !entry.qortal_unknown_scope, + stored_fee: (stored_fee > 0).then_some(stored_fee), fee, memo, expired: false, @@ -8076,6 +8167,315 @@ mod tests { assert_eq!(intents[0].amount, 250_000_000); } + #[test] + fn qortal_fallback_requires_confirmed_attributed_outgoing_accounting() { + let db = test_db(); + let repo = Repository::new(&db); + // scope: 0 = known, 1 = missing, 2 = dangling address link. + // net-zero is a synthetic sign-detection fixture, not a claim that + // these inputs/outputs form a conserved single-wallet transaction. + let cases = [ + ( + "intent", + 250_010_000, + 0, + 0, + Some(250_000_000), + true, + 0, + 10_000, + Some(250_000_000), + ), + ( + "historical", + 250_000_000, + 0, + 0, + None, + true, + 0, + 10_000, + Some(249_990_000), + ), + ( + "change", + 500_000_000, + 249_990_000, + 0, + Some(250_000_000), + true, + 0, + 10_000, + Some(250_000_000), + ), + ( + "self", + 250_010_000, + 0, + 250_000_000, + None, + true, + 0, + 10_000, + Some(250_000_000), + ), + ( + "internal", + 250_010_000, + 250_000_000, + 0, + Some(250_000_000), + true, + 0, + 10_000, + Some(0), + ), + ( + "split", + 500_000_000, + 249_990_000, + 100_000_000, + Some(250_000_000), + true, + 0, + 10_000, + Some(250_000_000), + ), + ( + "pending", + 500_000_000, + 0, + 0, + Some(250_000_000), + false, + 0, + 10_000, + None, + ), + ( + "unknown", + 500_000_000, + 249_990_000, + 0, + Some(250_000_000), + true, + 1, + 10_000, + None, + ), + ( + "dangling", + 500_000_000, + 249_990_000, + 0, + Some(250_000_000), + true, + 2, + 10_000, + None, + ), + ("fee-only", 10_000, 0, 0, None, true, 0, 10_000, Some(0)), + ("underflow", 5_000, 0, 0, None, true, 0, 10_000, None), + ("inferred-fee", 250_000_000, 0, 0, None, true, 0, 0, None), + ( + "custom-fee", + 250_000_000, + 0, + 0, + None, + true, + 0, + 20_000, + Some(249_980_000), + ), + ( + "dust-fee", + 250_000_000, + 0, + 0, + None, + true, + 0, + 10_001, + Some(249_989_999), + ), + ( + "pending-internal", + 250_010_000, + 250_000_000, + 0, + Some(250_000_000), + false, + 0, + 10_000, + None, + ), + ( + "reversed-intent", + 250_010_000, + 0, + 0, + Some(250_000_000), + true, + 0, + 10_000, + Some(250_000_000), + ), + ( + "net-zero", + 250_010_000, + 0, + 250_010_000, + None, + true, + 0, + 10_000, + Some(250_000_000), + ), + ]; + for (name, input, internal, external, intent, confirmed, scope, fee, expected) in cases { + let account_id = repo + .insert_account(&Account { + id: None, + name: name.into(), + created_at: 1, + }) + .unwrap(); + let mut spend_txid: Vec = (0..32).collect(); + spend_txid[0] = account_id as u8; + let txid = txid_hex_from_bytes(&spend_txid); + insert_received_note( + &repo, + account_id, + vec![128 + account_id as u8; 32], + NoteType::Ironwood, + 0, + input, + 100, + None, + None, + false, + 0x31, + ); + assert!(repo + .mark_note_spent_by_nullifier_with_txid(account_id, &[0x31; 32], &spend_txid) + .unwrap()); + for (index, value, address_scope) in [ + (0, internal, AddressScope::Internal), + (1, external, AddressScope::External), + ] { + if value == 0 { + continue; + } + let address = Address { + id: None, + key_id: None, + account_id, + diversifier_index: index, + diversifier_index_88: None, + address: format!("pirate1-{name}-{index}"), + address_type: AddressType::Ironwood, + label: None, + created_at: 1, + color_tag: ColorTag::None, + address_scope, + }; + repo.upsert_address(&address).unwrap(); + let id = repo + .get_address_by_string(account_id, &address.address) + .unwrap() + .unwrap() + .id; + let id = match scope { + 1 => None, + 2 => Some(i64::MAX), + _ => id, + }; + insert_received_note( + &repo, + account_id, + spend_txid.clone(), + NoteType::Ironwood, + i64::from(index), + value, + if confirmed { 101 } else { 0 }, + id, + None, + false, + 0x40 + index as u8, + ); + } + if let Some(amount) = intent { + let intent_txid = if name == "reversed-intent" { + reverse_txid_hex(&txid).unwrap() + } else { + txid.clone() + }; + repo.upsert_outgoing_transaction_intent( + account_id, + &intent_txid, + amount, + fee, + 2_100, + 0, + ) + .unwrap(); + } + repo.upsert_transaction( + &txid, + if confirmed { 101 } else { 0 }, + 2_100, + i64::try_from(fee).unwrap(), + ) + .unwrap(); + let rows = repo + .get_transactions_with_options(account_id, None, 101, 1, false) + .unwrap(); + let row = rows.iter().find(|row| row.txid == txid).unwrap(); + assert!(row.has_outgoing, "{name}"); + assert_eq!(row.outgoing_value, expected, "{name}"); + assert_eq!( + row.outgoing_before_fee, + if confirmed && scope == 0 && intent.is_none() { + u64::try_from(input - internal).ok() + } else { + None + }, + "{name}" + ); + assert_eq!(row.outgoing_scope_known, confirmed && scope == 0, "{name}"); + assert_eq!(row.stored_fee, (fee > 0).then_some(fee), "{name}"); + assert_eq!( + row.outgoing_value_estimate, + if name == "inferred-fee" { + Some(249_990_000) + } else { + None + }, + "{name}" + ); + assert_eq!( + rows.iter().filter(|row| row.txid == txid).count(), + 1, + "{name}" + ); + let incoming = rows.iter().find(|row| row.txid != txid).unwrap(); + assert!(!incoming.has_outgoing, "{name}"); + assert_eq!(incoming.outgoing_value, None, "{name}"); + if name == "net-zero" { + assert_eq!(row.amount, 0); + } + if name == "split" { + let split = repo + .get_transactions_with_options(account_id, None, 101, 1, true) + .unwrap(); + let split: Vec<_> = split.iter().filter(|row| row.txid == txid).collect(); + assert_eq!(split.len(), 2); + assert_eq!(split.iter().filter(|row| row.has_outgoing).count(), 1); + } + } + } + #[test] fn outgoing_intent_excludes_unseen_and_confirmed_change_from_send_amount() { let db = test_db(); diff --git a/crates/pirate-wallet-service/Cargo.toml b/crates/pirate-wallet-service/Cargo.toml index 5418582b..29a91317 100644 --- a/crates/pirate-wallet-service/Cargo.toml +++ b/crates/pirate-wallet-service/Cargo.toml @@ -43,6 +43,7 @@ zcash_transparent = { workspace = true } sapling = { workspace = true } [dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } incrementalmerkletree = { workspace = true } shardtree = { workspace = true } tempfile = { workspace = true } diff --git a/crates/pirate-wallet-service/src/api/payment_disclosure.rs b/crates/pirate-wallet-service/src/api/payment_disclosure.rs index cc17ae53..deaa4b59 100644 --- a/crates/pirate-wallet-service/src/api/payment_disclosure.rs +++ b/crates/pirate-wallet-service/src/api/payment_disclosure.rs @@ -698,10 +698,29 @@ async fn verify_payment_disclosure_inner( } #[cfg(test)] -mod persistence_tests { +pub(super) mod persistence_tests { use super::*; - fn encrypted_sapling_fixture(ovk: SaplingOutgoingViewingKey) -> (String, Vec) { + pub(in crate::api) fn encrypted_sapling_fixture( + ovk: SaplingOutgoingViewingKey, + ) -> (String, Vec) { + encrypted_sapling_fixture_with_transparent_output(ovk, false) + } + + pub(in crate::api) fn encrypted_sapling_fixture_with_transparent_output( + ovk: SaplingOutgoingViewingKey, + transparent_output: bool, + ) -> (String, Vec) { + encrypted_sapling_history_fixture(ovk, transparent_output, false, 0, None) + } + + pub(in crate::api) fn encrypted_sapling_history_fixture( + ovk: SaplingOutgoingViewingKey, + transparent_output: bool, + transparent_input: bool, + fee: i64, + extra_output: Option<(Option, u64)>, + ) -> (String, Vec) { use sapling::note_encryption::{sapling_note_encryption, SaplingDomain}; use sapling::value::{NoteValue, ValueCommitTrapdoor, ValueCommitment}; use zcash_primitives::transaction::{Authorized, TransactionData, TxVersion}; @@ -730,21 +749,63 @@ mod persistence_tests { encryption.encrypt_outgoing_plaintext(&cv, &cmu, &mut rng), [0u8; 192], ); + let mut outputs = vec![output]; + if let Some((extra_ovk, amount)) = extra_output { + let value = NoteValue::from_raw(amount); + let cv = ValueCommitment::derive(value, ValueCommitTrapdoor::random(&mut rng)); + let note = address.create_note( + value, + sapling::util::generate_random_rseed(enforcement, &mut rng), + ); + let cmu = note.cmu(); + let encryption = sapling_note_encryption(extra_ovk, note, [0; 512], &mut rng); + outputs.push(sapling::bundle::OutputDescription::from_parts( + cv.clone(), + cmu, + SaplingDomain::epk_bytes(encryption.epk()), + encryption.encrypt_note_plaintext(), + encryption.encrypt_outgoing_plaintext(&cv, &cmu, &mut rng), + [0u8; 192], + )); + } // Real note encryption, with dummy proof/signature bytes: never broadcast. let bundle = sapling::Bundle::from_parts( vec![], - vec![output], - ZatBalance::from_i64(0).unwrap(), + outputs, + ZatBalance::from_i64(fee).unwrap(), sapling::bundle::Authorized { binding_sig: [0u8; 64].into(), }, ); + let transparent = + (transparent_output || transparent_input).then(|| zcash_transparent::bundle::Bundle { + vin: if transparent_input { + vec![zcash_transparent::bundle::TxIn::from_parts( + zcash_transparent::bundle::OutPoint::new([1; 32], 0), + zcash_transparent::address::Script::default(), + u32::MAX, + )] + } else { + vec![] + }, + vout: if transparent_output { + vec![zcash_transparent::bundle::TxOut::new( + zcash_protocol::value::Zatoshis::from_u64(1).unwrap(), + zcash_transparent::address::TransparentAddress::ScriptHash([9; 20]) + .script() + .into(), + )] + } else { + vec![] + }, + authorization: zcash_transparent::bundle::Authorized, + }); let tx = TransactionData::::from_parts( TxVersion::V4, BranchId::Sapling, 0, BlockHeight::from_u32(1_000_020), - None, + transparent, None, bundle, None, diff --git a/crates/pirate-wallet-service/src/api/qortal.rs b/crates/pirate-wallet-service/src/api/qortal.rs index 9a0b7d4b..b4aafa31 100644 --- a/crates/pirate-wallet-service/src/api/qortal.rs +++ b/crates/pirate-wallet-service/src/api/qortal.rs @@ -174,6 +174,13 @@ pub async fn qortal_send(wallet_id: WalletId, request: QortalSendRequest) -> Res struct LocalQortalTransaction { transaction: QortalTransaction, should_recover_outgoing: bool, + fallback_outgoing_value: Option, + outgoing_value_estimate: Option, + outgoing_before_fee: Option, + outgoing_scope_known: bool, + stored_fee: Option, + metadata_complete: bool, + metadata_error: Option, } fn load_qortal_transactions( @@ -190,8 +197,23 @@ fn load_qortal_transactions( .ok_or_else(|| anyhow!("No wallet secret found for {}", wallet_id))?; let sync_state = pirate_storage_sqlite::SyncStateStorage::new(&db).load_sync_state()?; let current_height = sync_state.local_height.max(sync_state.target_height); + load_qortal_account_transactions( + &repo, + secret.account_id, + sync_state.local_height, + current_height, + limit, + ) +} - let addresses = repo.get_all_addresses(secret.account_id)?; +fn load_qortal_account_transactions( + repo: &pirate_storage_sqlite::Repository<'_>, + account_id: i64, + local_height: u64, + current_height: u64, + limit: Option, +) -> Result<(Vec, HashMap)> { + let addresses = repo.get_all_addresses(account_id)?; let addresses_by_id = addresses .iter() .filter_map(|address| address.id.map(|id| (id, address.clone()))) @@ -203,18 +225,24 @@ fn load_qortal_transactions( // Qortal expects exactly one entry per txid and separates change through // metadata arrays, so disable the GUI's split-transfer presentation. - let records = - repo.get_transactions_with_options(secret.account_id, limit, current_height, 1, false)?; + let records = repo.get_transactions_with_options(account_id, None, local_height, 1, false)?; let mut transactions = Vec::with_capacity(records.len()); - for record in records { + // Expired, unmined intents are not chain transactions, and the legacy + // schema has no failed-send state. They must not poison every later list. + // Only scanned height proves expiry; apply the caller's limit afterwards. + for record in records + .into_iter() + .filter(|record| !record.expired) + .take(limit.map(|limit| limit as usize).unwrap_or(usize::MAX)) + { let txid_bytes = hex::decode(&record.txid) .map_err(|err| anyhow!("Invalid stored transaction id: {}", err))?; let mut reversed_txid = txid_bytes.clone(); reversed_txid.reverse(); - let mut notes = repo.get_notes_by_txid(secret.account_id, &txid_bytes)?; + let mut notes = repo.get_notes_by_txid(account_id, &txid_bytes)?; if notes.is_empty() { - notes = repo.get_notes_by_txid(secret.account_id, &reversed_txid)?; + notes = repo.get_notes_by_txid(account_id, &reversed_txid)?; } let mut incoming_metadata = Vec::new(); @@ -254,7 +282,14 @@ fn load_qortal_transactions( let confirmed = record.height > 0 && current_height >= record.height as u64; let block_height = u32::try_from(record.height.max(0)).unwrap_or(u32::MAX); transactions.push(LocalQortalTransaction { - should_recover_outgoing: record.amount < 0, + should_recover_outgoing: record.has_outgoing, + fallback_outgoing_value: record.outgoing_value, + outgoing_value_estimate: record.outgoing_value_estimate, + outgoing_before_fee: record.outgoing_before_fee, + outgoing_scope_known: record.outgoing_scope_known, + stored_fee: record.stored_fee, + metadata_complete: !record.has_outgoing, + metadata_error: None, transaction: QortalTransaction { block_height, datetime: record.timestamp, @@ -273,11 +308,27 @@ fn load_qortal_transactions( Ok((transactions, scopes_by_address)) } +struct RecoveredRecipients { + fee: Option, + recipients: Vec, + complete: bool, +} + +impl From> for RecoveredRecipients { + fn from(recipients: Vec) -> Self { + Self { + fee: None, + recipients, + complete: false, + } + } +} + async fn recover_qortal_recipients( client: &LightClient, wallet_id: &WalletId, txid: &str, -) -> Result> { +) -> Result { let (_endpoint_config, tx_hash_candidates, sapling_ovks, orchard_ovks, tx_height_hint) = collect_tx_recovery_context(wallet_id, txid)?; @@ -285,14 +336,13 @@ async fn recover_qortal_recipients( for tx_hash in tx_hash_candidates { match client.get_transaction(&tx_hash).await { Ok(raw) => { - return Ok( - payment_disclosure::recover_outgoing_recipients_with_disclosures_from_raw_tx( - &raw, - tx_height_hint, - &sapling_ovks, - &orchard_ovks, - address_prefix_network_type(wallet_id)?, - ), + return recover_qortal_recipients_from_raw( + &raw, + txid, + tx_height_hint, + &sapling_ovks, + &orchard_ovks, + address_prefix_network_type(wallet_id)?, ); } Err(err) => last_error = Some(err.to_string()), @@ -306,12 +356,84 @@ async fn recover_qortal_recipients( )) } +fn recover_qortal_recipients_from_raw( + raw: &[u8], + txid: &str, + tx_height_hint: Option, + sapling_ovks: &[SaplingOutgoingViewingKey], + orchard_ovks: &[orchard::keys::OutgoingViewingKey], + network: NetworkType, +) -> Result { + let parsed = read_pirate_transaction(raw)?; + let raw_id = hex::encode(parsed.txid().as_ref()); + let reversed_id = hex::encode( + parsed + .txid() + .as_ref() + .iter() + .rev() + .copied() + .collect::>(), + ); + if txid != raw_id && txid != reversed_id { + return Err(anyhow!("Recovered transaction id does not match {}", txid)); + } + let recipients = payment_disclosure::recover_outgoing_recipients_with_disclosures_from_raw_tx( + raw, + tx_height_hint, + sapling_ovks, + orchard_ovks, + network, + ); + // A nonempty set can omit outputs (missing OVKs, encoding + // failures, dummy outputs). Only full coverage proves a total. + let outputs = parsed + .sapling_bundle() + .map_or(0, |bundle| bundle.shielded_outputs().len()) + + parsed + .ironwood_bundle() + .map_or(0, |bundle| bundle.actions().len()); + let no_transparent_outputs = parsed + .transparent_bundle() + .is_none_or(|bundle| bundle.vout.is_empty()); + let supported_pools = parsed.sprout_bundle().is_none() && parsed.orchard_bundle().is_none(); + let complete = + supported_pools && no_transparent_outputs && outputs > 0 && recipients.len() == outputs; + // Padding need not decrypt to determine a fee. For fully shielded supported + // transactions the public value balances establish it without prevouts. + // This proves only the fee, not input ownership or recipient completeness. + let fully_shielded = parsed + .transparent_bundle() + .is_none_or(|bundle| bundle.vin.is_empty() && bundle.vout.is_empty()); + let fee = if supported_pools && fully_shielded { + parsed + .fee_paid::(|_| Ok(None))? + .map(u64::from) + } else { + None + }; + Ok(RecoveredRecipients { + fee, + recipients, + complete, + }) +} + /// Return transaction history with the metadata arrays Qortal actually reads. pub async fn qortal_list_transactions( wallet_id: WalletId, limit: Option, ) -> Result> { + legacy_history(load_and_enrich_qortal_transactions(wallet_id, limit).await?) +} + +async fn load_and_enrich_qortal_transactions( + wallet_id: WalletId, + limit: Option, +) -> Result> { let (mut transactions, scopes_by_address) = load_qortal_transactions(&wallet_id, limit)?; + // Optional remote metadata must not monopolize the single JNI wallet lane. + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); let needs_outgoing_recovery = transactions .iter() .any(|entry| entry.should_recover_outgoing); @@ -319,17 +441,20 @@ pub async fn qortal_list_transactions( let endpoint_config = get_lightd_endpoint_config(wallet_id.clone())?; let client_config = tunnel::light_client_config_for_endpoint( &endpoint_config, - RetryConfig::default(), + RetryConfig { + max_attempts: 1, + ..RetryConfig::default() + }, Duration::from_secs(30), Duration::from_secs(60), ); let client = LightClient::with_config(client_config); - match client.connect().await { - Ok(()) => Some(client), - Err(err) => { + match tokio::time::timeout_at(deadline, client.connect()).await { + Ok(Ok(())) => Some(client), + outcome => { tracing::warn!( - "Could not connect for Qortal transaction metadata recovery: {}", - err + "Could not connect for Qortal transaction metadata recovery: {:?}", + outcome ); None } @@ -338,19 +463,73 @@ pub async fn qortal_list_transactions( None }; - for entry in &mut transactions { + enrich_qortal_transactions(&mut transactions, &scopes_by_address, deadline, |txid| { + let client = recovery_client.as_ref(); + let wallet_id = &wallet_id; + async move { + match client { + Some(client) => recover_qortal_recipients(client, wallet_id, &txid).await, + None => Err(anyhow!("lightwalletd is unavailable")), + } + } + }) + .await?; + + Ok(transactions) +} + +fn legacy_history(rows: Vec) -> Result> { + if let Some(row) = rows.iter().find(|row| !row.metadata_complete) { + return Err(anyhow!( + "Qortal transaction metadata unavailable for {}", + row.transaction.txid + )); + } + Ok(rows.into_iter().map(|row| row.transaction).collect()) +} + +// One deadline covers connection and every transaction, not one timeout per row. +// Dropping a timed-out read cancels its future; no detached recovery keeps the +// native lane occupied after history has been returned. +async fn enrich_qortal_transactions( + transactions: &mut [LocalQortalTransaction], + scopes_by_address: &HashMap, + deadline: tokio::time::Instant, + mut recover: F, +) -> Result<()> +where + F: FnMut(String) -> Fut, + Fut: std::future::Future>, +{ + for entry in transactions { if !entry.should_recover_outgoing { continue; } - let recovered = match recovery_client.as_ref() { - Some(client) => { - recover_qortal_recipients(client, &wallet_id, &entry.transaction.txid).await + let recovered = if tokio::time::Instant::now() >= deadline { + Err(anyhow!("Qortal history metadata recovery budget exhausted")) + } else { + match tokio::time::timeout_at(deadline, recover(entry.transaction.txid.clone())).await { + Ok(result) => result, + Err(_) => Err(anyhow!("Qortal history metadata recovery budget exhausted")), } - None => Err(anyhow!("lightwalletd is unavailable")), }; + if let Ok(recovery) = &recovered { + if let Some(fee) = recovery.fee { + if entry.stored_fee.is_some_and(|stored| stored != fee) { + tracing::warn!(txid = %entry.transaction.txid, "Raw fee differs from stored history fee"); + entry.fallback_outgoing_value = None; + } + entry.stored_fee = Some(fee); + entry.transaction.fee = fee; + entry.outgoing_value_estimate = None; + if let Some(before_fee) = entry.outgoing_before_fee { + entry.fallback_outgoing_value = before_fee.checked_sub(fee); + } + } + } match recovered { - Ok(recipients) => { - for recipient in recipients { + Ok(recovery) if !recovery.recipients.is_empty() => { + for recipient in recovery.recipients { let metadata = QortalTxMetadata { address: recipient.address.clone(), value: recipient.amount, @@ -362,45 +541,669 @@ pub async fn qortal_list_transactions( entry.transaction.outgoing_metadata.push(metadata); } } + let Some(recovered_value) = entry + .transaction + .outgoing_metadata + .iter() + .try_fold(0u64, |total, metadata| total.checked_add(metadata.value)) + else { + entry.fallback_outgoing_value = None; + entry.outgoing_value_estimate = None; + entry.metadata_error = Some(format!( + "Outgoing metadata overflow for {}", + entry.transaction.txid + )); + continue; + }; + if entry + .outgoing_value_estimate + .is_some_and(|estimate| recovered_value > estimate) + { + entry.outgoing_value_estimate = None; + } + if recovery.complete && entry.outgoing_scope_known { + // Decrypted full coverage outranks local fee/accounting assumptions. + if entry + .fallback_outgoing_value + .is_some_and(|local| local != recovered_value) + { + tracing::warn!(txid = %entry.transaction.txid, "Recovered recipients differ from local history accounting"); + } + entry.fallback_outgoing_value = Some(recovered_value); + entry.metadata_complete = true; + } else if let Some(local) = entry.fallback_outgoing_value { + if let Some(remainder) = local.checked_sub(recovered_value) { + if remainder > 0 { + entry.transaction.outgoing_metadata.push(QortalTxMetadata { + address: "[UNKNOWN]".into(), + value: remainder, + memo: None, + }); + } + entry.metadata_complete = true; + } else { + // Keep individually recovered outputs, but do not treat + // their possibly partial sum or the contradicted local value as a total. + tracing::warn!(txid = %entry.transaction.txid, "Partial recovered recipients exceed local history accounting"); + entry.fallback_outgoing_value = None; + entry.outgoing_value_estimate = None; + } + } + if entry.metadata_complete && entry.transaction.outgoing_metadata.is_empty() { + entry.transaction.outgoing_metadata.push(QortalTxMetadata { + address: "[UNKNOWN]".into(), + value: 0, + memo: None, + }); + } + if !entry.metadata_complete { + entry.metadata_error = Some(format!( + "Outgoing metadata incomplete for {}", + entry.transaction.txid + )); + } + continue; } + Ok(_) => {} Err(err) => { - tracing::warn!( - "Could not recover Qortal metadata for transaction {}: {}", - entry.transaction.txid, - err - ); + tracing::warn!(txid = %entry.transaction.txid, "Could not recover Qortal metadata: {}", err); } } - - if entry.transaction.outgoing_metadata.is_empty() { - let external_value = entry - .transaction - .amount - .unsigned_abs() - .saturating_sub(entry.transaction.fee); - if external_value > 0 { - // Qortal Core calculates transaction values from metadata and - // ignores the top-level amount. Preserve the correct value when - // a historical raw transaction is temporarily unavailable. - entry.transaction.outgoing_metadata.push(QortalTxMetadata { - address: "[UNKNOWN]".to_string(), - value: external_value, - memo: None, - }); - } + if let Some(value) = entry.fallback_outgoing_value { + entry.transaction.outgoing_metadata.push(QortalTxMetadata { + address: "[UNKNOWN]".into(), + value, + memo: None, + }); + entry.metadata_complete = true; + } else { + entry.metadata_error = Some(format!( + "Outgoing metadata unavailable for {}", + entry.transaction.txid + )); } } - - Ok(transactions - .into_iter() - .map(|entry| entry.transaction) - .collect()) + Ok(()) } #[cfg(test)] mod tests { use super::*; + fn outgoing_row() -> LocalQortalTransaction { + LocalQortalTransaction { + should_recover_outgoing: true, + fallback_outgoing_value: Some(100), + outgoing_value_estimate: None, + outgoing_before_fee: None, + outgoing_scope_known: true, + stored_fee: Some(10), + metadata_complete: false, + metadata_error: None, + transaction: QortalTransaction { + block_height: 42, + datetime: 1_700_000_000, + txid: "00".repeat(32), + amount: -110, + fee: 10, + incoming_metadata: Vec::new(), + incoming_metadata_change: Vec::new(), + outgoing_metadata: Vec::new(), + outgoing_metadata_change: Vec::new(), + unconfirmed: None, + }, + } + } + + #[tokio::test(start_paused = true)] + async fn history_recovery_shares_one_budget_and_keeps_every_local_row() { + let mut rows = vec![outgoing_row(), outgoing_row(), outgoing_row()]; + let start = tokio::time::Instant::now(); + let mut attempts = 0; + enrich_qortal_transactions( + &mut rows, + &HashMap::new(), + start + Duration::from_secs(5), + |_| { + attempts += 1; + async { + tokio::time::sleep(Duration::from_secs(3)).await; + Ok(vec![TransactionRecipient { + address: "zs-known".into(), + pool: "sapling".into(), + amount: 100, + output_index: 0, + memo: None, + payment_disclosure: None, + }] + .into()) + } + }, + ) + .await + .unwrap(); + assert_eq!(attempts, 2); + assert_eq!(tokio::time::Instant::now() - start, Duration::from_secs(5)); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].transaction.outgoing_metadata[0].address, "zs-known"); + for row in &rows[1..] { + assert_eq!(row.transaction.outgoing_metadata[0].address, "[UNKNOWN]"); + assert_eq!(row.transaction.outgoing_metadata[0].value, 100); + assert_eq!(row.transaction.amount, -110); + assert_eq!(row.transaction.fee, 10); + } + } + + #[tokio::test(start_paused = true)] + async fn stalled_metadata_is_cancelled_and_expired_budget_skips_requests() { + let mut rows = vec![outgoing_row(), outgoing_row()]; + let dropped = std::rc::Rc::new(std::cell::Cell::new(false)); + struct Guard(std::rc::Rc>); + impl Drop for Guard { + fn drop(&mut self) { + self.0.set(true); + } + } + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + enrich_qortal_transactions(&mut rows, &HashMap::new(), deadline, |_| { + let guard = Guard(dropped.clone()); + async move { + let _guard = guard; + std::future::pending().await + } + }) + .await + .unwrap(); + assert!(dropped.get()); + assert!(rows + .iter() + .all(|row| row.transaction.outgoing_metadata[0].value == 100)); + let mut rows = vec![outgoing_row()]; + enrich_qortal_transactions(&mut rows, &HashMap::new(), deadline, |_| async { + panic!("Expired connection budget must not start any transaction lookup") + }) + .await + .unwrap(); + assert_eq!( + rows[0].transaction.outgoing_metadata[0].address, + "[UNKNOWN]" + ); + } + + #[tokio::test(start_paused = true)] + async fn expired_intents_do_not_poison_history_or_consume_its_limit() { + use pirate_storage_sqlite::{ + Account, Database, EncryptionAlgorithm, EncryptionKey, MasterKey, NoteRecord, NoteType, + Repository, + }; + let directory = tempfile::tempdir().unwrap(); + let salt = pirate_storage_sqlite::generate_salt(); + let key = EncryptionKey::from_passphrase("test-passphrase", &salt).unwrap(); + let db = Database::open( + directory.path().join("history.db"), + &key, + MasterKey::generate(EncryptionAlgorithm::ChaCha20Poly1305), + ) + .unwrap(); + let repo = Repository::new(&db); + let account_id = repo + .insert_account(&Account { + id: None, + name: "history".into(), + created_at: 1, + }) + .unwrap(); + let receive_txid = vec![0x11; 32]; + repo.insert_note(&NoteRecord { + id: None, + account_id, + key_id: None, + note_type: NoteType::Ironwood, + value: 100, + nullifier: vec![0x12; 32], + commitment: vec![0x13; 32], + spent: false, + height: 100, + txid: receive_txid.clone(), + output_index: 0, + address_id: None, + spent_txid: None, + diversifier: None, + note: None, + position: None, + memo: None, + }) + .unwrap(); + repo.upsert_transaction(&hex::encode(&receive_txid), 100, 1, 0) + .unwrap(); + repo.upsert_outgoing_transaction_intent(account_id, &"55".repeat(32), 50, 10, 2, 120) + .unwrap(); + // An advertised target past expiry is insufficient: retain the pending intent. + let (before, _) = + load_qortal_account_transactions(&repo, account_id, 100, 1000, None).unwrap(); + assert_eq!(before.len(), 2); + assert!(before.iter().any(|row| row.should_recover_outgoing)); + // At the expiry boundary it is still pending. + assert_eq!( + load_qortal_account_transactions(&repo, account_id, 120, 1000, None) + .unwrap() + .0 + .len(), + 2 + ); + let (mut after, scopes) = + load_qortal_account_transactions(&repo, account_id, 121, 1000, Some(1)).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].transaction.txid, hex::encode(receive_txid)); + enrich_qortal_transactions( + &mut after, + &scopes, + tokio::time::Instant::now(), + |_| async { panic!("expired intent must not trigger remote recovery") }, + ) + .await + .unwrap(); + } + + #[tokio::test(start_paused = true)] + async fn fallback_uses_accounting_value_not_display_amount_or_fee() { + for (display, fee, exact) in [ + (-250_000_000, 10_000, 250_000_000), + (-250_000_000, 10_000, 249_990_000), + (-10_000, 10_000, 250_000_000), + (0, 10_000, 0), + (100, 10_000, 250_000_000), + ] { + let mut row = outgoing_row(); + row.transaction.amount = display; + row.transaction.fee = fee; + row.fallback_outgoing_value = Some(exact); + let mut rows = vec![row]; + enrich_qortal_transactions( + &mut rows, + &HashMap::new(), + tokio::time::Instant::now(), + |_| async { panic!("expired budget must not start recovery") }, + ) + .await + .unwrap(); + assert_eq!(rows[0].transaction.outgoing_metadata.len(), 1); + assert_eq!(rows[0].transaction.outgoing_metadata[0].value, exact); + assert_eq!(rows[0].transaction.fee, fee); + } + } + + #[tokio::test(start_paused = true)] + async fn unknown_value_fails_within_shared_budget_instead_of_becoming_zero() { + let mut row = outgoing_row(); + row.fallback_outgoing_value = None; + let mut rows = vec![row]; + let start = tokio::time::Instant::now(); + enrich_qortal_transactions( + &mut rows, + &HashMap::new(), + start + Duration::from_secs(5), + |_| std::future::pending::>(), + ) + .await + .unwrap(); + assert!(!rows[0].metadata_complete); + assert!(rows[0] + .metadata_error + .as_ref() + .unwrap() + .contains(&rows[0].transaction.txid)); + assert_eq!(tokio::time::Instant::now() - start, Duration::from_secs(5)); + assert!(rows[0].transaction.outgoing_metadata.is_empty()); + let error = legacy_history(rows).unwrap_err(); + assert!(error.to_string().contains(&"00".repeat(32))); + } + + #[tokio::test(start_paused = true)] + async fn recovered_internal_only_transfer_does_not_use_intent_total() { + let mut row = outgoing_row(); + row.fallback_outgoing_value = None; + let mut rows = vec![row]; + let scopes = HashMap::from([("zs-internal".to_string(), AddressScope::Internal)]); + enrich_qortal_transactions( + &mut rows, + &scopes, + tokio::time::Instant::now() + Duration::from_secs(5), + |_| async { + Ok(RecoveredRecipients { + fee: None, + complete: true, + recipients: vec![TransactionRecipient { + address: "zs-internal".into(), + pool: "sapling".into(), + amount: 100, + output_index: 0, + memo: None, + payment_disclosure: None, + }], + }) + }, + ) + .await + .unwrap(); + assert_eq!(rows[0].transaction.outgoing_metadata_change[0].value, 100); + assert_eq!(rows[0].transaction.outgoing_metadata.len(), 1); + assert_eq!(rows[0].transaction.outgoing_metadata[0].value, 0); + } + + #[tokio::test(start_paused = true)] + async fn partial_recovery_is_reconciled_with_local_accounting() { + for value in [40, 100, 101] { + let mut rows = vec![outgoing_row()]; + let result = enrich_qortal_transactions( + &mut rows, + &HashMap::new(), + tokio::time::Instant::now() + Duration::from_secs(5), + |_| async { + Ok(vec![TransactionRecipient { + address: "zs-external".into(), + pool: "sapling".into(), + amount: value, + output_index: 0, + memo: None, + payment_disclosure: None, + }] + .into()) + }, + ) + .await; + if value > 100 { + result.unwrap(); + assert!(!rows[0].metadata_complete); + assert_eq!(rows[0].transaction.outgoing_metadata[0].value, 101); + assert_eq!(rows[0].fallback_outgoing_value, None); + assert!(legacy_history(rows).is_err()); + } else { + result.unwrap(); + assert_eq!( + rows[0] + .transaction + .outgoing_metadata + .iter() + .map(|row| row.value) + .sum::(), + 100 + ); + if value < 100 { + assert_eq!( + rows[0].transaction.outgoing_metadata[1].address, + "[UNKNOWN]" + ); + assert_eq!(rows[0].transaction.outgoing_metadata[1].value, 60); + } + } + } + } + + #[tokio::test(start_paused = true)] + async fn incoming_only_history_never_recovers_outgoing_metadata() { + let mut row = outgoing_row(); + row.should_recover_outgoing = false; + row.metadata_complete = true; + row.fallback_outgoing_value = None; + let mut rows = vec![row]; + enrich_qortal_transactions( + &mut rows, + &HashMap::new(), + tokio::time::Instant::now(), + |_| async { panic!("incoming row must not query the network") }, + ) + .await + .unwrap(); + assert!(rows[0].transaction.outgoing_metadata.is_empty()); + } + + #[test] + fn raw_recovery_verifies_identity_and_requires_all_outputs() { + let ovk = SaplingOutgoingViewingKey([77; 32]); + let (txid, raw) = payment_disclosure::persistence_tests::encrypted_sapling_fixture(ovk); + let recovered = recover_qortal_recipients_from_raw( + &raw, + &txid, + Some(1_000_000), + &[ovk], + &[], + NetworkType::Mainnet, + ) + .unwrap(); + assert!(recovered.complete); + assert_eq!(recovered.recipients.len(), 1); + assert_eq!(recovered.recipients[0].amount, 100); + let (mixed_txid, mixed_raw) = payment_disclosure::persistence_tests::encrypted_sapling_fixture_with_transparent_output(ovk, true); + let mixed = recover_qortal_recipients_from_raw( + &mixed_raw, + &mixed_txid, + Some(1_000_000), + &[ovk], + &[], + NetworkType::Mainnet, + ) + .unwrap(); + assert_eq!(mixed.recipients.len(), 1); + assert!(!mixed.complete); + let missing_keys = recover_qortal_recipients_from_raw( + &raw, + &txid, + Some(1_000_000), + &[SaplingOutgoingViewingKey([78; 32])], + &[], + NetworkType::Mainnet, + ) + .unwrap(); + assert!(!missing_keys.complete); + assert!(missing_keys.recipients.is_empty()); + assert!(recover_qortal_recipients_from_raw( + &raw, + &"00".repeat(32), + Some(1_000_000), + &[ovk], + &[], + NetworkType::Mainnet + ) + .is_err()); + } + + #[tokio::test(start_paused = true)] + async fn padded_restored_history_uses_raw_fee_without_guessing_missing_outputs() { + use payment_disclosure::persistence_tests::encrypted_sapling_history_fixture; + let ovk = SaplingOutgoingViewingKey([77; 32]); + // Zero-valued unrecoverable padding and a genuinely missing positive + // recipient must remain distinguishable through local accounting. + for (hidden, fee) in [(0, 10), (50, 10), (0, 11), (0, 20)] { + let (txid, raw) = + encrypted_sapling_history_fixture(ovk, false, false, fee, Some((None, hidden))); + let mut row = outgoing_row(); + row.transaction.txid = txid.clone(); + row.fallback_outgoing_value = None; + row.stored_fee = None; + row.outgoing_before_fee = Some(100 + hidden + fee as u64); + let mut rows = vec![row]; + enrich_qortal_transactions( + &mut rows, + &HashMap::new(), + tokio::time::Instant::now() + Duration::from_secs(5), + |_| async { + let recovered = recover_qortal_recipients_from_raw( + &raw, + &txid, + Some(1_000_000), + &[ovk], + &[], + NetworkType::Mainnet, + )?; + assert!(!recovered.complete); + assert_eq!(recovered.recipients.len(), 1); + Ok(recovered) + }, + ) + .await + .unwrap(); + assert!(rows[0].metadata_complete); + assert_eq!(rows[0].fallback_outgoing_value, Some(100 + hidden)); + assert_eq!(rows[0].stored_fee, Some(fee as u64)); + let legacy = legacy_history(rows).unwrap(); + assert_eq!( + legacy[0] + .outgoing_metadata + .iter() + .map(|item| item.value) + .sum::(), + 100 + hidden + ); + if hidden > 0 { + assert_eq!(legacy[0].outgoing_metadata[1].address, "[UNKNOWN]"); + } + } + } + + #[tokio::test(start_paused = true)] + async fn raw_fee_supersedes_conflicting_stored_fee_without_reusing_its_total() { + for before_fee in [None, Some(120)] { + let mut row = outgoing_row(); + row.outgoing_before_fee = before_fee; + let mut rows = vec![row]; + enrich_qortal_transactions( + &mut rows, + &HashMap::new(), + tokio::time::Instant::now() + Duration::from_secs(5), + |_| async { + Ok(RecoveredRecipients { + fee: Some(20), + complete: false, + recipients: vec![], + }) + }, + ) + .await + .unwrap(); + assert_eq!(rows[0].stored_fee, Some(20)); + assert_eq!(rows[0].transaction.fee, 20); + assert_eq!( + rows[0].fallback_outgoing_value, + before_fee.map(|value| value - 20) + ); + assert_eq!(rows[0].metadata_complete, before_fee.is_some()); + } + } + + #[test] + fn raw_fee_requires_no_transparent_inputs_and_valid_value_balance() { + use payment_disclosure::persistence_tests::encrypted_sapling_history_fixture; + let ovk = SaplingOutgoingViewingKey([77; 32]); + for (vin, vout) in [(true, false), (false, true)] { + let (txid, raw) = encrypted_sapling_history_fixture(ovk, vout, vin, 10, None); + let recovered = recover_qortal_recipients_from_raw( + &raw, + &txid, + Some(1_000_000), + &[ovk], + &[], + NetworkType::Mainnet, + ) + .unwrap(); + assert_eq!(recovered.fee, None); + } + let (txid, raw) = encrypted_sapling_history_fixture(ovk, false, false, -1, None); + assert!(recover_qortal_recipients_from_raw( + &raw, + &txid, + Some(1_000_000), + &[ovk], + &[], + NetworkType::Mainnet + ) + .is_err()); + } + + #[test] + fn ironwood_builder_padding_is_not_claimed_as_complete_recovery() { + use orchard::{ + builder::{Builder, BundleType}, + bundle::BundleVersion, + value::NoteValue, + Anchor, + }; + use zcash_primitives::transaction::{Authorized, TransactionData}; + use zcash_protocol::{ + consensus::{BlockHeight, BranchId}, + value::ZatBalance, + }; + let key = pirate_core::keys::IronwoodExtendedSpendingKey::master(&[8; 32]).unwrap(); + let recipient = key.to_extended_fvk().address_at(0); + let ovk = orchard::keys::OutgoingViewingKey::from([77; 32]); + let version = BundleVersion::ironwood_v3(); + let mut builder = Builder::new( + BundleType::DEFAULT, + version, + version.default_flags(), + Anchor::empty_tree(), + ) + .unwrap(); + builder + .add_output( + Some(ovk.clone()), + recipient.inner, + NoteValue::from_raw(40_000), + [0; 512], + ) + .unwrap(); + let (bundle, _) = builder + .build::(&mut rand::rngs::OsRng) + .unwrap() + .unwrap(); + assert!(bundle.actions().len() > 1); + let signed = bundle + .create_proof( + &pirate_core::ironwood_params().proving_key, + &mut rand::rngs::OsRng, + ) + .unwrap() + .apply_signatures(rand::rngs::OsRng, [0; 32], &[]) + .unwrap(); + // Real builder padding/encryption/proof; synthetic authorization/prevout, + // never a transaction to broadcast. Transparent input prevents fee inference. + let transparent = zcash_transparent::bundle::Bundle { + vin: vec![zcash_transparent::bundle::TxIn::from_parts( + zcash_transparent::bundle::OutPoint::new([1; 32], 0), + zcash_transparent::address::Script::default(), + u32::MAX, + )], + vout: vec![], + authorization: zcash_transparent::bundle::Authorized, + }; + let tx = TransactionData::::from_parts_v6( + BranchId::Nu6_3, + 0, + BlockHeight::from_u32(5_000_000), + Some(transparent), + None, + None, + Some(signed), + ) + .freeze() + .unwrap(); + let mut raw = Vec::new(); + tx.write(&mut raw).unwrap(); + let recovery = recover_qortal_recipients_from_raw( + &raw, + &tx.txid().to_string(), + Some(4_000_000), + &[], + &[ovk], + NetworkType::Mainnet, + ) + .unwrap(); + assert_eq!(recovery.recipients.len(), 1); + assert_eq!(recovery.recipients[0].amount, 40_000); + assert!(!recovery.complete); + assert_eq!(recovery.fee, None); + } + #[test] fn qortal_transaction_omits_unconfirmed_when_confirmed() { let transaction = QortalTransaction { diff --git a/docs/qortal-handoff.md b/docs/qortal-handoff.md index 5a7622b2..e4f905c9 100644 --- a/docs/qortal-handoff.md +++ b/docs/qortal-handoff.md @@ -136,10 +136,34 @@ still sees the wallet as behind. The JNI adapter uses direct transport to match the legacy embedded wallet's network behavior. `list` constructs incoming metadata from the encrypted note database and -recovers outgoing Sapling and Ironwood recipients from the raw transaction. If a -historical raw transaction is temporarily unavailable, the response emits one -`[UNKNOWN]` recipient with the correct external value so Qortal does not turn an -outgoing transaction into a zero-value transaction. +recovers outgoing Sapling and Ironwood recipients from the raw transaction. If +a historical raw transaction is temporarily unavailable, the response emits +one `[UNKNOWN]` recipient only when confirmed local accounting establishes its +non-internal outgoing value: spent inputs minus internal received outputs and +a stored fee or a fee established from the raw transaction. When local +accounting is available, a partially recovered recipient list is completed +with an unknown-recipient remainder. Decrypted recipients take precedence over +local accounting when recovery covers every shielded output and there are no +transparent outputs and confirmed local output scopes are known. Otherwise, a +recovered subtotal exceeding local accounting leaves the total unknown rather +than rejecting the recovered recipients. The display amount is not used for +this calculation because stored intents exclude fees while historical net +amounts include them. + +Unconfirmed change, missing address scope (including dangling address links), +inferred fees, or invalid arithmetic leave that value unknown. If remote +recovery also fails, `list` returns a metadata-unavailable error naming the +txid rather than fabricating an amount. This error affects the history +request; it does not require stopping the wallet. A known zero outgoing value +is represented by a zero-valued metadata item so legacy consumers can still +account for the fee. Outgoing detection uses spent notes or stored intent, not +the net amount sign, so self-transfers are eligible for recovery too. Expired +unmined intents are omitted because the legacy schema has no failed-send +state. Only the locally scanned height proves expiry; an advertised server +target does not. The history limit applies after this filtering. Consumers +should use the top-level `fee` once per transaction, not infer one fee per +recipient metadata item; a single transaction can have several recipients or a +recovered/unknown remainder. P2SH redemption verifies that the input is P2SH, the redeem script hashes to that address, and funding output zero pays the same address. It rejects a @@ -251,3 +275,39 @@ This operation is the native prerequisite for importing external Pirate wallet exports into Qortal's encrypted SQLite wallet. File parsing and user-facing format selection remain Qortal-side follow-up work. Viewing-key recovery is not covered by this request. + +## Transaction history recovery deadline + +Qortal transaction history loads stored rows first. Recipient/memo recovery is +optional and has one five-second deadline covering connection and all outgoing +transactions together, with one network attempt per candidate transaction hash +(the compatibility lookup can try both byte orders). If that budget +expires, the request retains completed metadata and uses locally validated +`[UNKNOWN]` outgoing values where available. If any unresolved outgoing value +cannot be established locally, the whole history request returns a bounded +metadata-unavailable error instead of a partial or misleading transaction list. +Completed rows are returned only when the whole history request succeeds. +The recovery future is cancelled directly, not detached. This keeps optional +lightwalletd reads from exhausting Core's native-operation timeout and blocking +subsequent balance/status requests. The bound covers remote enrichment, not +synchronous local database loading or transaction decoding. + +## Padded outputs and fee recovery + +Sapling and Ironwood builders can pad transactions with zero-valued outputs +that do not decrypt with the wallet's outgoing viewing keys. Therefore a +recovered-recipient count below the raw output count does not prove that a +real recipient is missing. It also does not prove the converse: a nonempty +recovery can still omit a positive-valued output. Neither case is inferred +from decryption failure alone. + +For Sapling/Ironwood transactions with no transparent inputs or outputs and +no unsupported pool components, the parsed transaction's public value balances +establish the fee. The transaction id is checked before using those balances; +negative or overflowing fees are rejected. For confirmed, locally attributed +history without a send intent, that fee can complete the spent-input-minus- +internal-receipt accounting even when padding does not decrypt. An unrecovered +positive remainder remains an unknown-recipient item, not discarded padding. +This also handles custom fees and dust added to a fee without assuming the +default fee is exact. A raw fee alone proves neither input ownership nor change +scope; the local accounting prerequisites still apply. From 6b19fb18cd949ada5c8aeeebcd412cfb2448eed0 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Tue, 22 Sep 2026 12:28:50 -0400 Subject: [PATCH 2/2] feat(qortal): expose explicit partial transaction history Add qortal_list_transactions_partial through the existing JSON bridge. Retain incomplete rows with null totals, separate estimates, pending state, available recipient metadata, and transaction-scoped diagnostics. Existing list consumers keep their strict schema and must explicitly adopt the new response contract. Document consumer requirements and add serialization, mixed-history, pending, and scope regressions. Exercise cross-pool fee extraction with actual Ironwood builder padding. No generated bindings or storage migration are required. --- crates/pirate-wallet-service/src/api.rs | 3 +- .../pirate-wallet-service/src/api/qortal.rs | 275 +++++++++++++++++- crates/pirate-wallet-service/src/models.rs | 40 +++ crates/pirate-wallet-service/src/service.rs | 7 + docs/qortal-handoff.md | 69 ++++- 5 files changed, 383 insertions(+), 11 deletions(-) diff --git a/crates/pirate-wallet-service/src/api.rs b/crates/pirate-wallet-service/src/api.rs index 50647494..1b0763eb 100644 --- a/crates/pirate-wallet-service/src/api.rs +++ b/crates/pirate-wallet-service/src/api.rs @@ -98,7 +98,8 @@ pub use self::payment_disclosure::{ export_sapling_payment_disclosure, verify_payment_disclosure, }; pub use self::qortal::{ - qortal_balance, qortal_list_transactions, qortal_send, qortal_sync_status, QortalSendRequest, + qortal_balance, qortal_list_transactions, qortal_list_transactions_partial, qortal_send, + qortal_sync_status, QortalSendRequest, }; pub use self::qortal_p2sh::{QortalP2shRedeemRequest, QortalP2shSendRequest}; pub use self::seed_export::SeedExportWarnings; diff --git a/crates/pirate-wallet-service/src/api/qortal.rs b/crates/pirate-wallet-service/src/api/qortal.rs index b4aafa31..f7fc640a 100644 --- a/crates/pirate-wallet-service/src/api/qortal.rs +++ b/crates/pirate-wallet-service/src/api/qortal.rs @@ -1,5 +1,8 @@ use super::*; -use crate::models::{QortalSyncStatus, QortalTransaction, QortalTxMetadata}; +use crate::models::{ + QortalPartialHistory, QortalPartialTransaction, QortalSyncStatus, QortalTransaction, + QortalTxMetadata, +}; use pirate_storage_sqlite::AddressScope; use serde_json::{json, Value}; use std::collections::HashMap; @@ -427,6 +430,16 @@ pub async fn qortal_list_transactions( legacy_history(load_and_enrich_qortal_transactions(wallet_id, limit).await?) } +/// Explicit opt-in: retain incomplete rows and separate unknown totals from estimates. +pub async fn qortal_list_transactions_partial( + wallet_id: WalletId, + limit: Option, +) -> Result { + Ok(partial_history( + load_and_enrich_qortal_transactions(wallet_id, limit).await?, + )) +} + async fn load_and_enrich_qortal_transactions( wallet_id: WalletId, limit: Option, @@ -480,14 +493,51 @@ async fn load_and_enrich_qortal_transactions( fn legacy_history(rows: Vec) -> Result> { if let Some(row) = rows.iter().find(|row| !row.metadata_complete) { - return Err(anyhow!( - "Qortal transaction metadata unavailable for {}", - row.transaction.txid - )); + return Err(anyhow!("Qortal transaction metadata unavailable for {}: use the opt-in partial history API to inspect incomplete rows", row.transaction.txid)); } Ok(rows.into_iter().map(|row| row.transaction).collect()) } +fn partial_history(rows: Vec) -> QortalPartialHistory { + QortalPartialHistory { + transactions: rows + .into_iter() + .map(|row| { + let outgoing_value = if !row.should_recover_outgoing { + Some(0) + } else if row.metadata_complete { + row.fallback_outgoing_value + } else { + None + }; + let tx = row.transaction; + QortalPartialTransaction { + txid: tx.txid, + block_height: tx.block_height, + datetime: tx.datetime, + unconfirmed: tx.unconfirmed.unwrap_or(false), + has_outgoing: row.should_recover_outgoing, + outgoing_value, + outgoing_value_estimate: if outgoing_value.is_none() { + row.outgoing_value_estimate + } else { + None + }, + fee: row.stored_fee, + fee_estimate: (row.should_recover_outgoing && row.stored_fee.is_none()) + .then_some(pirate_core::fees::DEFAULT_FEE), + incoming_metadata: tx.incoming_metadata, + incoming_metadata_change: tx.incoming_metadata_change, + outgoing_metadata: tx.outgoing_metadata, + outgoing_metadata_change: tx.outgoing_metadata_change, + metadata_complete: row.metadata_complete, + metadata_error: row.metadata_error, + } + }) + .collect(), + } +} + // One deadline covers connection and every transaction, not one timeout per row. // Dropping a timed-out read cancels its future; no detached recovery keeps the // native lane occupied after history has been returned. @@ -958,6 +1008,124 @@ mod tests { assert!(rows[0].transaction.outgoing_metadata.is_empty()); } + #[tokio::test(start_paused = true)] + async fn partial_history_keeps_unknown_and_known_rows_without_promoting_estimates() { + let mut restored = outgoing_row(); + restored.transaction.txid = "11".repeat(32); + restored.fallback_outgoing_value = None; + restored.outgoing_value_estimate = Some(90); + restored.stored_fee = None; + let mut pending = outgoing_row(); + pending.transaction.txid = "22".repeat(32); + pending.transaction.unconfirmed = Some(true); + pending.fallback_outgoing_value = None; + let mut rows = vec![restored, pending, outgoing_row()]; + let start = tokio::time::Instant::now(); + enrich_qortal_transactions( + &mut rows, + &HashMap::new(), + start + Duration::from_secs(5), + |_| std::future::pending::>(), + ) + .await + .unwrap(); + assert_eq!(tokio::time::Instant::now() - start, Duration::from_secs(5)); + let json = serde_json::to_value(partial_history(rows)).unwrap(); + let rows = json["transactions"].as_array().unwrap(); + assert_eq!(rows.len(), 3); + assert!(rows[0]["outgoing_value"].is_null()); + assert_eq!(rows[0]["outgoing_value_estimate"], "90"); + assert!(rows[0]["fee"].is_null()); + assert_eq!( + rows[0]["fee_estimate"], + pirate_core::fees::DEFAULT_FEE.to_string() + ); + assert_eq!(rows[0]["outgoing_metadata"], serde_json::json!([])); + assert!(rows[1]["outgoing_value"].is_null()); + assert!(rows[1]["outgoing_value_estimate"].is_null()); + assert_eq!(rows[2]["outgoing_value"], "100"); + assert!(rows[2]["metadata_complete"].as_bool().unwrap()); + assert!(rows[2]["metadata_error"].is_null()); + } + + #[tokio::test(start_paused = true)] + async fn recovered_coverage_controls_whether_local_disagreement_can_be_resolved() { + for complete in [false, true] { + for internal in [false, true] { + let mut row = outgoing_row(); + row.fallback_outgoing_value = if internal { None } else { Some(100) }; + let mut rows = vec![row]; + let scopes = if internal { + HashMap::from([("recipient".to_string(), AddressScope::Internal)]) + } else { + HashMap::new() + }; + enrich_qortal_transactions( + &mut rows, + &scopes, + tokio::time::Instant::now() + Duration::from_secs(5), + |_| async { + Ok(RecoveredRecipients { + fee: None, + complete, + recipients: vec![TransactionRecipient { + address: "recipient".into(), + pool: "sapling".into(), + amount: 101, + output_index: 0, + memo: None, + payment_disclosure: None, + }], + }) + }, + ) + .await + .unwrap(); + let history = partial_history(rows); + let row = &history.transactions[0]; + assert_eq!(row.metadata_complete, complete); + assert_eq!( + row.outgoing_value, + complete.then_some(if internal { 0 } else { 101 }) + ); + assert_eq!(row.metadata_error.is_none(), complete); + } + } + } + + #[tokio::test(start_paused = true)] + async fn complete_recovery_cannot_resolve_missing_local_change_scope() { + let mut row = outgoing_row(); + row.fallback_outgoing_value = None; + row.outgoing_scope_known = false; + let mut rows = vec![row]; + enrich_qortal_transactions( + &mut rows, + &HashMap::new(), + tokio::time::Instant::now() + Duration::from_secs(5), + |_| async { + Ok(RecoveredRecipients { + fee: None, + complete: true, + recipients: vec![TransactionRecipient { + address: "unattributed-change".into(), + pool: "sapling".into(), + amount: 101, + output_index: 0, + memo: None, + payment_disclosure: None, + }], + }) + }, + ) + .await + .unwrap(); + let history = partial_history(rows); + assert_eq!(history.transactions[0].outgoing_value, None); + assert!(!history.transactions[0].metadata_complete); + assert_eq!(history.transactions[0].outgoing_metadata[0].value, 101); + } + #[test] fn raw_recovery_verifies_identity_and_requires_all_outputs() { let ovk = SaplingOutgoingViewingKey([77; 32]); @@ -1120,6 +1288,42 @@ mod tests { .is_err()); } + #[tokio::test(start_paused = true)] + async fn padded_pending_send_remains_visible_with_unknown_total() { + use payment_disclosure::persistence_tests::encrypted_sapling_history_fixture; + let ovk = SaplingOutgoingViewingKey([77; 32]); + let (txid, raw) = encrypted_sapling_history_fixture(ovk, false, false, 10, Some((None, 0))); + let mut row = outgoing_row(); + row.transaction.txid = txid.clone(); + row.transaction.unconfirmed = Some(true); + row.transaction.block_height = 0; + row.fallback_outgoing_value = None; + row.outgoing_scope_known = false; + let mut rows = vec![row]; + enrich_qortal_transactions( + &mut rows, + &HashMap::new(), + tokio::time::Instant::now() + Duration::from_secs(5), + |_| async { + recover_qortal_recipients_from_raw( + &raw, + &txid, + Some(1_000_000), + &[ovk], + &[], + NetworkType::Mainnet, + ) + }, + ) + .await + .unwrap(); + let response = partial_history(rows); + assert_eq!(response.transactions.len(), 1); + assert!(response.transactions[0].unconfirmed); + assert_eq!(response.transactions[0].outgoing_value, None); + assert_eq!(response.transactions[0].outgoing_metadata[0].value, 100); + } + #[test] fn ironwood_builder_padding_is_not_claimed_as_complete_recovery() { use orchard::{ @@ -1183,7 +1387,7 @@ mod tests { Some(transparent), None, None, - Some(signed), + Some(signed.clone()), ) .freeze() .unwrap(); @@ -1202,6 +1406,65 @@ mod tests { assert_eq!(recovery.recipients[0].amount, 40_000); assert!(!recovery.complete); assert_eq!(recovery.fee, None); + // A cross-pool fixture exercises a negative Ironwood value balance + // offset by Sapling, with no transparent prevouts needed for the fee. + let (_, sapling_raw) = + payment_disclosure::persistence_tests::encrypted_sapling_history_fixture( + SaplingOutgoingViewingKey([77; 32]), + false, + false, + 40_010, + None, + ); + let sapling_tx = read_pirate_transaction(&sapling_raw).unwrap(); + let shielded = TransactionData::::from_parts_v6( + BranchId::Nu6_3, + 0, + BlockHeight::from_u32(5_000_000), + None, + sapling_tx.sapling_bundle().cloned(), + None, + Some(signed), + ) + .freeze() + .unwrap(); + let mut raw = Vec::new(); + shielded.write(&mut raw).unwrap(); + let recovery = recover_qortal_recipients_from_raw( + &raw, + &shielded.txid().to_string(), + Some(1_000_000), + &[SaplingOutgoingViewingKey([77; 32])], + &[orchard::keys::OutgoingViewingKey::from([77; 32])], + NetworkType::Mainnet, + ) + .unwrap(); + assert_eq!(recovery.fee, Some(10)); + assert_eq!( + recovery + .recipients + .iter() + .map(|recipient| recipient.amount) + .sum::(), + 40_100 + ); + assert!(!recovery.complete); + } + + #[test] + fn partial_history_requires_explicit_request_method() { + let request: crate::service::WalletServiceRequest = + serde_json::from_value(serde_json::json!({ + "method": "qortal_list_transactions_partial", "wallet_id": "test", "limit": 10 + })) + .unwrap(); + assert!(matches!( + request, + crate::service::WalletServiceRequest::QortalListTransactionsPartial { + limit: Some(10), + .. + } + )); } #[test] diff --git a/crates/pirate-wallet-service/src/models.rs b/crates/pirate-wallet-service/src/models.rs index d7bff89e..ddfd22a4 100644 --- a/crates/pirate-wallet-service/src/models.rs +++ b/crates/pirate-wallet-service/src/models.rs @@ -600,6 +600,46 @@ pub struct QortalTransaction { pub unconfirmed: Option, } +/// Opt-in history response that retains rows whose outgoing metadata is incomplete. +/// Legacy `list` deliberately does not return this schema. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QortalPartialHistory { + pub transactions: Vec, +} + +/// Partial-history consumers must distinguish null totals from zero and must +/// never present the explicitly estimated fields as verified amounts. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QortalPartialTransaction { + pub txid: TxId, + pub block_height: u32, + pub datetime: i64, + pub unconfirmed: bool, + pub has_outgoing: bool, + /// Established non-internal outgoing total, or null when unknown. + #[serde(with = "amount_json::opt_u64")] + pub outgoing_value: Option, + /// Default-fee estimate for attributed confirmed history only. + #[serde(with = "amount_json::opt_u64")] + pub outgoing_value_estimate: Option, + /// Persisted or raw-transaction fee, or null. Incoming does not imply zero. + #[serde(with = "amount_json::opt_u64")] + pub fee: Option, + /// Conventional fee assumption; custom fees and dust additions can differ. + #[serde(with = "amount_json::opt_u64")] + pub fee_estimate: Option, + pub incoming_metadata: Vec, + pub incoming_metadata_change: Vec, + /// Known recovered recipients or locally established unknown-recipient values. + /// This vector is not a total when metadata_complete is false. + pub outgoing_metadata: Vec, + pub outgoing_metadata_change: Vec, + /// Whether outgoing metadata accounts for an established outgoing total. + pub metadata_complete: bool, + /// Transaction-scoped diagnostic, without exposing native/backend error text. + pub metadata_error: Option, +} + /// Qortal-compatible synchronization progress. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct QortalSyncStatus { diff --git a/crates/pirate-wallet-service/src/service.rs b/crates/pirate-wallet-service/src/service.rs index ff855678..828b3b72 100644 --- a/crates/pirate-wallet-service/src/service.rs +++ b/crates/pirate-wallet-service/src/service.rs @@ -176,6 +176,10 @@ pub enum WalletServiceRequest { wallet_id: WalletId, limit: Option, }, + QortalListTransactionsPartial { + wallet_id: WalletId, + limit: Option, + }, QortalSend { wallet_id: WalletId, request: QortalSendRequest, @@ -719,6 +723,9 @@ impl WalletService { WalletServiceRequest::QortalListTransactions { wallet_id, limit } => { serialize(ffi::qortal_list_transactions(wallet_id, limit).await?) } + WalletServiceRequest::QortalListTransactionsPartial { wallet_id, limit } => { + serialize(ffi::qortal_list_transactions_partial(wallet_id, limit).await?) + } WalletServiceRequest::QortalSend { wallet_id, request } => { serialize(ffi::qortal_send(wallet_id, request).await?) } diff --git a/docs/qortal-handoff.md b/docs/qortal-handoff.md index e4f905c9..f4fb050b 100644 --- a/docs/qortal-handoff.md +++ b/docs/qortal-handoff.md @@ -292,6 +292,57 @@ lightwalletd reads from exhausting Core's native-operation timeout and blocking subsequent balance/status requests. The bound covers remote enrichment, not synchronous local database loading or transaction decoding. +## Opt-in partial transaction history + +The typed JSON method `qortal_list_transactions_partial` accepts `wallet_id` +and optional `limit` and returns an object with a `transactions` array. It +uses the same local loading, expiry filtering, and shared recovery deadline as +`list`, but retains rows whose outgoing totals cannot be established. Existing +`list` and `qortal_list_transactions` responses remain strict: older consumers +must not silently interpret an unknown total as zero or sum an incomplete +recipient list. Consumers must explicitly adopt the new method before showing +partial history. No Core or GUI consumer is changed by this native API +addition. + +Each row contains `txid`, `block_height`, `datetime`, `unconfirmed`, +`has_outgoing`, the four existing incoming/outgoing metadata arrays, and: + +- `outgoing_value`: established non-internal outgoing total, or null. + Incoming-only rows use zero. It excludes the transaction fee. +- `outgoing_value_estimate`: a separate default-fee estimate for confirmed, + locally attributed rows with no stored fee; otherwise null. Never use it as + an established total. Custom fees and dust added to fees can change the + result. +- `fee`: a stored fee or one established from raw transaction value balances, + or null; `fee_estimate` separately reports the conventional default for + outgoing rows without a stored fee. +- `metadata_complete`: whether outgoing value accounting is complete. It does + not promise every recipient address or memo is known; an established + remainder can still use `[UNKNOWN]`. Incoming metadata keeps the existing + database scope classification and is not certified by this flag. +- `metadata_error`: a txid-scoped diagnostic for incomplete outgoing + accounting, or null. Incomplete rows do not prevent other rows from being + returned. + +The four optional aggregate amount/fee fields encode arrrtoshis as decimal +strings or null. Metadata entries retain their existing numeric value +encoding. Consumers must not sum incomplete outgoing metadata as the +transaction total, charge a fee per recipient, or substitute an estimate for a +null amount without labeling it estimated. Pending intent totals do not +establish external payments: requested recipients can include internal +addresses. + +Raw recovery verifies the requested transaction id. A nonempty recovered list +alone does not prove complete coverage: missing keys, failed recovery, or +dummy outputs may leave it incomplete. Full coverage takes precedence over +local accounting only when confirmed local output scopes are known; decrypted +addresses alone cannot repair missing change attribution. Pending or scope- +uncertain rows retain null totals even with full raw coverage. Individually +recovered recipients are retained and a contradicted local total is discarded. +Checked summation rejects overflow as an incomplete row. Scope checks for the +Qortal local fallback include dangling address links without changing the +GUI's existing transfer-splitting rules. + ## Padded outputs and fee recovery Sapling and Ironwood builders can pad transactions with zero-valued outputs @@ -301,13 +352,23 @@ real recipient is missing. It also does not prove the converse: a nonempty recovery can still omit a positive-valued output. Neither case is inferred from decryption failure alone. -For Sapling/Ironwood transactions with no transparent inputs or outputs and -no unsupported pool components, the parsed transaction's public value balances +For Sapling/Ironwood transactions with no transparent inputs or outputs and no +unsupported pool components, the parsed transaction's public value balances establish the fee. The transaction id is checked before using those balances; negative or overflowing fees are rejected. For confirmed, locally attributed history without a send intent, that fee can complete the spent-input-minus- internal-receipt accounting even when padding does not decrypt. An unrecovered positive remainder remains an unknown-recipient item, not discarded padding. This also handles custom fees and dust added to a fee without assuming the -default fee is exact. A raw fee alone proves neither input ownership nor change -scope; the local accounting prerequisites still apply. +default fee is exact. A raw fee alone proves neither input ownership nor +change scope; the local accounting prerequisites still apply. + +Pending or scope-uncertain rows without independent total evidence stay in +partial history with a null outgoing total, available recipient metadata, and +an unconfirmed flag. This avoids hiding the history while also avoiding a +fabricated amount. Strict legacy `list` can still reject these rows; consumers +requiring partial availability must use `qortal_list_transactions_partial` +through the existing JNI `invokeJson` entry point. No new command-table alias +is required. Clients should render unknown amounts as unavailable, and label +any separately supplied estimate. This API change alone does not update a +consumer that continues to call strict `list`.