Skip to content

fix(key-wallet): cap asset locks at Core's 100-input / 20 480-byte standard limits - #1191

Closed
romchornyi wants to merge 10 commits into
devfrom
fix/asset-lock-input-cap
Closed

romchornyi wants to merge 10 commits into
devfrom
fix/asset-lock-input-cap

Conversation

@romchornyi

@romchornyi romchornyi commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1190.

A wallet whose CoinJoin account holds more than 100 coins cannot shield. Every CoinJoin → Shielded attempt builds an asset lock that spends every mixed UTXO. Dash Core (v23.1.7+, and the v24 line) refuses an asset lock with more than 100 inputs as non-standard (IsStandardSpecialTx: MAX_STANDARD_ASSET_LOCK_INPUTS{100} → assetlocktx-too-many-inputs; above 20 480 bytes → assetlocktx-too-big). Peers drop such a lock without a reject, so the wallet only ever sees a 60 s Uncertain broadcast. Platform enforces the same 100-input cap in consensus (max_asset_lock_transaction_inputs, dashpay/platform#3491). key-wallet applied only the regular-transaction cap of 500 (MAX_STANDARD_TX_INPUTS) to asset locks. On mainnet this blocked a user with 104 mixed coins for a month (support case 32500); submitting the lock through DAPI returned assetlocktx-too-many-inputs.

The limits. MAX_ASSET_LOCK_INPUTS = 100 and MAX_ASSET_LOCK_TX_SIZE = 20_480 live next to MAX_STANDARD_TX_INPUTS in transaction_builder and are re-exported from asset_lock_builder. Any TransactionBuilder build that carries an asset-lock payload is held to them:

  • Exact-amount locks. Coin selection stays within 100 inputs. When the configured strategy needs more, or runs short, the selector falls back to largest-first. An amount that cannot be funded within the cap returns BuilderError::TooManyInputs { count, max: 100 } before anything is reserved, with count the fewest inputs the amount actually needs.
  • Size. It is checked before signing, against an upper bound on the signed size (assembled unsigned transaction plus a 107-byte P2PKH scriptSig per input). If an exact-amount pick is over the limit, the builder retries largest-first. The signed paths check the real size again after signing, so a signer that produces larger scriptSigs is caught, and they release their reservation (owner-guarded). Oversize is reported as the new typed BuilderError::AssetLockTooLarge { size, max }.
  • P2PKH only. Non-P2PKH candidates are left out of an asset-lock build, because the size bound and the signer assume P2PKH.

Bounded drain. build_asset_lock / build_asset_lock_with_signer in drain mode no longer spend every final UTXO:

  • When more than 100 are available, they lock the 100 largest. If those are still over the size limit, they drop the smallest of them until the lock fits.
  • What was left behind is reported in two new AssetLockResult fields, remaining_utxo_count and remaining_duffs. They are 0 for a full drain and for exact-amount builds, and they count only final, unreserved, spendable P2PKH candidates.
  • A drain through the generic TransactionBuilder is not bounded. Over 100 candidates it returns TooManyInputs, so no caller gets a partial drain without being told.
  • Regular (non-asset-lock) builds are unchanged, including the 500-input refusal.

Not in scope.

Testing.

  • cargo test -p key-wallet --all-features: 709 passed.
  • cargo test -p key-wallet-ffi: 277 passed.
  • fmt, clippy --all-features --all-targets -D warnings and cargo doc -D warnings are clean.
  • New tests cover:
    • a 104-UTXO drain that takes the 100 largest and reports 4 left;
    • a drain at exactly 100;
    • the signer drain;
    • shrinking to the size limit and surfacing the reselection error;
    • exact amounts within and over the cap;
    • the size-aware fallback;
    • oversized signatures caught after signing, with the reservation released;
    • non-P2PKH filtering;
    • the generic-builder drain refusal;
    • the bounded selector's fallback, tie-break and zero bound.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Asset-lock transactions now support bounded selection, using the largest eligible inputs that fit the input and transaction-size limits. Any eligible funds left unspent are reported.
    • Asset-lock builds that exceed the limits now return an error instead of producing an oversized transaction.
    • Drain transactions can include zero-value OP_RETURN outputs while retaining the requirement for exactly one value-carrying output.

jeanpierreroma and others added 10 commits October 16, 2026 22:18
Dash Core refuses an asset lock with more than 100 inputs as non-standard
(MAX_STANDARD_ASSET_LOCK_INPUTS, `assetlocktx-too-many-inputs`) and peers drop
it silently; Platform enforces the same bound in consensus. The asset-lock
builder had no input cap, so a drain over an account with more than 100 UTXOs
(e.g. a heavily mixed CoinJoin account) built a transaction the network could
never accept, and the wallet only saw the broadcast time out.

- Add MAX_ASSET_LOCK_INPUTS (100) and MAX_ASSET_LOCK_TX_SIZE (20480).
- CoinSelector::with_max_inputs / TransactionBuilder::set_max_inputs: a drain
  becomes a bounded drain that spends the N largest UTXOs and leaves the rest;
  other strategies fall back to largest-first when their pick is over the cap,
  and the builder fails with BuilderError::TooManyInputs when no selection
  within the cap funds the amount. A builder carrying an asset-lock payload is
  bounded at MAX_ASSET_LOCK_INPUTS automatically; regular builds are unchanged.
- Check the signed asset lock against both limits after the build and release
  the reservation owner-guarded on failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bounded drain over 104 and 103 UTXOs (soft wallet and external signer) takes
exactly the 100 largest and reserves only them; a drain of exactly 100 is
unchanged; an exact-amount lock that needs more than 100 inputs fails with
BuilderError::TooManyInputs and reserves nothing; one that fits still builds;
the post-build standardness backstop rejects over-cap and oversized
transactions. Coin-selector tests cover the bounded drain, tie determinism,
the largest-first fallback and the reported input need.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
set_max_inputs(0) bounded coin selection at one input but the post-selection
check at zero, so every build failed with TooManyInputs. Clamp both alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- AssetLockResult gains remaining_utxo_count / remaining_duffs: what a
  bounded drain left behind (0/0 for a full drain or an exact build), so a
  partial drain is no longer indistinguishable from a full one.
- Bounded selection also falls back to largest-first when the first strategy
  reports insufficient funds, not only when its pick is over the cap.
- Oversized asset locks fail with the typed BuilderError::AssetLockTooLarge,
  checked against the signed-size upper bound inside the builder before
  anything is reserved or signed; the post-sign backstop is gone.
- Move MAX_ASSET_LOCK_INPUTS / MAX_ASSET_LOCK_TX_SIZE beside
  MAX_STANDARD_TX_INPUTS and re-export them from asset_lock_builder, removing
  the circular module import; drop the redundant explicit set_max_inputs in
  the asset-lock builder (the payload-derived bound applies).
- Clamp the input cap once (input_cap -> usize); CoinSelector::with_max_inputs
  takes the bound as given.
- Bounded drain partitions with select_nth_unstable_by instead of sorting.
- Docs: the cap makes a lock relayable by Core and within Platform's input
  cap, not every state transition small enough (InstantSend proof size, use a
  ChainLock proof); a bounded drain leaves the smallest UTXOs, so a follow-up
  drain may fall below the transition's minimum amount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Re-check the signed asset lock's actual size after signing (an external
  signer may return larger scriptSigs than the pre-sign bound assumes) and
  release the reservation owner-guarded on BuilderError::AssetLockTooLarge.
- Size-aware fallback: an exact-amount asset lock that fits the input cap but
  not the size limit retries largest-first before failing.
- Derive remaining_utxo_count / remaining_duffs from the candidates the
  builder actually offered selection (new crate-private
  build_signed_reserved_reporting) instead of re-filtering the accounts.
- Remove the unused TransactionBuilder::set_max_inputs: only an asset-lock
  payload bounds selection, so a regular drain never silently leaves coins.
- Compute is_asset_lock once in assemble_unsigned.
- Coin selection: one call path for LargestFirst, and no largest-first retry
  when the spendable pool is below even a one-input spend.
- Fix the stale add_funding_reservation_only doc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A signer returning oversized scriptSigs is caught after signing with
  AssetLockTooLarge and the reservation is released.
- An exact-amount asset lock whose smallest-first pick fits the input cap but
  not the size limit falls back to largest-first.
- A bounded selection over a pool below a one-input spend reports the first
  strategy's InsufficientFunds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- An asset-lock drain through the public TransactionBuilder over 100
  candidates now fails with BuilderError::TooManyInputs { max: 100 }. The
  bounded "largest N" drain needs the crate-private bounded_drain() flag, set
  only by the asset-lock builder, the one path that reports what was left
  behind (remaining_utxo_count / remaining_duffs). Unselected is computed only
  for that drain.
- One signed-size upper bound (signed_size_upper_bound, from the assembled
  transaction) drives both the size-aware fallback and the pre-sign check.
- A bounded drain whose largest N exceed MAX_ASSET_LOCK_TX_SIZE drops its
  smallest inputs until the bound fits instead of failing.
- The builder skips its largest-first size retry when selection already ran
  or fell back to largest-first (CoinSelector::select_coins_reporting).
- The post-sign actual-size check moves into the builder's signed path, so
  every signed asset-lock build is covered; the builder releases its own
  reservation owner-guarded on that error.
- CoinSelector::with_max_inputs treats 0 as 1; doc no longer claims a builder
  clamp.
- Selection is assembled through unsigned_from_selection so a retry does not
  consume the builder state.
- Move a test inserted inside another test's doc comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in shrink

- The asset-lock size bound prices every input as a P2PKH spend, so an
  asset-lock build now refuses a non-P2PKH candidate up front with the typed
  BuilderError::AssetLockNonP2pkhInput { outpoint }, before anything is
  selected or reserved. build_unsigned_reserved documents that an external
  signer must keep standard 107-byte P2PKH scriptSigs (no post-sign check on
  that path). Test UTXO dummies now carry their address's P2PKH script.
- Bounded-drain shrink: each dropped input lowers the bound by exactly
  TX_INPUT_SIZE, so compute the kept count once and reselect once; otherwise
  fail with AssetLockTooLarge for the transaction actually checked.
- Docs: remaining_* include inputs dropped by the cap or the size shrink and
  must be read to learn about a partial drain; both build_asset_lock and
  build_asset_lock_with_signer are bounded-drain paths; an InstantSend proof
  for a lock near the cap may exceed Platform's transition size limit (use a
  ChainLock proof, cf. dashpay/platform#5460).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y errors

- Asset-lock builds no longer refuse when any candidate is non-P2PKH: such
  outputs are simply not offered to selection (one odd coin must not block
  every asset lock from the account). The unreleased AssetLockNonP2pkhInput
  variant is dropped.
- Size retries (bounded-drain shrink, exact-amount largest-first) propagate a
  failed reselection's real error, and AssetLockTooLarge reports the
  smallest transaction actually checked.
- unsigned_from_selection takes outputs and payload by value: the single-pass
  build moves them; a retry reuses the payload of the transaction it rejects.
- Remove CoinSelector::select_coins_reporting; the builder skips its
  largest-first retry only when LargestFirst is the configured strategy.
- remaining_* docs say exactly what is counted; bounded_drain documents that
  only the reporting signed path may be used with it.
- One canonical description of the asset-lock limits and bounded drain on
  build_asset_lock, linked from the constants, the builder flag and
  add_funding_reservation_only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cap check

- Replace the 0 sentinel for "not an asset lock" with an Option gated on
  is_asset_lock.
- Fold the first pick's and the retry's TooManyInputs check into one
  select_within_cap helper.
- Document that an exact-amount asset lock never drops a change output to
  fit the size limit (change is only emitted above dust, so dropping it
  would burn that value as fee).

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

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: dashpay/rust-dashcore/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 03bedab7-59c5-4ce3-9a7d-815e8809e855
📥 Commits

Reviewing files that changed from the base of the PR and between d35a8d6 and 7fad4a3.

📒 Files selected for processing: 4
  • key-wallet/src/test_utils/utxo.rs
  • key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs
  • key-wallet/src/wallet/managed_wallet_info/coin_selection.rs
  • key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📝 Walkthrough

Asset-lock builds now enforce input and serialized-size limits. Bounded drains select eligible UTXOs within those limits and report eligible UTXOs left unspent. Exact-amount builds remain capped and return errors when they exceed the limits.

Changes: Asset-lock limits and bounded drains

Layer / File(s) Summary
Bounded coin selection
key-wallet/src/wallet/managed_wallet_info/coin_selection.rs
CoinSelector accepts an optional input limit. Bounded drains select the largest spendable UTXOs; other strategies can fall back to largest-first selection.
Asset-lock transaction limits
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
TransactionBuilder enforces asset-lock input and serialized-size limits. Oversized signed transactions release their reservations and return AssetLockTooLarge.
Bounded asset-lock reporting
key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs, key-wallet/src/test_utils/utxo.rs
Wallet-key and external-signer builders report eligible UTXOs left unselected. Tests cover bounded drains, exact-amount builds, size limits, and remainder reporting. Dummy UTXOs now use their address script.

Priority: ➖ Normal

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

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant AssetLockBuilder
  participant TransactionBuilder
  participant CoinSelector
  participant Signer
  AssetLockBuilder->>TransactionBuilder: request signed asset-lock build with reporting
  TransactionBuilder->>CoinSelector: select eligible inputs within configured limit
  CoinSelector-->>TransactionBuilder: selected inputs and unselected candidates
  TransactionBuilder->>TransactionBuilder: assemble transaction and reserve inputs
  TransactionBuilder->>Signer: sign asset-lock transaction
  Signer-->>TransactionBuilder: signed transaction
  TransactionBuilder->>TransactionBuilder: check serialized size and release reservation if oversized
  TransactionBuilder-->>AssetLockBuilder: return transaction, reservation, and unselected totals
Loading

Suggested reviewers: zocolini, quantumexplorer

Merge Risk: ⚪ Minimal · up to 7fad4

Asset-lock builds now stay within Dash Core's 100-input and 20,480-byte limits. Bounded drains report which eligible UTXOs were left unspent, and exact-amount builds that cannot fit return typed errors. No concrete defect was identified in the change. Downstream consumers may need small updates for the new error variant and result fields.

Pre-merge checks: Passed 5
Check name Status Explanation
Linked Issues check Passed Issue #1190 requires asset-lock builds to stay within 100 inputs and 20,480 bytes, bounded drains, typed exact-amount failures, and reservation release after an oversized signed build. The reviewed ch…
Out of Scope Changes check Passed The changes remain connected to issue #1190. The transaction-builder and coin-selection changes enforce asset-lock limits and bounded-drain behavior. The result fields, documentation, fixtures, and te…
Docstring Coverage Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 60 functions across 4 files.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: enforcing Core's 100-input and 20,480-byte limits for key-wallet asset locks.
Full details: Linked Issues check

Explanation

Issue #1190 requires asset-lock builds to stay within 100 inputs and 20,480 bytes, bounded drains, typed exact-amount failures, and reservation release after an oversized signed build. The reviewed changes add and re-export both limits, apply asset-lock-specific selection and size checks in TransactionBuilder, retry exact-amount selection with largest-first, and return TooManyInputs or AssetLockTooLarge. Drain builders select bounded largest UTXOs, shrink for size, filter to P2PKH candidates, report remaining_utxo_count and remaining_duffs, and release reservations when the signed size fails. Generic asset-lock drains fail over the cap, while regular transaction behavior keeps the 500-input limit. The summary and focused source evidence also show bounded selector fallback behavior and coverage for the required cases.

Full details: Out of Scope Changes check

Explanation

The changes remain connected to issue #1190. The transaction-builder and coin-selection changes enforce asset-lock limits and bounded-drain behavior. The result fields, documentation, fixtures, and tests support partial-drain reporting and validation. The noted InstantSend proof, Platform transition, and platform-wallet reporting work is explicitly outside this pull request and is not presented as an unmet coding requirement.

✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Waiting for bot review — coderabbitai not yet. Wait for the missing reviews, or a writer can post /skip-bots to proceed without them; blocking findings still need addressing.
Full checklist in the description.

@github-actions github-actions Bot added the waiting-bots Waiting for the review bots to report on this head label Oct 16, 2026
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Bots are done — your move: post /self-reviewed.
Full checklist in the description.

@github-actions github-actions Bot added waiting-self-review Waiting for the author to post /self-reviewed and removed waiting-bots Waiting for the review bots to report on this head labels Oct 16, 2026
@codecov

codecov Bot commented Oct 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.21216% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.05%. Comparing base (d35a8d6) to head (7fad4a3).

Files with missing lines Patch % Lines
.../wallet/managed_wallet_info/transaction_builder.rs 96.61% 11 Missing ⚠️
...c/wallet/managed_wallet_info/asset_lock_builder.rs 98.77% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev    #1191      +/-   ##
==========================================
+ Coverage   78.86%   79.05%   +0.19%     
==========================================
  Files         304      304              
  Lines       81381    82126     +745     
==========================================
+ Hits        64178    64924     +746     
+ Misses      17203    17202       -1     
Flag Coverage Δ
core 77.59% <ø> (ø)
ffi 50.69% <ø> (+<0.01%) ⬆️
rpc 52.05% <ø> (ø)
spv 92.82% <ø> (+0.04%) ⬆️
wallet 82.70% <98.21%> (+0.50%) ⬆️
Files with missing lines Coverage Δ
...t/src/wallet/managed_wallet_info/coin_selection.rs 95.24% <100.00%> (+1.29%) ⬆️
...c/wallet/managed_wallet_info/asset_lock_builder.rs 95.06% <98.77%> (+1.24%) ⬆️
.../wallet/managed_wallet_info/transaction_builder.rs 94.57% <96.61%> (+0.45%) ⬆️

... and 7 files with indirect coverage changes

@romchornyi
romchornyi marked this pull request as draft October 16, 2026 21:10
@github-actions github-actions Bot removed the waiting-self-review Waiting for the author to post /self-reviewed label Oct 16, 2026
llbartekll added a commit to dashpay/platform that referenced this pull request Oct 16, 2026
…t, not the standard-tx limit

An asset lock may carry at most `max_asset_lock_transaction_inputs` (100)
inputs: Dash Core's relay policy drops a larger lock silently
(`assetlocktx-too-many-inputs`) and Platform consensus validates the same
bound. The drain only inherited key-wallet's 500-input standard-transaction
limit, so a 101–500-input balance built and broadcast a lock that could
never land (seen on mainnet in support case 32500).

Read the cap from the protocol version, judge the built drain payload
against it in the same abandon path as the drain floor (reservation
released, nothing on the wire) and refuse with the typed
`AssetLockTooManyInputs`; have the preview report `InputLimitMayBeExceeded`
above it before sizing the payload. Tests, registry and host fixture texts
follow. Scoped to drains; dashpay/rust-dashcore#1191 brings the same bound
to every asset-lock build in key-wallet.

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

@ZocoLini ZocoLini left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

1400 lines for somehting that we already have implemented for a different type of transaction its too much, i think this can reuse what we already have for the 500-input cap instead of adding new machinery:

  • Make the existing TooManyInputs check use a per-type limit: 100 for asset locks, 500 otherwise.
  • For the drain, seed the 100 largest coins with add_inputs + add_funding_reservation_only, so the build only spends those.
  • If exact-amount selection picks more than the limit, retry largest-first.

100 P2PKH inputs stay well under 20 480 bytes, so the size checks aren't needed, and the remaining-funds fields can go: the caller can read them from the balance. That should be a few dozen lines plus a test.

@romchornyi

Copy link
Copy Markdown
Contributor Author

Closing in favour of a wallet-side fix: the app consolidates CoinJoin coins before the drain asset lock (dashpay/dashwallet-ios#1221), the same approach as the Android wallet. The underlying SDK gap stays tracked in #1190.

@romchornyi romchornyi closed this Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants