Skip to content

feat(key-wallet): CoinJoin funding account + drain mode for asset-lock builds#915

Open
QuantumExplorer wants to merge 1 commit into
devfrom
feat/coinjoin-asset-lock-funding
Open

feat(key-wallet): CoinJoin funding account + drain mode for asset-lock builds#915
QuantumExplorer wants to merge 1 commit into
devfrom
feat/coinjoin-asset-lock-funding

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Jul 23, 2026

Copy link
Copy Markdown
Member

What

The asset-lock builders (build_asset_lock / build_asset_lock_with_signer) previously hardcoded their funding source to standard BIP44 accounts. This adds:

  • AssetLockFundingAccount { Bip44, CoinJoin } — which account family supplies (and signs) the funding UTXOs. CoinJoin funding lets mixed coins fund an asset lock directly, without first sweeping them through a transparent BIP44 address (which links the mixed UTXOs to a reusable transparent address for an extra hop).
  • Drain mode — consume every final UTXO of the funding account and rewrite the single credit output's value to Σ inputs − fee. The TransactionBuilder's SelectionStrategy::All path now also rewrites the asset-lock payload credit output so the payload always mirrors the on-chain OP_RETURN burn (a mismatch is consensus-invalid), and a zero-value drain is rejected with InsufficientFunds.
  • CoinJoin funding is drain-only — partial spends would need CoinJoin change re-denomination, which the builder does not do; a non-drain CoinJoin build is rejected with InvalidData.

key-wallet-ffi's build_asset_lock entry point keeps its exact behavior (BIP44, non-drain).

Why

Consumed by dashpay/platform (AssetLockFunding::DrainAccountBalance + a shielded_fund_from_asset_lock_coinjoin_drain FFI) so the dashwallet-ios post-migration "move your mixed coins" flow can offer a Shielded destination that moves the whole CoinJoin balance into the shielded pool in one transaction, with no transparent intermediate hop.

Tests

  • test_drain_coinjoin_asset_lock — all CoinJoin UTXOs consumed, burn = Σ − fee, payload credit mirrors the burn, no change output, inputs signed.
  • test_drain_requires_single_credit_output, test_coinjoin_funding_requires_drain — guard rejections.
  • Full key-wallet (551) + key-wallet-manager (48) unit suites pass.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Asset-lock transactions can now be funded from either standard wallet accounts or CoinJoin accounts.
    • CoinJoin funding supports drain-only transactions that consume all available inputs without creating change.
  • Bug Fixes
    • Drain transactions now accurately align credit output values with the burned amount after fees.
    • Added validation for invalid drain configurations, including multiple credit outputs, unsupported CoinJoin modes, and insufficient funds.

…k builds

Asset-lock builders (build_asset_lock / build_asset_lock_with_signer)
previously hardcoded the funding source to standard BIP44 accounts. This
adds:

- AssetLockFundingAccount { Bip44, CoinJoin }: which account family
  supplies (and signs) the funding UTXOs. CoinJoin lets mixed coins fund
  an asset lock directly, without first sweeping them through a
  transparent BIP44 address.
- drain mode: consume every final UTXO of the funding account and
  rewrite the single credit output's value to sum(inputs) - fee. The
  TransactionBuilder's SelectionStrategy::All path now also rewrites the
  asset-lock payload credit output so the payload always mirrors the
  on-chain OP_RETURN burn (a mismatch is consensus-invalid), and rejects
  a zero-value drain.
- CoinJoin funding is drain-only: partial spends would need CoinJoin
  change re-denomination, which the builder does not do; a non-drain
  CoinJoin build is rejected with InvalidData.

The key-wallet-ffi build_asset_lock entry point keeps its behavior
(BIP44, non-drain).

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

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Asset-lock construction now accepts BIP44 or CoinJoin funding accounts. CoinJoin funding is restricted to drain mode, drain transactions select all inputs, and asset-lock payload values are validated against the drained amount. FFI and tests use the updated API.

Changes

Asset-lock funding and drain flow

Layer / File(s) Summary
Funding-account API and routing
key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs
Adds AssetLockFundingAccount, updates both builder APIs, resolves BIP44 or CoinJoin accounts, and applies CoinJoin drain-only validation.
Asset-lock drain validation
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs, key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs
Drain assembly validates outputs and payload credits, computes the drained value, and synchronizes the payload credit amount. CoinJoin drain tests cover success and rejection cases.
Call-site migration and regression coverage
key-wallet-ffi/src/transaction.rs, key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs
Updates the FFI invocation and existing builder and signer tests to pass structured funding-account values and retain prior error assertions.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FFI
  participant ManagedWalletInfo
  participant TransactionBuilder
  FFI->>ManagedWalletInfo: build asset-lock transaction
  ManagedWalletInfo->>TransactionBuilder: select funding inputs
  TransactionBuilder-->>ManagedWalletInfo: assembled transaction
  ManagedWalletInfo-->>FFI: serialized transaction output
