Skip to content

feat(key-wallet): let a drain carry zero-value OP_RETURN data outputs - #928

Open
HashEngineering wants to merge 3 commits into
devfrom
feat/drain-with-op-return
Open

feat(key-wallet): let a drain carry zero-value OP_RETURN data outputs#928
HashEngineering wants to merge 3 commits into
devfrom
feat/drain-with-op-return

Conversation

@HashEngineering

@HashEngineering HashEngineering commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Problem

SelectionStrategy::All requires exactly one output, so a drain cannot carry a memo. A MAYAChain deposit needs both: the vault output and the memo as an OP_RETURN beside it, or the swap cannot be matched to its quote.

That made "swap my whole balance" inexpressible. Callers had to guess the fee, subtract it themselves and send an explicit amount — which under-pays the destination whenever the guess is low. Under-delivery is not a rounding annoyance: MAYAChain executes a swap for an amount the user never agreed to, and NEAR Intents refuses the deposit outright, parking the funds for roughly an hour before refunding minus a 0.001 DASH fee.

Change

Zero-value data carriers now ride along with a drain. They claim none of the drained balance, and effective_outputs_size already prices their bytes into the fee, so the drain arithmetic is unchanged: the single value-carrying output takes total_input − estimated_fee, with no change output.

Guards kept tight:

  • an OP_RETURN with a non-zero value is refused — it would claim part of the drained balance
  • exactly one spendable output must remain; only zero-value OP_RETURN data outputs may accompany it
  • asset locks are unchanged: their single output is the OP_RETURN burn mirroring the payload credits, so it stays the value carrier
  • a drain whose inputs cannot cover the fee still returns typed InsufficientFunds

One file, +168/−4.

Tests

646 pass in key-wallet, including five new cases: the data carrier beside the destination, the fee covering its bytes, two spendable outputs still refused, a value-bearing carrier refused, and the asset-lock carrier unchanged.

Verification

Exercised on testnet through the Android SDK and dash-wallet: a max Maya deposit builds as a drain carrying an 80-byte memo and reports 27,442,985 duffs deliverable with a measured 432-duff fee. The build is signed and released without broadcasting, so the whole path is verified without a transaction reaching the network.

Notes for reviewers

This is the drain case of #922 (merged), which added the OP_RETURN output, output-order control and change-to-VIN0 — the combination that PR did not cover. It sits directly on dev and needs no other PR as a base.

Reaching it from a host also needs the SDK surface: a selectionStrategy parameter on buildSignedPayment and a way to read the engine-computed amount. That is a separate dashpay/platform change; without it the capability is present in the engine but unreachable from Kotlin.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Improved “select all” transaction handling for transactions with one spendable output and optional zero-value memo data.
    • Preserved support for asset-lock drain transactions, including burn data that carries the transaction value.
  • Bug Fixes

    • Prevented invalid transactions containing multiple spendable outputs or value-bearing memo outputs.
    • Improved fee sizing when memo data is included, helping transactions be constructed with accurate fees.

SelectionStrategy::All required exactly one output, so a drain could not
carry a memo. A MAYAChain deposit needs both: the vault output AND the
memo as an OP_RETURN beside it, or the swap cannot be matched to its
quote. That made "swap my whole balance" inexpressible — callers had to
guess the fee, subtract it themselves and send an explicit amount, which
under-pays the destination whenever the guess is low. Under-delivery is
not a rounding annoyance: MAYAChain executes a swap for an amount the user
never agreed to, and NEAR Intents refuses the deposit outright.

Zero-value data carriers now ride along with a drain. They claim none of
the drained balance, and effective_outputs_size already prices their bytes
into the fee, so the drain arithmetic (total_input - estimated_fee to the
single value carrier, no change) is unchanged.

Guards kept tight:
- an OP_RETURN with a non-zero value is refused: it would claim part of
  the drained balance
- exactly one spendable output must remain; only zero-value OP_RETURN
  data outputs may accompany it
- asset locks are unchanged: THEIR single output IS the OP_RETURN burn
  mirroring the payload credits, so it stays the value carrier
- a drain whose inputs cannot cover the fee still returns typed
  InsufficientFunds

Tests: 646 pass in key-wallet, including five new cases covering the data
carrier beside the destination, the fee covering its bytes, two spendable
outputs still refused, a value-bearing carrier refused, and the asset-lock
carrier unchanged.

Verified on testnet through the Android SDK and dash-wallet: a max Maya
deposit builds as a drain carrying an 80-byte memo and reports 27442985
duffs deliverable with a measured 432-duff fee. The build is signed and
released without broadcasting, so the path is proven without a transaction
reaching the network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 57b5f402-2840-422b-9ff3-9246b9509593

📥 Commits

Reviewing files that changed from the base of the PR and between 8209a11 and 539358d.

📒 Files selected for processing (1)
  • key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs

📝 Walkthrough

Walkthrough

SelectionStrategy::All now supports one spendable output with zero-value OP_RETURN memo outputs. It rejects multiple spendable outputs and value-bearing OP_RETURN outputs. Asset-lock drains retain their burn OP_RETURN behavior. Tests cover construction, fee sizing, validation, and regression cases.

Changes

Drain OP_RETURN support

Layer / File(s) Summary
Drain output shape and validation
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
Drain assembly accepts zero-value OP_RETURN outputs with one spendable destination. It rejects multiple spendable outputs and value-bearing OP_RETURN outputs. Asset-lock drains preserve the burn OP_RETURN value carrier.
Drain behavior coverage
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
Tests cover memo construction, OP_RETURN fee sizing, invalid output shapes, and asset-lock payload credit values.

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

Suggested labels: ready-for-review

🚥 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 summarizes the main change: allowing drain transactions to carry zero-value OP_RETURN data outputs.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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/drain-with-op-return

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: 1

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

