From 7c92136fe81cfd678303f662dd984a7ae38ee68e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 2 Apr 2026 18:23:47 +0300 Subject: [PATCH] fix: apply BIP-69 sorting to asset lock credit outputs in payload The payload's credit_outputs were not sorted, making the transaction non-deterministic and potentially fingerprintable. Changes: - TransactionBuilder::build_asset_lock() now sorts credit outputs by BIP-69 (amount ascending, then scriptPubKey lexicographically) - Returns (Transaction, sorted_indices) so callers can re-map keys to the new sorted positions - ManagedWalletInfo::build_asset_lock() reorders derived keys to match the sorted output positions, so keys[i] always corresponds to payload.credit_outputs[i] Co-Authored-By: Claude Opus 4.6 --- .../managed_wallet_info/asset_lock_builder.rs | 16 ++++++++-- .../transaction_builder.rs | 29 ++++++++++++++++--- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs b/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs index 1b7d40fce..4ad0da6ad 100644 --- a/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs @@ -241,7 +241,8 @@ impl ManagedWalletInfo { let fee = tx_builder_with_inputs.calculate_fee(); let fee_with_extra = tx_builder_with_inputs.calculate_fee_with_extra_output(); - let transaction = tx_builder_with_inputs.build_asset_lock(credit_outputs)?; + let (transaction, sorted_indices) = + tx_builder_with_inputs.build_asset_lock(credit_outputs)?; let actual_fee = if transaction.output.len() > outputs_count_before { fee_with_extra @@ -250,7 +251,9 @@ impl ManagedWalletInfo { }; // Transaction built successfully — now derive keys. - let mut keys = Vec::with_capacity(credit_output_fundings.len()); + // Keys are derived in the original funding order, then reordered + // to match the BIP-69 sorted credit output positions in the payload. + let mut original_keys = Vec::with_capacity(credit_output_fundings.len()); for funding in &credit_output_fundings { let funding_key_account = resolve_funding_account( &mut self.accounts, @@ -260,7 +263,14 @@ impl ManagedWalletInfo { let key = funding_key_account .next_private_key(root_xpriv, network) .map_err(|e| AssetLockError::KeyDerivation(e.to_string()))?; - keys.push(key); + original_keys.push(key); + } + + // Reorder keys to match sorted output positions: + // sorted_indices[i] = original index of the output now at position i + let mut keys = vec![[0u8; 32]; original_keys.len()]; + for (sorted_pos, &orig_idx) in sorted_indices.iter().enumerate() { + keys[sorted_pos] = original_keys[orig_idx]; } Ok(AssetLockResult { diff --git a/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs b/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs index 493bf1938..63e37f4a8 100644 --- a/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs @@ -629,13 +629,34 @@ impl TransactionBuilder { /// Build an Asset Lock Transaction /// - /// Used to lock Dash for use in Platform (creates Platform credits) - pub fn build_asset_lock(self, credit_outputs: Vec) -> Result { + /// Used to lock Dash for use in Platform (creates Platform credits). + /// Credit outputs in the payload are sorted by BIP-69 (amount ascending, + /// then scriptPubKey lexicographically) for deterministic ordering. + /// Returns `(Transaction, sorted_indices)` where `sorted_indices[i]` is + /// the original index of the credit output now at position `i` in the payload. + pub fn build_asset_lock( + self, + credit_outputs: Vec, + ) -> Result<(Transaction, Vec), BuilderError> { + // Track original indices so callers can re-map keys + let mut indexed: Vec<(usize, TxOut)> = credit_outputs.into_iter().enumerate().collect(); + + // BIP-69: sort by amount ascending, then scriptPubKey lexicographically + indexed.sort_by(|(_, a), (_, b)| match a.value.cmp(&b.value) { + std::cmp::Ordering::Equal => a.script_pubkey.as_bytes().cmp(b.script_pubkey.as_bytes()), + other => other, + }); + + let sorted_indices: Vec = indexed.iter().map(|(orig, _)| *orig).collect(); + let sorted_outputs: Vec = indexed.into_iter().map(|(_, out)| out).collect(); + let payload = AssetLockPayload { version: 0, - credit_outputs, + credit_outputs: sorted_outputs, }; - self.set_special_payload(TransactionPayload::AssetLockPayloadType(payload)).build() + let tx = + self.set_special_payload(TransactionPayload::AssetLockPayloadType(payload)).build()?; + Ok((tx, sorted_indices)) } /// Estimate transaction size in bytes