Skip to content

fix: apply BIP-69 sorting to asset lock credit outputs#622

Open
QuantumExplorer wants to merge 1 commit intov0.42-devfrom
fix/bip69-credit-output-sorting
Open

fix: apply BIP-69 sorting to asset lock credit outputs#622
QuantumExplorer wants to merge 1 commit intov0.42-devfrom
fix/bip69-credit-output-sorting

Conversation

@QuantumExplorer
Copy link
Copy Markdown
Member

@QuantumExplorer QuantumExplorer commented Apr 2, 2026

Summary

The asset lock payload's credit_outputs were not BIP-69 sorted, making the transaction output ordering non-deterministic and potentially fingerprintable.

Changes

TransactionBuilder::build_asset_lock()

  • Now sorts credit outputs by BIP-69 before placing them in the payload (amount ascending, then scriptPubKey lexicographically)
  • Returns (Transaction, Vec<usize>) where sorted_indices[i] = original index of the output now at sorted position i

ManagedWalletInfo::build_asset_lock()

  • Reorders derived private keys to match the sorted output positions
  • result.keys[i] always corresponds to payload.credit_outputs[i]

Why

BIP-69 mandates deterministic output ordering. The transaction's regular outputs were already sorted, but the payload's credit outputs were in caller-provided order. This inconsistency could fingerprint transactions built by this library.

Test plan

  • All 11 asset lock tests pass
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • CI passes

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Fixed transaction building logic to ensure proper key ordering alignment with output sorting requirements.

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 <[email protected]>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 2, 2026

📝 Walkthrough

Walkthrough

The changes modify the build_asset_lock method to implement BIP-69 sorting of credit outputs. The transaction builder now returns a tuple containing the transaction and a vector of original indices mapping sorted positions back to input order. The asset lock builder uses these indices to reorder derived keys accordingly.

Changes

Cohort / File(s) Summary
Asset Lock Building with BIP-69 Sorting
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs, key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs
Modified build_asset_lock to sort credit outputs by BIP-69 rules (ascending value, then lexicographic script pubkey) and return index mapping alongside transaction. Asset lock builder updated to destructure tuple result and reorder derived keys using sorted indices.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant AssetLockBuilder
    participant TransactionBuilder
    participant Sorter as BIP-69 Sorter

    Caller->>AssetLockBuilder: build_asset_lock(credit_outputs)
    AssetLockBuilder->>TransactionBuilder: build_asset_lock(credit_outputs)
    TransactionBuilder->>Sorter: Sort outputs by value, then script_pubkey
    Sorter-->>TransactionBuilder: sorted_outputs, original_indices
    TransactionBuilder->>TransactionBuilder: Create AssetLockPayload with sorted_outputs
    TransactionBuilder->>TransactionBuilder: Build transaction
    TransactionBuilder-->>AssetLockBuilder: (transaction, original_indices)
    AssetLockBuilder->>AssetLockBuilder: Collect derived keys from credit_outputs
    AssetLockBuilder->>AssetLockBuilder: Reorder keys using original_indices
    AssetLockBuilder-->>Caller: keys[sorted_pos] matches payload sorted position
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 A little rabbit hops with glee,
BIP-69 sorting, now you see!
Outputs arranged by worth and key,
Indices dance in harmony,
Keys reordered, wild and free!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: apply BIP-69 sorting to asset lock credit outputs' directly and clearly summarizes the main change—implementing BIP-69 sorting for asset lock credit outputs, which is the primary focus of the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/bip69-credit-output-sorting

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 2, 2026

Codecov Report

❌ Patch coverage is 0% with 22 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.57%. Comparing base (3f65002) to head (7c92136).

Files with missing lines Patch % Lines
.../wallet/managed_wallet_info/transaction_builder.rs 0.00% 15 Missing ⚠️
...c/wallet/managed_wallet_info/asset_lock_builder.rs 0.00% 7 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##           v0.42-dev     #622      +/-   ##
=============================================
- Coverage      67.60%   67.57%   -0.03%     
=============================================
  Files            321      321              
  Lines          68258    68275      +17     
=============================================
- Hits           46147    46140       -7     
- Misses         22111    22135      +24     
Flag Coverage Δ
core 75.21% <ø> (ø)
ffi 38.00% <ø> (ø)
rpc 19.92% <ø> (ø)
spv 83.84% <ø> (-0.05%) ⬇️
wallet 67.39% <0.00%> (-0.07%) ⬇️
Files with missing lines Coverage Δ
...c/wallet/managed_wallet_info/asset_lock_builder.rs 58.69% <0.00%> (-1.64%) ⬇️
.../wallet/managed_wallet_info/transaction_builder.rs 57.96% <0.00%> (-0.89%) ⬇️

... and 3 files with indirect coverage changes

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs (1)

244-273: 🛠️ Refactor suggestion | 🟠 Major

Please add a regression test for the new sorted_indiceskeys contract.