Loading

Possibly related PRs

Suggested labels: ready-for-review

Suggested reviewers: llbartekll, 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 summarizes the main change: CoinJoin funding support and drain mode for asset-lock builds.
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/coinjoin-asset-lock-funding

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

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

271-289: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated drain/CoinJoin validation block.

The exact same 19-line validation (drain && credit_output_fundings.len() != 1 and CoinJoin && !drain) is repeated verbatim in build_asset_lock (Lines 271-289) and build_asset_lock_with_signer (Lines 393-411). Extracting it into a small private helper avoids future drift between the two entry points.

♻️ Proposed extraction
+fn validate_drain_funding(
+    funding_account: AssetLockFundingAccount,
+    credit_output_count: usize,
+    drain: bool,
+) -> Result<(), AssetLockError> {
+    if drain && credit_output_count != 1 {
+        return Err(AssetLockError::Builder(BuilderError::InvalidData(
+            "drain asset lock requires exactly one credit output".into(),
+        )));
+    }
+    if matches!(funding_account, AssetLockFundingAccount::CoinJoin { .. }) && !drain {
+        return Err(AssetLockError::Builder(BuilderError::InvalidData(
+            "CoinJoin-funded asset locks support drain mode only".into(),
+        )));
+    }
+    Ok(())
+}

Then in both build_asset_lock and build_asset_lock_with_signer, replace the duplicated blocks with:

-        if drain && credit_output_fundings.len() != 1 {
-            return Err(AssetLockError::Builder(BuilderError::InvalidData(
-                "drain asset lock requires exactly one credit output".into(),
-            )));
-        }
-        if matches!(funding_account, AssetLockFundingAccount::CoinJoin { .. }) && !drain {
-            return Err(AssetLockError::Builder(BuilderError::InvalidData(
-                "CoinJoin-funded asset locks support drain mode only".into(),
-            )));
-        }
+        validate_drain_funding(funding_account, credit_output_fundings.len(), drain)?;

Also applies to: 393-411