1882-1907: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert that the memo drain fee covers its signed serialized size.

memo_fee > plain_fee passes if the fee increases by too little. Return the memo transaction from build and assert that memo_fee covers its signed-size estimate.

Proposed fix
-            b.build_unsigned_reserved().expect("drain builds").1
+            b.build_unsigned_reserved()
+                .map(|(tx, fee, _)| (tx, fee))
+                .expect("drain builds")
         };
 
-        let plain_fee = build(false);
-        let memo_fee = build(true);
+        let (_plain_tx, plain_fee) = build(false);
+        let (memo_tx, memo_fee) = build(true);
         // 60 payload bytes + OP_RETURN + push opcode + the 8-byte value and its script varint.
         assert!(
             memo_fee > plain_fee,
             "a drain carrying a {}-byte memo must cost more than a bare drain ({memo_fee} vs {plain_fee})",
             memo.len()
         );
+
+        const SIGNED_INPUT_SIZE: usize = 148;
+        const UNSIGNED_INPUT_SIZE: usize = 41;
+        let signed_size = (serialize(&memo_tx).len()
+            + memo_tx.input.len() * (SIGNED_INPUT_SIZE - UNSIGNED_INPUT_SIZE)) as u64;
+        assert!(
+            memo_fee >= signed_size,
+            "fee {memo_fee} must cover signed size {signed_size}"
+        );

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

🤖 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 1882 - 1907, Strengthen test_drain_fee_covers_the_data_carrier_bytes by
having build return the memo transaction alongside its fee, then assert memo_fee
covers the transaction’s signed serialized-size estimate rather than only
asserting it exceeds plain_fee. Preserve the existing memo and plain-drain
comparisons while using the transaction’s actual signed-size calculation and
fee-rate conventions already established in the surrounding code.

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 534-540: Replace the expect call in the transaction output
selection logic with error propagation returning BuilderError::InvalidData when
no spendable output is found. Preserve the existing asset-lock output path and
successful iterator selection behavior.

---

Nitpick comments:
In `@key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs`:
- Around line 1882-1907: Strengthen test_drain_fee_covers_the_data_carrier_bytes
by having build return the memo transaction alongside its fee, then assert
memo_fee covers the transaction’s signed serialized-size estimate rather than
only asserting it exceeds plain_fee. Preserve the existing memo and plain-drain
comparisons while using the transaction’s actual signed-size calculation and
fee-rate conventions already established in the surrounding code.
🪄 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: 39272815-cfc0-4f90-9620-33d083156066

📥 Commits

Reviewing files that changed from the base of the PR and between dca5b05 and 9ff48a9.

📒 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
The fee test asserted only that a memo-carrying drain costs more than a
bare one, which passes even when the extra fee falls short of the bytes
the carrier adds — the case that would let a drain broadcast below the
relay rate.

Return the transactions from the build closure and assert against their
serialized sizes: the fee exceeds the serialized bytes by the per-input
signature allowance, that allowance is unchanged by the carrier, and the
71 bytes the carrier adds are priced at the transaction's own rate
(equality, so under-pricing and upward drift both fail). Also assert the
deliverable output shrank by exactly that extra fee — the zero-value
carrier takes nothing from the destination beyond its bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@HashEngineering

Copy link
Copy Markdown
Contributor Author

Pushed 8209a11f to address the review note on the fee test.

The old assertion was only memo_fee > plain_fee, which passes even when the extra fee falls short of the bytes the carrier adds — the case that would let a drain go out below the relay rate. The build closure now returns the transactions, and the test measures against their serialized sizes.

The actual numbers for a 60-byte memo: the carrier adds 71 bytes and 71 duffs, so it is priced at exactly 1 duff per byte, the same rate as the rest of the transaction. That let me replace the inequality with equalities:

  • the fee exceeds the serialized bytes by the per-input signature allowance (these are unsigned transactions), and that allowance is unchanged by the carrier — both builds spend the same single input, so the carrier's whole cost shows up as size
  • fee_delta == size_delta — equality rather than >=, so under-pricing and a fee that silently drifts upward both fail
  • the deliverable output shrank by exactly that extra fee, confirming the zero-value carrier takes nothing from the destination beyond its bytes

cargo test -p key-wallet is green: 646 tests, 0 failures.

The count check immediately above proves exactly one carrier exists, so
neither arm can come back empty today — but this is library code, and a
later change to that invariant would turn into a panic in a caller's
process. Take `first_mut()` / `find(..)` as Options and return
`BuilderError::InvalidData` when neither yields an output, so the
invariant fails typed instead. Also drops the `tx_outputs[0]` index on
the asset-lock arm, which could panic on an empty output set.

Behavior is unchanged on every reachable path; 646 key-wallet tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.69697% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.54%. Comparing base (dca5b05) to head (539358d).
⚠️ Report is 3 commits behind head on dev.

Files with missing lines Patch % Lines
.../wallet/managed_wallet_info/transaction_builder.rs 94.69% 7 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #928      +/-   ##
==========================================
+ Coverage   75.07%   75.54%   +0.47%     
==========================================
  Files         328      328              
  Lines       77587    78116     +529     
==========================================
+ Hits        58245    59011     +766     
+ Misses      19342    19105     -237     
Flag Coverage Δ
core 77.29% <ø> (ø)
ffi 51.48% <ø> (+1.63%) ⬆️
rpc 20.00% <ø> (ø)
spv 91.29% <ø> (-0.10%) ⬇️
wallet 76.96% <94.69%> (+0.93%) ⬆️
Files with missing lines Coverage Δ
.../wallet/managed_wallet_info/transaction_builder.rs 89.68% <94.69%> (+0.76%) ⬆️

... and 26 files with indirect coverage changes

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