Skip to content

fix(bitcoin): do not create a change output below the dust limit - #812

Open
KTibow wants to merge 2 commits into
enkryptcom:developfrom
KTibow:fix/dust-change-output
Open

fix(bitcoin): do not create a change output below the dust limit#812
KTibow wants to merge 2 commits into
enkryptcom:developfrom
KTibow:fix/dust-change-output

Conversation

@KTibow

@KTibow KTibow commented Jul 24, 2026

Copy link
Copy Markdown

Problem

A Bitcoin or Litecoin send can fail at broadcast when the amount happens to leave a very small remainder, even though the amount being sent is well above the dust limit.

Root cause

The send screen checks the dust limit in three places, and all three compare the same value — the amount being sent:

  • send-transaction/index.vue:119 (alert visibility)
  • send-transaction/index.vue:133 (below-dust prop)
  • send-transaction/index.vue:355 (isInputsValid)
Number(sendAmount.value) < (props.network as BitcoinNetwork).dust

The change output is never checked against it. sendAction appends change for any remainder above zero:

const remainder = UTXOBalance.value.sub(toAmount).sub(currentFee);
if (remainder.gtn(0)) {
  txInfo.outputs.push({
    address: props.network.displayAddress(addressFrom.value),
    value: remainder.toNumber(),
  });
}

A remainder of a few hundred base units becomes a dust output, which makes the whole transaction non-standard, so nodes refuse to relay it. The limit that would have caught it is already in the network config — the check was just never pointed at the change.

It bites hardest on Litecoin, whose limit is an order of magnitude above Bitcoin's:

litecoin.ts:  dust: 0.0001      // 10,000 base units
bitcoin.ts:   dust: 0.00000546  //    546 base units
dogecoin.ts:  dust: 0.01        // 1,000,000 base units

so a remainder worth a fraction of a cent is enough to break the send.

Fix

Compare the remainder against the network's limit and leave it as fee when it falls short, which is the usual wallet behaviour for change that cannot be paid out.

The swap path in swap/libs/swap-txs.ts:61 built its change output the same way, with the same remainder > 0 test, so it is fixed alongside. Both call sites now use getDustThreshold(), which converts the limit from whole coins to base units in one place.

Testing

  • yarn test in packages/extension: 13 passing, up from 11. providers/bitcoin/tests/bitcoin.dust.test.ts covers the conversion for every configured network and the comparison at the boundary.
  • vue-tsc --build --force produces exactly the same 309 pre-existing errors as develop; none added.
  • eslint and prettier --check clean on the touched files.

Notes

I could not find an existing issue or PR covering this.

One thing I noticed while reading the swap path but did not touch, since it is a separate concern: executeSwap subtracts the network fee from outputs[0], the swap destination, rather than from the change. That means the recipient gets less than the quoted amount. Worth a look, but out of scope here.


Written by Claude Opus 5 with human oversight. Every claim above was checked against the source.

Summary by CodeRabbit

  • Bug Fixes
    • Improved Bitcoin transaction handling by applying network-specific dust thresholds.
    • Prevented uneconomical change outputs from being created; small remainders are now included as fees.
    • Applied consistent dust-limit behavior to regular Bitcoin sends and swaps.
  • Tests
    • Added coverage for dust thresholds across Bitcoin, Bitcoin testnet, Litecoin, and Dogecoin.
    • Verified change outputs at, above, and below the applicable threshold.

The send screen checks the amount being sent against the network's dust
limit in three places, but never checks the change. sendAction() appended
change for any remainder above zero, so a send that happened to leave a few
hundred base units behind produced a transaction with a dust output, which
nodes reject as non standard. The send simply failed, and the limit that
would have caught it was already sitting in the network config.

It bites hardest on litecoin, whose limit is 0.0001 LTC against bitcoin's
0.00000546 BTC, so a remainder well under a cent is enough to trigger it.

Compare the remainder against the limit and leave it as fee when it falls
short. The swap path built its change output the same way and is fixed
alongside. Both now share getDustThreshold(), which converts the limit from
whole coins to base units.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

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

Next review available in: 50 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

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: a8c7612e-eca2-461a-be68-210f58d32869

📥 Commits

Reviewing files that changed from the base of the PR and between 2898b7c and 094663c.

📒 Files selected for processing (1)
  • packages/extension/src/providers/bitcoin/tests/bitcoin.dust.test.ts

Walkthrough

Adds a network-specific Bitcoin dust threshold helper, tests its base-unit conversion and boundary behavior, and updates send and swap transaction construction to omit change outputs below the applicable threshold.

Changes

Bitcoin dust threshold handling