🤖 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/asset_lock_builder.rs` around lines
271 - 289, Extract the duplicated drain and CoinJoin validation from
build_asset_lock and build_asset_lock_with_signer into a small private helper
that accepts the relevant funding account, drain flag, and
credit_output_fundings, returning the existing AssetLockError on invalid input.
Replace both inline validation blocks with calls to this helper, preserving the
current validation order and error messages.
🤖 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 353-380: Hoist the drained == 0 validation in the
SelectionStrategy::All path to immediately after calculating drained and before
updating the destination output or entering the special_payload branch. Return
the existing BuilderError::InsufficientFunds error for zero-value drains, then
remove the duplicate check from the AssetLockPayloadType handling while
preserving its credit output update.

---

Nitpick comments:
In `@key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs`:
- Around line 271-289: Extract the duplicated drain and CoinJoin validation from
build_asset_lock and build_asset_lock_with_signer into a small private helper
that accepts the relevant funding account, drain flag, and
credit_output_fundings, returning the existing AssetLockError on invalid input.
Replace both inline validation blocks with calls to this helper, preserving the
current validation order and error messages.
🪄 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 Plus

Run ID: aef4ebcf-7d56-47db-a153-67371ddb5891

📥 Commits

Reviewing files that changed from the base of the PR and between 70d4bf8 and 1846079.

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

Comment on lines +353 to +380
let drained = total_input.saturating_sub(selection.estimated_fee);
let [out] = tx_outputs.as_mut_slice() else {
return Err(BuilderError::InvalidData(
"SelectionStrategy::All requires exactly one output (the destination)".into(),
));
};
out.value = total_input.saturating_sub(selection.estimated_fee);
out.value = drained;
// An asset-lock drain must also rewrite the payload's credit
// output: the on-chain OP_RETURN burn (`out` above) mirrors the
// payload credit sum, and a mismatch is consensus-invalid.
if let Some(TransactionPayload::AssetLockPayloadType(payload)) =
&mut self.special_payload
{
let [credit] = payload.credit_outputs.as_mut_slice() else {
return Err(BuilderError::InvalidData(
"SelectionStrategy::All with an asset-lock payload requires exactly one \
credit output"
.into(),
));
};
if drained == 0 {
return Err(BuilderError::InsufficientFunds {
available: total_input,
required: selection.estimated_fee,
});
}
credit.value = drained;
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Zero-value drain check only guards the asset-lock branch.

drained == 0 is only rejected inside the AssetLockPayloadType branch (Lines 363-378). For a plain (non-asset-lock) drain via SelectionStrategy::All, out.value = drained (Line 359) can be set to 0 with no error — the "reject zero-value drains" guarantee doesn't hold for the general-purpose drain path this same public builder exposes.

🐛 Proposed fix: hoist the zero check above the payload-specific branch
         if self.selection_strategy == SelectionStrategy::All {
             // Drain: the single output takes the whole balance minus fee (the caller's amount is
             // ignored); no change.
             let drained = total_input.saturating_sub(selection.estimated_fee);
+            if drained == 0 {
+                return Err(BuilderError::InsufficientFunds {
+                    available: total_input,
+                    required: selection.estimated_fee,
+                });
+            }
             let [out] = tx_outputs.as_mut_slice() else {
                 return Err(BuilderError::InvalidData(
                     "SelectionStrategy::All requires exactly one output (the destination)".into(),
                 ));
             };
             out.value = drained;
             // An asset-lock drain must also rewrite the payload's credit
             // output: the on-chain OP_RETURN burn (`out` above) mirrors the
             // payload credit sum, and a mismatch is consensus-invalid.
             if let Some(TransactionPayload::AssetLockPayloadType(payload)) =
                 &mut self.special_payload
             {
                 let [credit] = payload.credit_outputs.as_mut_slice() else {
                     return Err(BuilderError::InvalidData(
                         "SelectionStrategy::All with an asset-lock payload requires exactly one \
                          credit output"
                             .into(),
                     ));
                 };
-                if drained == 0 {
-                    return Err(BuilderError::InsufficientFunds {
-                        available: total_input,
-                        required: selection.estimated_fee,
-                    });
-                }
                 credit.value = drained;
             }
         } else if change_amount > 546 {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let drained = total_input.saturating_sub(selection.estimated_fee);
let [out] = tx_outputs.as_mut_slice() else {
return Err(BuilderError::InvalidData(
"SelectionStrategy::All requires exactly one output (the destination)".into(),
));
};
out.value = total_input.saturating_sub(selection.estimated_fee);
out.value = drained;
// An asset-lock drain must also rewrite the payload's credit
// output: the on-chain OP_RETURN burn (`out` above) mirrors the
// payload credit sum, and a mismatch is consensus-invalid.
if let Some(TransactionPayload::AssetLockPayloadType(payload)) =
&mut self.special_payload
{
let [credit] = payload.credit_outputs.as_mut_slice() else {
return Err(BuilderError::InvalidData(
"SelectionStrategy::All with an asset-lock payload requires exactly one \
credit output"
.into(),
));
};
if drained == 0 {
return Err(BuilderError::InsufficientFunds {
available: total_input,
required: selection.estimated_fee,
});
}
credit.value = drained;
}
let drained = total_input.saturating_sub(selection.estimated_fee);
if drained == 0 {
return Err(BuilderError::InsufficientFunds {
available: total_input,
required: selection.estimated_fee,
});
}
let [out] = tx_outputs.as_mut_slice() else {
return Err(BuilderError::InvalidData(
"SelectionStrategy::All requires exactly one output (the destination)".into(),
));
};
out.value = drained;
// An asset-lock drain must also rewrite the payload's credit
// output: the on-chain OP_RETURN burn (`out` above) mirrors the
// payload credit sum, and a mismatch is consensus-invalid.
if let Some(TransactionPayload::AssetLockPayloadType(payload)) =
&mut self.special_payload
{
let [credit] = payload.credit_outputs.as_mut_slice() else {
return Err(BuilderError::InvalidData(
"SelectionStrategy::All with an asset-lock payload requires exactly one \
credit output"
.into(),
));
};
credit.value = drained;
}
🤖 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 353 - 380, Hoist the drained == 0 validation in the SelectionStrategy::All
path to immediately after calculating drained and before updating the
destination output or entering the special_payload branch. Return the existing
BuilderError::InsufficientFunds error for zero-value drains, then remove the
duplicate check from the AssetLockPayloadType handling while preserving its
credit output update.

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.29766% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.61%. Comparing base (19690d3) to head (1846079).
⚠️ Report is 1 commits behind head on dev.

Files with missing lines Patch % Lines
...c/wallet/managed_wallet_info/asset_lock_builder.rs 93.14% 19 Missing ⚠️
.../wallet/managed_wallet_info/transaction_builder.rs 50.00% 9 Missing ⚠️
key-wallet-ffi/src/transaction.rs 0.00% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #915      +/-   ##
==========================================
+ Coverage   74.54%   74.61%   +0.06%     
==========================================
  Files         327      328       +1     
  Lines       75032    75973     +941     
==========================================
+ Hits        55936    56688     +752     
- Misses      19096    19285     +189     
Flag Coverage Δ
core 77.29% <ø> (ø)
ffi 50.52% <0.00%> (-0.43%) ⬇️
rpc 20.00% <ø> (ø)
spv 91.20% <ø> (-0.01%) ⬇️
wallet 74.59% <90.50%> (+0.18%) ⬆️
Files with missing lines Coverage Δ
key-wallet-ffi/src/transaction.rs 0.00% <0.00%> (ø)
.../wallet/managed_wallet_info/transaction_builder.rs 86.61% <50.00%> (-0.98%) ⬇️
...c/wallet/managed_wallet_info/asset_lock_builder.rs 89.61% <93.14%> (+1.48%) ⬆️

... and 35 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