This is the first consumer of the new mapping, but the local tests below still only exercise error paths. A small success-path test with equal-value outputs and different script_pubkeys would pin both the BIP-69 tie-breaker and the guarantee that keys[i] lines up with payload.credit_outputs[i].

As per coding guidelines, "Write unit tests for new functionality".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs` around lines
244 - 273, Add a regression unit test that exercises the success path of
build_asset_lock/asset_lock_builder logic to verify the new sorted_indices→keys
contract: construct a tx_builder_with_inputs scenario with multiple equal-value
credit outputs but different script_pubkeys, call build_asset_lock to obtain
sorted_indices and the resulting payload.credit_outputs, derive keys via the
same flow (using credit_output_fundings and
resolve_funding_account/next_private_key), and assert that for each i the
derived keys[i] corresponds to the private key used for
payload.credit_outputs[i] (i.e., keys ordering matches sorted_indices/BIP-69
tie-breaker). Target the asset_lock_builder.rs tests for a test that sets up
equal-value outputs, uses credit_output_fundings, asserts no errors, and checks
the keys-to-payload.credit_outputs alignment to prevent regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs`:
- Around line 637-659: The build_asset_lock() currently sets
self.special_payload only at the final build step, so callers like
asset_lock_builder::select_inputs() and calculate_fee*() never see the payload
bytes and can underselect; fix this by exposing the sorted AssetLockPayload and
indices prior to input selection—either (A) add a preparatory method on the
builder (e.g., prepare_asset_lock_payload(&mut self, credit_outputs: Vec<TxOut>)
-> Result<Vec<usize>, BuilderError>) that performs the BIP-69 sorting, installs
self.special_payload (or otherwise stores the payload) and returns
sorted_indices so select_inputs()/calculate_fee*() can include payload size, or
(B) change build_asset_lock() to factor out the sorting into a public helper
(e.g., sort_asset_lock_outputs(credit_outputs) -> (AssetLockPayload,
Vec<usize>)) that asset_lock_builder can call before select_inputs(); update
callers in asset_lock_builder.rs (the code around lines 227-245) to call the new
preparatory API so payload bytes are counted during fee/input selection.

---

Outside diff comments:
In `@key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs`:
- Around line 244-273: Add a regression unit test that exercises the success
path of build_asset_lock/asset_lock_builder logic to verify the new
sorted_indices→keys contract: construct a tx_builder_with_inputs scenario with
multiple equal-value credit outputs but different script_pubkeys, call
build_asset_lock to obtain sorted_indices and the resulting
payload.credit_outputs, derive keys via the same flow (using
credit_output_fundings and resolve_funding_account/next_private_key), and assert
that for each i the derived keys[i] corresponds to the private key used for
payload.credit_outputs[i] (i.e., keys ordering matches sorted_indices/BIP-69
tie-breaker). Target the asset_lock_builder.rs tests for a test that sets up
equal-value outputs, uses credit_output_fundings, asserts no errors, and checks
the keys-to-payload.credit_outputs alignment to prevent regressions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 50186ded-3f4c-4cfc-9aa9-fb044c3d029f

📥 Commits

Reviewing files that changed from the base of the PR and between 3f65002 and 7c92136.

📒 Files selected for processing (2)
  • key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs
  • key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs

Comment on lines +637 to +659
pub fn build_asset_lock(
self,
credit_outputs: Vec<TxOut>,
) -> Result<(Transaction, Vec<usize>), 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<usize> = indexed.iter().map(|(orig, _)| *orig).collect();
let sorted_outputs: Vec<TxOut> = 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))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This still hides asset-lock payload bytes from select_inputs().

special_payload is only created here, but key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs, Lines 227-245, call select_inputs() and calculate_fee*() before invoking build_asset_lock(). Because the builder only counts payload bytes once self.special_payload is set, asset-lock builds can still underselect near the margin and return a fee that excludes the payload. Please expose the sorted payload/indices before selection, or add a preparatory API that lets callers install the payload earlier.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs` around
lines 637 - 659, The build_asset_lock() currently sets self.special_payload only
at the final build step, so callers like asset_lock_builder::select_inputs() and
calculate_fee*() never see the payload bytes and can underselect; fix this by
exposing the sorted AssetLockPayload and indices prior to input selection—either
(A) add a preparatory method on the builder (e.g.,
prepare_asset_lock_payload(&mut self, credit_outputs: Vec<TxOut>) ->
Result<Vec<usize>, BuilderError>) that performs the BIP-69 sorting, installs
self.special_payload (or otherwise stores the payload) and returns
sorted_indices so select_inputs()/calculate_fee*() can include payload size, or
(B) change build_asset_lock() to factor out the sorting into a public helper
(e.g., sort_asset_lock_outputs(credit_outputs) -> (AssetLockPayload,
Vec<usize>)) that asset_lock_builder can call before select_inputs(); update
callers in asset_lock_builder.rs (the code around lines 227-245) to call the new
preparatory API so payload bytes are counted during fee/input selection.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant