Skip to content

feat(key-wallet): OP_RETURN outputs, output-order control and change-to-VIN0 - #922

Open
romchornyi wants to merge 2 commits into
devfrom
feat/tx-builder-op-return
Open

feat(key-wallet): OP_RETURN outputs, output-order control and change-to-VIN0#922
romchornyi wants to merge 2 commits into
devfrom
feat/tx-builder-op-return

Conversation

@romchornyi

@romchornyi romchornyi commented Aug 4, 2026

Copy link
Copy Markdown

Issue being fixed or feature implemented

The Dash iOS wallet is restoring MAYACHAIN swap routes. MAYAChain's UTXO deposit
contract (docs,
"UTXO Chains") requires a very specific transaction shape:

  • VOUT0 — Asgard vault payment
  • VOUT1 — the swap memo in an OP_RETURN
  • VOUT2 — change paid back to the VIN0 address
  • no output reordering

TransactionBuilder could express none of it. The change rule is the
load-bearing one: "Do not use HD wallets that forward the change to a new
address, because MAYAChain IDs the user as the address in VIN0. The user must
keep their VIN0 address funded for refunds."
set_funding assigns
next_change_address() — exactly the pattern that breaks — and it breaks
silently: the swap succeeds, and only a later refund goes to an address the
user was never told to watch.

This cannot be worked around downstream. Consumers that fund and sign in a
single call (the Swift SDK's FFI builder) have no seam to patch outputs
afterwards, so the shape has to be expressible on the builder itself.

What was done?

All in key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs.

  • add_op_return(&[u8]) -> Result<Self, BuilderError> — appends a
    zero-value OP_RETURN output. Payloads over the 80-byte standardness limit
    return the new BuilderError::OpReturnDataTooLarge instead of panicking
    inside ScriptBuf. MAX_STANDARD_OP_RETURN_BYTES is pub so FFI callers can
    pre-check before handing over a builder the call would consume.
  • preserve_output_order() — skips BIP-69 output sorting.
  • change_to_first_input() — routes change to the address of the first
    input after the BIP-69 input sort. This required moving
    selected_inputs.sort_by(bip69_input_sorter) above change construction: VIN0
    is not known until the sort has run, while change was previously pushed
    before it.
  • calculate_base_size now measures each output's real serialized size
    (8 + varint(script_len) + script_len) instead of charging a flat
    TX_OUTPUT_SIZE per output.

The fee change is not cosmetic. For the canonical Maya shape — 1 input, vault +
80-byte memo + change — the flat estimate gives 260 bytes against a real 318,
i.e. 0.82 duff/byte, under the 1 duff/byte relay minimum, so the transaction
can be rejected outright rather than merely underpaying.

Deliberately conservative choices, called out for review:

  • the asset-lock burn output is still charged TX_OUTPUT_SIZE rather than its
    real ~11 bytes, so identity-funding fees stay byte-identical;
  • both new flags are opt-in — default behaviour is unchanged for every existing
    caller.

How Has This Been Tested?

cargo test -p key-wallet — 21/21 in the transaction_builder module, run on
this branch rebased onto current dev.

New tests:

  • test_maya_deposit_shape_preserves_output_order_and_routes_change_to_first_input
    — asserts output count and order, the OP_RETURN payload round-trip,
    output[2].script_pubkey == VIN0's address script (built with two inputs, so
    it genuinely exercises the post-sort behaviour), and that the fee covers the
    signed size. The last point matters: build_unsigned leaves every
    script_sig empty, so comparing the fee against the serialized bytes as-is
    would pass regardless of how badly the estimate under-counted.
  • test_add_op_return_rejects_oversized_payload — returns the error rather than
    panicking.
  • test_default_ordinary_send_matches_legacy_bytes — an ordinary
    two-recipient send assembles byte-identically to the pre-change logic.
  • test_base_size_unchanged_for_pre_op_return_shapes — P2PKH and asset-lock
    size estimates match the pre-change formula exactly, pinning the "no fee
    movement for existing shapes" claim.