Layer / File(s) Summary
Dust threshold conversion and validation
packages/extension/src/providers/bitcoin/libs/utils.ts, packages/extension/src/providers/bitcoin/tests/bitcoin.dust.test.ts
Adds and exports getDustThreshold, converting network dust values to base units; tests Bitcoin-related networks and threshold boundaries.
Dust-aware change output construction
packages/extension/src/providers/bitcoin/ui/send-transaction/index.vue, packages/extension/src/ui/action/views/swap/libs/swap-txs.ts
Includes remainder outputs only when they meet or exceed the network-specific dust threshold in send and swap transactions.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: gamalielhere, kvhnuke, semajam8

🚥 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 describes the main behavior change: avoiding Bitcoin change outputs below the dust limit.
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 unit tests (beta)
  • Create PR with unit tests

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

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/extension/src/providers/bitcoin/tests/bitcoin.dust.test.ts (1)

21-27: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Exercise the changed transaction builders at the dust boundary.

These assertions only test JavaScript comparisons; they would still pass if either production condition in packages/extension/src/providers/bitcoin/ui/send-transaction/index.vue or packages/extension/src/ui/action/views/swap/libs/swap-txs.ts regressed. Add focused cases for threshold - 1, threshold, and threshold + 1 that assert whether the change output is present.

🤖 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 `@packages/extension/src/providers/bitcoin/tests/bitcoin.dust.test.ts` around
lines 21 - 27, Replace the comparison-only assertions in the dust test with
focused transaction-builder cases covering threshold - 1, threshold, and
threshold + 1, asserting whether the change output is absent or present as
appropriate. Exercise both changed builders, the send flow in the transaction UI
and the swap flow in the swap transaction library, using the existing
dust-threshold setup and helpers.
🤖 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.

Nitpick comments:
In `@packages/extension/src/providers/bitcoin/tests/bitcoin.dust.test.ts`:
- Around line 21-27: Replace the comparison-only assertions in the dust test
with focused transaction-builder cases covering threshold - 1, threshold, and
threshold + 1, asserting whether the change output is absent or present as
appropriate. Exercise both changed builders, the send flow in the transaction UI
and the swap flow in the swap transaction library, using the existing
dust-threshold setup and helpers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 647c38a1-46f3-4a6e-b782-13cc55dee7d4

📥 Commits

Reviewing files that changed from the base of the PR and between df1cd61 and 2898b7c.

📒 Files selected for processing (4)
  • packages/extension/src/providers/bitcoin/libs/utils.ts
  • packages/extension/src/providers/bitcoin/tests/bitcoin.dust.test.ts
  • packages/extension/src/providers/bitcoin/ui/send-transaction/index.vue
  • packages/extension/src/ui/action/views/swap/libs/swap-txs.ts

The boundary case compared plain numbers rather than exercising either
change output builder, so it would have stayed green if the guard itself
regressed. It asserted nothing about this repo, so it is removed rather
than left to give false confidence. getDustThreshold, which is the part
that can actually regress, is still covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@KTibow

KTibow commented Jul 24, 2026

Copy link
Copy Markdown
Author

@coderabbitai Good catch, and you're right that those assertions were worthless — they compared plain numbers and would have stayed green if either guard regressed.

I tried the suggested fix and backed it out. getBitcoinNativeTransaction is importable, but pulling it into a test drags in the extension runtime stack transitively:

Error: This script should only be loaded in a browser extension.
  webextension-polyfill/dist/browser-polyfill.js:10

and mocking that just moves the failure one layer down:

Error: Unable to detect runtime context i.e webext-bridge can't figure out what to do
  extension-bridge/src/internal.ts:130  (via apis/onMessage.ts)

Stubbing that chain is more scaffolding than this repo has ever needed — no existing test mocks any of it — and it would be a lot of machinery to cover one >=. The send-side condition is worse: sendAction lives inside <script setup> in a .vue file and isn't exported at all, so it can't be reached without mounting the component with a router, keyring and API mocks.

So rather than leave an assertion that gives false confidence, I removed it in 094663c. getDustThreshold — the conversion from whole coins to base units, which is the part that can realistically regress — stays covered. The two call sites are now a single >= against it, which I've left to review.

Happy to add the full builder test if you or the maintainers think the mocking scaffold is worth introducing here.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@KTibow Thanks for investigating this thoroughly. I agree: assertions over plain local numbers would be misleading, and the required extension-runtime/component scaffolding is disproportionate for validating two straightforward >= getDustThreshold(network) guards.

Keeping focused coverage for getDustThreshold()’s network-specific conversion while reviewing the call-site conditions directly is the right trade-off here. No additional builder test is needed for this PR.

🐇

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