From 9d77f41fb99db0a87bf9620147f5181fb9516084 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:43:20 +0000 Subject: [PATCH 1/3] fix(platform-wallet): emit the real locking script for spent UTXOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `derive_spent_utxos` filled `TxOut.script_pubkey` with `ScriptBuf::default()` while cloning the authoritative address two lines below. `TxOut.script_pubkey` is documented upstream as "The script which must be satisfied for the output to be spent" — a total field with no encoding for absence — so a default there is a claim about the chain that was never observed. Rebuild it from the address the function already holds. The reconstruction is exact, not a guess. `InputDetail.address` is cloned from the wallet's own `Utxo.address` (managed_core_funds_account.rs:398), which key-wallet derived from the spent output's `script_pubkey` via `Address::from_script` on the receive path. `is_p2pkh` and `is_p2sh` accept only the canonical form — fixed length with every structural opcode pinned, the 20-byte hash the sole free field, copied verbatim in both directions — so re-encoding the address reproduces the original bytes. Verified against rust-dashcore rev 70d4bf8e, the rev this branch pins. The address/script pairing is a caller convention, not a type invariant: `Utxo::new` takes both as independent parameters and validates neither. The new test is what keeps the convention honest. Behavioural consequence, intended and reviewed: spent rows now carry a resolvable script, so consumers that read stored scripts — notably the address-reuse guard being added in dashpay/platform#3968 — gain entries that never existed before. A previously-used address whose funds were since spent is no longer re-issued as a fresh receive address. This is an expansion of reuse-guard coverage and is why it lands here on v4.2-dev as its own change rather than inside that PR's merge window. Height and the confirmation flags stay defaulted; unlike the script they are genuinely unrecoverable here and are read as "not yet known". Test: spent_utxos_carry_the_real_script_of_the_address_they_spend. Co-Authored-By: Claude Opus 5 --- .../src/changeset/core_bridge.rs | 127 ++++++++++++++++-- 1 file changed, 116 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index b700e73dcaf..cc7c8d11b1e 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -29,7 +29,6 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use dashcore::blockdata::transaction::{txout::TxOut, OutPoint}; -use dashcore::ScriptBuf; use key_wallet::account::AccountType; use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType}; use key_wallet::managed_account::transaction_record::{OutputRole, TransactionRecord}; @@ -687,15 +686,22 @@ fn derive_new_utxos(record: &TransactionRecord) -> Vec { /// Derive the "ours" UTXOs spent by a transaction's inputs. /// /// Walks `record.input_details` (the entries keyed to inputs that spent -/// our outpoints) and synthesizes a `Utxo` per entry using the data we -/// have: the outpoint from `transaction.input[index].previous_output`, -/// the value and address from `InputDetail`. The script_pubkey, height, -/// and confirmation flags belong to the *previous* transaction's -/// output and aren't carried in `InputDetail`; they're filled with -/// defaults (`ScriptBuf::default()`, height 0, all flags false). The -/// persister deletes by `outpoint` so the missing fields are -/// informational only — they never affect correctness of the spent-set -/// removal, only the audit-trail richness on the way out. +/// our outpoints) and synthesizes a `Utxo` per entry: the outpoint from +/// `transaction.input[index].previous_output`, the value and address from +/// `InputDetail`, and the locking script rebuilt from that address. +/// +/// The script is an exact reconstruction, not a guess. `InputDetail.address` +/// is cloned from the wallet's own `Utxo.address`, which key-wallet derived +/// from the spent output's `script_pubkey` via `Address::from_script`; that +/// decoder accepts only canonical P2PKH/P2SH forms, so re-encoding the +/// address reproduces the original bytes. Note the pairing is a caller +/// convention rather than a type invariant — `Utxo::new` takes the script +/// and the address as independent parameters and validates neither. +/// +/// Height and the confirmation flags describe the *previous* transaction and +/// genuinely aren't recoverable here, so they stay defaulted (height 0, all +/// flags false); unlike `script_pubkey`, those fields are read as "not yet +/// known" and re-warm on the next sync. fn derive_spent_utxos(record: &TransactionRecord) -> Vec { record .input_details @@ -706,7 +712,7 @@ fn derive_spent_utxos(record: &TransactionRecord) -> Vec { outpoint: input.previous_output, txout: TxOut { value: detail.value, - script_pubkey: ScriptBuf::default(), + script_pubkey: detail.address.script_pubkey(), }, address: detail.address.clone(), height: 0, @@ -926,6 +932,105 @@ mod tests { use super::freeze_synced_height_if_faulted; use crate::changeset::changeset::CoreChangeSet; + /// A spent UTXO must carry the real locking script of the output it + /// spends, reconstructed from the address the input detail already + /// carries. `TxOut::script_pubkey` has no encoding for "unknown", so a + /// default-filled script is a claim about the chain that was never + /// observed, and any consumer reading stored scripts sees an unusable row. + /// + /// The reconstruction is exact: `InputDetail.address` is cloned from the + /// wallet's own `Utxo.address`, which key-wallet derived from that + /// output's script via `Address::from_script`, and `is_p2pkh`/`is_p2sh` + /// accept only the canonical form — so `script_pubkey()` rebuilds the same + /// bytes. This test is what keeps that true, since `Utxo::new` does not + /// enforce the address/script pairing. + #[test] + fn spent_utxos_carry_the_real_script_of_the_address_they_spend() { + use dashcore::hashes::Hash; + use dashcore::{OutPoint, Transaction, TxIn, Txid}; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::transaction_record::{ + InputDetail, TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{TransactionContext, TransactionType}; + + let addresses = [ + dashcore::Address::new( + dashcore::Network::Testnet, + dashcore::address::Payload::PubkeyHash(dashcore::PubkeyHash::from_byte_array( + [0x11u8; 20], + )), + ), + dashcore::Address::new( + dashcore::Network::Testnet, + dashcore::address::Payload::ScriptHash(dashcore::ScriptHash::from_byte_array( + [0x22u8; 20], + )), + ), + ]; + let transaction = Transaction { + version: 3, + lock_time: 0, + input: addresses + .iter() + .enumerate() + .map(|(index, _)| TxIn { + previous_output: OutPoint { + txid: Txid::from_byte_array([index as u8 + 1; 32]), + vout: index as u32, + }, + ..Default::default() + }) + .collect(), + output: vec![], + special_transaction_payload: None, + }; + let record = TransactionRecord::new( + transaction, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Outgoing, + addresses + .iter() + .enumerate() + .map(|(index, address)| InputDetail { + index: index as u32, + value: 1_000 * (index as u64 + 1), + address: address.clone(), + }) + .collect(), + Vec::new(), + -2_000, + ); + + let spent = super::derive_spent_utxos(&record); + assert_eq!(spent.len(), addresses.len()); + for (utxo, address) in spent.iter().zip(addresses.iter()) { + assert!( + !utxo.txout.script_pubkey.is_empty(), + "a spent UTXO must never carry a fabricated empty script" + ); + assert_eq!( + utxo.txout.script_pubkey, + address.script_pubkey(), + "the script must be the address's own locking script" + ); + assert_eq!( + dashcore::Address::from_script( + &utxo.txout.script_pubkey, + dashcore::Network::Testnet + ) + .expect("the emitted script must decode as an address"), + *address, + "the script must round-trip back to the input's own address" + ); + } + } + /// dashpay/platform#4069: while persistence is healthy the sync /// watermark flows through untouched. #[test] From 524e7a72973848e753827578eeb04a19b596097d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:37:49 +0000 Subject: [PATCH 2/3] test(platform-wallet): exercise the real funding-then-spend path for derive_spent_utxos Add spent_utxo_script_matches_the_real_funding_output alongside the existing hand-built-InputDetail test: it funds a receive address and spends it via check_core_transaction (the same path SPV block processing uses), then asserts the derived spent UTXO's script matches the real funding output's script. The prior test only proved derive_spent_utxos calls Address::script_pubkey() on the InputDetail it was handed, not that the address/script pairing survives key-wallet's independent Address::from_script derivation on ingestion. Also fix a doc-comment overstatement: Address::from_script (pinned rust-dashcore rev 70d4bf8e) accepts witness-program forms too, not just P2PKH/P2SH. Addresses PR #4257 review comments. Co-Authored-By: Claude Opus 5 --- .../src/changeset/core_bridge.rs | 64 +++++++++++++++++-- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 57d7570d73c..7aaa76a4bc4 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -1081,10 +1081,11 @@ fn derive_new_utxos(record: &TransactionRecord) -> Vec { /// The script is an exact reconstruction, not a guess. `InputDetail.address` /// is cloned from the wallet's own `Utxo.address`, which key-wallet derived /// from the spent output's `script_pubkey` via `Address::from_script`; that -/// decoder accepts only canonical P2PKH/P2SH forms, so re-encoding the -/// address reproduces the original bytes. Note the pairing is a caller -/// convention rather than a type invariant — `Utxo::new` takes the script -/// and the address as independent parameters and validates neither. +/// decoder accepts canonical P2PKH, P2SH, and witness-program forms, so +/// re-encoding the address reproduces the original bytes. Note the pairing +/// is a caller convention rather than a type invariant — `Utxo::new` takes +/// the script and the address as independent parameters and validates +/// neither. /// /// Height and the confirmation flags describe the *previous* transaction and /// genuinely aren't recoverable here, so they stay defaulted (height 0, all @@ -1777,6 +1778,61 @@ mod usage_delta_tests { Some(0) ); } + + /// `derive_spent_utxos`' script reconstruction must hold on the real + /// funding-then-spend path, not just on a hand-built `InputDetail`: + /// fund a receive address via `check_core_transaction`, spend that + /// UTXO, and check the derived spent UTXO's script against the + /// *original funding output's* script — the one `InputDetail.address` + /// was independently derived from via `Address::from_script`. + #[tokio::test] + async fn spent_utxo_script_matches_the_real_funding_output() { + let TestWalletContext { + mut managed_wallet, + mut wallet, + receive_address, + .. + } = TestWalletContext::new_random(); + let funding_script = receive_address.script_pubkey(); + + let funding_outpoint = OutPoint { + txid: Txid::from_slice(&[2u8; 32]).expect("valid txid"), + vout: 0, + }; + let fund_tx = spend_to(funding_outpoint, funding_script.clone(), 75_000); + let fund_result = managed_wallet + .check_core_transaction(&fund_tx, in_block(100_000), &mut wallet, true, true) + .await; + assert!(fund_result.is_relevant); + + let spent_outpoint = OutPoint { + txid: fund_tx.txid(), + vout: 0, + }; + let spend_tx = spend_to(spent_outpoint, foreign_script(), 74_000); + let spend_result = managed_wallet + .check_core_transaction(&spend_tx, in_block(100_001), &mut wallet, true, true) + .await; + assert!(spend_result.is_relevant, "spend of our UTXO must match"); + + let record = spend_result + .new_records + .iter() + .chain(spend_result.updated_records.iter()) + .find(|r| !r.input_details.is_empty()) + .expect("spend must produce a record carrying the spent input's details"); + + let spent = derive_spent_utxos(record); + let spent_utxo = spent + .iter() + .find(|u| u.outpoint == spent_outpoint) + .expect("derived spent UTXOs must include the funding outpoint"); + assert_eq!( + spent_utxo.txout.script_pubkey, funding_script, + "the derived script must match the real funding output's script, \ + not just round-trip through the address" + ); + } } #[cfg(test)] From 3ef85a7790395f5134bdc9c8c7d95b0293250b33 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:25:30 +0000 Subject: [PATCH 3/3] docs(platform-wallet): stop claiming spent-UTXO metadata re-warms on sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit derive_spent_utxos' doc comment claimed the defaulted height/confirmation flags on a spent record are read as "not yet known" and "re-warm on the next sync." No such refresh exists: the SQLite persister's spent-UTXO path either flips an existing row's `spent` flag (leaving height/account untouched) or inserts a synthetic row with the defaults baked in — both excluded from list_unspent_utxos, and neither revisited by a later sync. Describe the defaults as what they are: baked into this synthetic record because InputDetail never carried them, not a placeholder pending refresh. Addresses PR #4257 review comment (discussion_r3820253351). Co-Authored-By: Claude Opus 5 --- packages/rs-platform-wallet/src/changeset/core_bridge.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 7aaa76a4bc4..570ad86a958 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -1088,9 +1088,8 @@ fn derive_new_utxos(record: &TransactionRecord) -> Vec { /// neither. /// /// Height and the confirmation flags describe the *previous* transaction and -/// genuinely aren't recoverable here, so they stay defaulted (height 0, all -/// flags false); unlike `script_pubkey`, those fields are read as "not yet -/// known" and re-warm on the next sync. +/// aren't carried in `InputDetail`, so they remain defaulted on this synthetic +/// spent record (height 0, all flags false). fn derive_spent_utxos(record: &TransactionRecord) -> Vec { record .input_details