Downstream verification: consumed by dashpay/platform#4286, which adds an
integration test building real Maya-shaped deposits, and manually smoke-tested
via a full MAYACHAIN swap from the Dash iOS wallet (dashpay/dashwallet-ios#916).

Breaking Changes

None. The three new methods are additive and opt-in; calculate_base_size
produces identical results for every transaction shape that existed before this
change, which test_base_size_unchanged_for_pre_op_return_shapes enforces.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

Summary by CodeRabbit

Release Notes

  • New Features

    • Added support for OP_RETURN data outputs with configurable size limits (80-byte default relay policy)
    • Added transaction building options to preserve output order and route change to the first input
  • Improvements

    • Enhanced transaction size estimation accuracy for data-bearing transactions
  • Tests

    • Added comprehensive test coverage for new transaction building capabilities and edge cases

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@romchornyi, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 33 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 27e08cc3-cade-4ae3-93bf-b121293f0f0e

📥 Commits

Reviewing files that changed from the base of the PR and between c9d33a4 and eebacae.

📒 Files selected for processing (1)
  • key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
📝 Walkthrough

Walkthrough

This PR adds OP_RETURN output support to the transaction builder with an 80-byte relay-policy limit constant and dedicated size validation. The builder now supports configurable insertion-order preservation and change routing to the first selected input. Size estimation uses serialized output sizes. Tests validate OP_RETURN limits, feature interactions, and backward compatibility.

Changes

Transaction Builder OP_RETURN and Change Routing

Layer / File(s) Summary
Constants, imports, and builder state
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
Adds opcodes import, DEFAULT_MAX_OP_RETURN_BYTES constant (80 bytes), serialized TxOut size helper, and new builder fields for preserve_output_order, change_to_first_input, and max_op_return_bytes. Fields initialize to false, false, and the default constant in TransactionBuilder::new.
OP_RETURN and change routing methods
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
Adds add_op_return(data) method that validates data length and creates a zero-value OP_RETURN output. Adds preserve_output_order() and change_to_first_input() builder methods. Adds OpReturnDataTooLarge error variant with display formatting showing actual and maximum payload lengths.
Size estimation and coin selection
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
Change estimation now accounts for serialized output sizes and either change-routing mode. Base-size calculation uses serialized output sizes instead of multiplying a fixed output count. Drain selection disables change_to_first_input. Coin selection budgets change output size when either change-routing mode is enabled.
Input sorting and change output assembly
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
Selected inputs sort by BIP-69 ordering before change routing. Change output script uses the first selected input's script or the configured address. Output sorting is skipped when preserve_output_order is enabled; AssetLock outputs remain unsorted in all cases.
Tests for OP_RETURN, ordering, and legacy compatibility
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
Adds test helpers for OP_RETURN scripts and legacy unsigned assembly. Tests cover Maya-shaped transactions with preserved output order and first-input change routing, byte-equivalent size checks, oversized OP_RETURN rejection with default and custom limits, and default-send serialization and fee equivalence with the legacy builder.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant TransactionBuilder
  Caller->>TransactionBuilder: add_op_return(data)
  TransactionBuilder->>TransactionBuilder: validate length against DEFAULT_MAX_OP_RETURN_BYTES
  alt data exceeds limit
    TransactionBuilder-->>Caller: BuilderError::OpReturnDataTooLarge
  else valid
    TransactionBuilder->>TransactionBuilder: append zero-value OP_RETURN output
    Caller->>TransactionBuilder: change_to_first_input() / preserve_output_order()
    TransactionBuilder->>TransactionBuilder: select inputs and estimate size
    TransactionBuilder->>TransactionBuilder: sort inputs by BIP-69
    TransactionBuilder->>TransactionBuilder: compute change output
    TransactionBuilder->>TransactionBuilder: determine change script from first input or address
    alt preserve_output_order
      TransactionBuilder->>TransactionBuilder: skip output sorting
    else default
      TransactionBuilder->>TransactionBuilder: sort outputs by BIP-69
    end
    TransactionBuilder-->>Caller: assembled transaction
  end
Loading

Suggested reviewers: zocolini, quantumexplorer, xdustinface

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the pull request's main changes to OP_RETURN outputs, output ordering, and change routing.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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 feat/tx-builder-op-return

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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs`:
- Around line 188-192: Update the documentation for add_output and
preserve_output_order in the transaction builder to state that BIP-69 output
sorting is enabled by default, while calling preserve_output_order disables
sorting and retains insertion order.
- Around line 29-31: Replace the hardcoded MAX_STANDARD_OP_RETURN_BYTES constant
with a relay-limit value supplied by the selected network policy or
TransactionBuilder configuration, and thread that value through add_op_return
and related builder construction paths. Preserve the existing payload validation
behavior while allowing networks or nodes with different OP_RETURN limits to
provide their policy-specific value.
- Around line 816-825: Update the legacy helper’s
CoinSelector::select_coins_with_size call to pass CHANGE_OUTPUT_SIZE when
change_addr exists and 0 for drain builds, matching the production selector
path. Adjust the surrounding build_unsigned_legacy logic as needed so the
regression test uses the same selector input and fee behavior as production.
- Around line 462-472: Update the change-output selection in TransactionBuilder
around change_to_first_input and set_change_address so input-derived change is
validated against the configured change address network. Reject mismatched
first_input.address.network and change_addr.network with the existing builder
error mechanism before assembling outputs, while preserving the current behavior
for matching networks and explicit change addresses.
- Around line 220-222: Update the change-output selector in the transaction size
estimation flow to use should_estimate_change_output() rather than checking only
change_addr. Ensure calculate_base_size(), coin selection, and final assembly
consistently budget a change output when change_to_first_input is enabled.
🪄 Autofix

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 Plus

Run ID: 404b3092-33ba-4f3f-bf97-cdebb1a812ea

📥 Commits

Reviewing files that changed from the base of the PR and between 9cbe4e7 and df1fe31.

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

Comment thread key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs Outdated
Comment thread key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
Comment thread key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
Comment thread key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
Comment thread key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.24377% with 28 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.79%. Comparing base (9cbe4e7) to head (eebacae).

Files with missing lines Patch % Lines
.../wallet/managed_wallet_info/transaction_builder.rs 92.24% 28 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #922      +/-   ##
==========================================
+ Coverage   74.76%   74.79%   +0.03%     
==========================================
  Files         328      328              
  Lines       76593    76942     +349     
==========================================
+ Hits        57267    57552     +285     
- Misses      19326    19390      +64     
Flag Coverage Δ
core 77.29% <ø> (ø)
ffi 49.32% <ø> (-0.40%) ⬇️
rpc 20.00% <ø> (ø)
spv 90.97% <ø> (+0.02%) ⬆️
wallet 75.95% <92.24%> (+0.26%) ⬆️
Files with missing lines Coverage Δ
.../wallet/managed_wallet_info/transaction_builder.rs 88.65% <92.24%> (+1.59%) ⬆️

... and 19 files with indirect coverage changes

@romchornyi
romchornyi force-pushed the feat/tx-builder-op-return branch from df1fe31 to 8677e3d Compare August 4, 2026 12:52
@romchornyi

Copy link
Copy Markdown
Author

Pushed 8677e3d6: rustfmt (that was the whole Pre-commit failure) plus fixes for three of the five review comments.

Fixed

  1. change_output_size ignored change_to_first_input. select_coins_with_size was budgeting a change output only when change_addr.is_some(), while calculate_base_size budgets one whenever should_estimate_change_output() is true. A builder using add_inputs + change_to_first_input without a preset change address therefore told the selector change was free while the assembler emitted one anyway. Both now key off should_estimate_change_output(), and the stale "Matches calculate_base_size" comment is corrected.

    To be precise about severity: the change output was already counted in base_size, so this is not a fee undercount — it skewed branch-and-bound's change-vs-changeless decision. Real inconsistency, not a money bug.

  2. The legacy regression helper passed 148 as the selector's last argument. That parameter is the change-output size since fix(key-wallet): rewrite branch-and-bound coin selection (#918) #919; 148 was the per-input size under the older signature. build_unsigned_legacy now passes CHANGE_OUTPUT_SIZE/0 exactly as the pre-change production path did, so test_default_ordinary_send_matches_legacy_bytes compares like with like. Both of these came from rebasing onto fix(key-wallet): rewrite branch-and-bound coin selection (#918) #919 — the visible conflict was resolved without auditing the neighbouring call whose semantics had changed.

  3. add_output doc. It stated BIP-69 sorting as unconditional; now says it is the default and points at preserve_output_order.

Not changed, with reasons

  1. Make MAX_STANDARD_OP_RETURN_BYTES network/policy-configurable. 80 bytes is the standard relay policy this builder already targets elsewhere, and no caller needs a different value today. Threading a policy parameter through add_op_return and the construction paths is API surface this PR does not need; better raised on its own if a node with a different -datacarriersize ever has to be supported.

  2. Validate first_input.address.network against change_addr.network. When change_to_first_input is set the change address is taken from a UTXO the wallet itself selected, so it is the wallet's network by construction, and TransactionBuilder holds no network of its own to check against. The mismatch the comment guards against isn't reachable from here.

cargo test -p key-wallet — 21/21 in transaction_builder; cargo clippy -p key-wallet --all-targets clean.

@romchornyi
romchornyi force-pushed the feat/tx-builder-op-return branch from 8677e3d to c9d33a4 Compare August 4, 2026 13:59
@romchornyi

Copy link
Copy Markdown
Author

Pushed c9d33a4e — the two remaining comments are now addressed, one by change and one with evidence for declining.

Injected the OP_RETURN relay limit (comment 1)

MAX_STANDARD_OP_RETURN_BYTES is now DEFAULT_MAX_OP_RETURN_BYTES, and the ceiling lives on the builder:

pub fn set_max_op_return_bytes(mut self, max_bytes: usize) -> Self

add_op_return validates against the configured value, so a node or network with a different -datacarriersize can supply its own policy without the builder hardcoding one. The constant remains public as the default and as a pre-check for callers that must reject a payload before handing over a builder add_op_return would consume. Covered by test_max_op_return_bytes_is_configurable, which exercises a raised ceiling (accepted, and still enforced one byte past it) and a lowered one, asserting the error reports the configured maximum rather than the default.

Network validation for input-derived change (comment 4) — not adding

Address::script_pubkey() delegates to payload().script_pubkey(); the network affects only the base58/bech32 encoding, never the output script. A P2PKH address for the same hash160 produces byte-identical scriptPubKey on mainnet and testnet, so pairing a testnet Utxo with a mainnet set_change_address yields exactly the same change output either way — the check would reject a caller mistake with no observable on-chain consequence.

There is also nothing to validate against: TransactionBuilder carries no network of its own, and Address<NetworkChecked> exposes no network accessor, so implementing this would mean either widening the builder's API or adding an accessor to the dash crate. Happy to add it if you would still like the caller-hygiene guard, but it seemed the wrong trade for this PR.

cargo test -p key-wallet — 22/22 in transaction_builder; cargo clippy -p key-wallet --all-targets clean.


Unrelated heads-up for whoever picks this up downstream: rebasing onto current dev pulls in #818 (AddressInfo.used removed) and #919 (select_coins_with_size arity), which dashpay/platform has not absorbed yet — cargo check -p platform-wallet-ffi fails against this branch on those two, not on anything in this PR.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs (1)

752-756: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use thiserror for BuilderError.

BuilderError still manually implements fmt::Display and std::error::Error. Add thiserror to key-wallet/Cargo.toml, derive thiserror::Error, and move the variant messages into #[error(...)]; keep CoinSelection as a source for proper cause support.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs` around
lines 752 - 756, Add the thiserror crate to key-wallet/Cargo.toml, then refactor
the BuilderError enum to derive thiserror::Error. Move the error messages from
the manual fmt::Display implementation into #[error(...)] attributes on each
variant, including OpReturnDataTooLarge. Preserve the CoinSelection variant as a
source by using #[source] to maintain proper error-cause support. Remove the old
manual Display and Error trait implementations once all variants have error
messages.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs`:
- Around line 240-254: Derive a conservative serialized change-output size from
all eligible input scripts before coin selection, accounting for routed change
addresses that may produce WitnessProgram scripts rather than assuming
CHANGE_OUTPUT_SIZE. Use this derived size consistently in calculate_base_size()
and select_coins_with_size() whenever change_to_first_input() or another change
path is enabled, while preserving existing behavior for P2PKH change. Extend the
tests around the existing change-selection cases near the routed change logic to
cover a larger change script and verify the estimate remains sufficient.

---

Nitpick comments:
In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs`:
- Around line 752-756: Add the thiserror crate to key-wallet/Cargo.toml, then
refactor the BuilderError enum to derive thiserror::Error. Move the error
messages from the manual fmt::Display implementation into #[error(...)]
attributes on each variant, including OpReturnDataTooLarge. Preserve the
CoinSelection variant as a source by using #[source] to maintain proper
error-cause support. Remove the old manual Display and Error trait
implementations once all variants have error messages.
🪄 Autofix

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 Plus

Run ID: 15f53f53-9841-4632-8338-07da4aadf7d2

📥 Commits

Reviewing files that changed from the base of the PR and between df1fe31 and c9d33a4.

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

Comment thread key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
Estimate routed change using the largest eligible input script and reject configured network mismatches. Add regression coverage for P2WSH sizing and cross-network change routing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-review CodeRabbit has approved this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants