Skip to content

Enforce a minimum fee rate for deposit sweep transactions - #4172

Merged
piotr-roslaniec merged 7 commits into
threshold-network:mainfrom
mswilkison:fix/deposit-sweep-min-fee
Jul 22, 2026
Merged

Enforce a minimum fee rate for deposit sweep transactions#4172
piotr-roslaniec merged 7 commits into
threshold-network:mainfrom
mswilkison:fix/deposit-sweep-min-fee

Conversation

@mswilkison

@mswilkison mswilkison commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Fixes part A of #4171: a deposit sweep could be broadcast at the ~1 sat/vByte relay floor (a low-but-valid oracle estimate in an uncongested mempool), and since sweeps are not RBF-enabled it could get stuck and jam the wallet. Observed live on mainnet.

Change

estimateDepositsSweepFee:

The floor constant documents that it (and the buffer) is a stopgap for the current non-RBF path and should be revisited when RBF lands; the P2SH-vsize interaction is noted at the floor site.

Tests

Unit test asserts a low estimate is floored to 5, an estimate above the floor is buffered by 25%, and a Bridge maximum below the floor returns an error (message-pinned to the floor branch). The deposit-sweep scenario testdata is updated for the buffered fee. go test ./pkg/tbtcpg/... passes.

Follow-ups

Summary by CodeRabbit

  • Bug Fixes
    • Improved deposit sweep fee estimation to avoid underpriced, non-RBF-able sweeps stalling in the mempool.
    • Introduced a minimum safe fee-rate floor and updated failure behavior when safe fees can’t fit within the Bridge maximum.
    • Added a 25% buffered fee-rate (rounded up), enforced minimum/maximum caps, and updated informational sat/vByte reporting.
    • Enhanced proposal-time warning logs when minimum-safe-fee behavior prevents fee selection.
  • Tests
    • Added coverage for minimum-floor, buffer, and maximum-cap fee behaviors, including rounding and error cases.
    • Updated the expected sweep fee in the proposal scenario test data.

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cb098ea7-79f9-41d6-ab44-2de9893b27d4

📥 Commits

Reviewing files that changed from the base of the PR and between 54cf39b and 1b58142.

📒 Files selected for processing (3)
  • pkg/tbtcpg/deposit_sweep.go
  • pkg/tbtcpg/deposit_sweep_fee_test.go
  • pkg/tbtcpg/internal/test/testdata/propose_sweep_scenario_0.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • pkg/tbtcpg/internal/test/testdata/propose_sweep_scenario_0.json
  • pkg/tbtcpg/deposit_sweep_fee_test.go
  • pkg/tbtcpg/deposit_sweep.go

📝 Walkthrough

Walkthrough

Deposit sweep fee estimation now enforces a 5 sat/vByte minimum, applies a 25% ceiling-rounded buffer, validates Bridge maximum limits, and caps final fees. Tests and sweep proposal fixture expectations are updated.

Changes

Deposit sweep fee estimation

Layer / File(s) Summary
Sweep fee policy and validation
pkg/tbtcpg/deposit_sweep.go
Adds the minimum fee-rate constant, validates minimum-safe fees against the Bridge maximum, applies buffered and floored fee rates, updates error documentation, and logs estimation failures during sweep proposal creation.
Fee estimation coverage and expectations
pkg/tbtcpg/deposit_sweep_fee_test.go, pkg/tbtcpg/internal/test/testdata/propose_sweep_scenario_0.json
Adds table-driven coverage for floor, buffer, cap, and error behavior, and updates the expected sweep transaction fee.

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

Possibly related issues

  • Issue 4171 — Adds the minimum fee floor and buffer for the low-fee deposit sweep problem; RBF or fee-bumping recovery remains outside this change.

Suggested reviewers: lrsaturnino

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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: enforcing a minimum fee rate for deposit sweep transactions.
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.

Comment thread pkg/tbtcpg/deposit_sweep.go Outdated
Comment thread pkg/tbtcpg/deposit_sweep.go Outdated
Comment thread pkg/tbtcpg/deposit_sweep_fee_test.go Outdated
@lrsaturnino

Copy link
Copy Markdown
Member

This PR only covers deposit sweeps. EstimateRedemptionFee (redemptions.go:503), EstimateMovingFundsFee (moving_funds.go:659), and EstimateMovedFundsSweepFee (moved_funds_sweep.go:414) still return the raw oracle fee with no minimum at all. Recommend a follow-up PR applying the same floor to all three — this one is already scoped to deposit sweeps only, so it's not a blocker for merging this PR itself.

@lrsaturnino

Copy link
Copy Markdown
Member

Two small things in the description are out of date with the diff: it says the floor is applied "before" the Bridge maximum-fee check, but the code checks the cap first (line 660) and floors after (line 670). It also says the low-fee test was "flagged rather than included," but deposit_sweep_fee_test.go is already in this PR and covers that case. Worth a quick update so reviewers aren't working from a stale description.

@mswilkison
mswilkison force-pushed the fix/deposit-sweep-min-fee branch from aa730ce to a21be7f Compare July 18, 2026 12:56
@lrsaturnino

lrsaturnino commented Jul 19, 2026

Copy link
Copy Markdown
Member

This closes the fee-floor gap on the generation side (estimateDepositsSweepFee), but nothing enforces it on the follower-validation side. The Go-side ValidateDepositSweepProposal does no independent fee check — it delegates to the chain validator, which on Ethereum calls the deployed WalletProposalValidator. That contract only requires sweepTxFee > 0 plus the existing upper bound — no minimum. A follower accepts the leader's proposed fee and signs without re-running the estimator (coordination.go#L728-L815, deposit_sweep.go#L196-L239), so an unpatched or misbehaving leader can still get a sweep at the ~1 sat/vByte client-side floor signed — the #4171 scenario — as long as it's the one proposing.

@lrsaturnino

lrsaturnino commented Jul 19, 2026

Copy link
Copy Markdown
Member

The fee floor/buffer this PR adds is computed off a P2WSH-only deposit-input estimate — EstimateDepositsSweepFee always calls AddScriptHashInputs(depositsCount, depositScriptByteSize, true) (isWitness=true), and the function's own doc comment already notes that real sweeps can contain legacy P2SH deposits, which "may be underestimated in some rare cases" (pkg/tbtcpg/deposit_sweep.go:561-563, 638).

That pre-existing underestimation interacts directly with the new floor: since the floor/buffer is applied to the estimated vsize rather than the actual on-wire vsize, a sweep with a meaningful share of P2SH deposits can still be assembled at an effective rate well below the intended 5 sat/vByte.

@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)
pkg/tbtcpg/deposit_sweep_fee_test.go (1)

27-52: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test case to cover cap validation.

The PR summary states that tests cover cap validation, but there doesn't appear to be a case explicitly verifying that a buffered estimate exceeding the cap is correctly truncated down to the maximum fee. Consider adding a test case to guarantee this logic branch is fully exercised.

💡 Proposed test case for cap validation
 		"minimum floor above the cap returns an error": {
 			estimateSatPerVByte: 1,
 			// Cap sits below 5*size (the floor) but above the raw fee (1*size),
 			// so the minimum-fee check must error rather than lower the fee. The
 			// substring pins this to the floor-exceeds-cap branch specifically,
 			// distinguishing it from the raw-fee-exceeds-cap error.
 			perDepositMaxFee:    uint64(3 * size),
 			expectErrorContains: "minimum safe sweep fee",
 		},
+		"buffered estimate above the cap is capped": {
+			estimateSatPerVByte:    20,
+			perDepositMaxFee:       uint64(22 * size),
+			expectedSatPerVByteFee: 22,
+		},
 	}
🤖 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 `@pkg/tbtcpg/deposit_sweep_fee_test.go` around lines 27 - 52, Add a table case
to the existing tests map in deposit sweep fee coverage where the 25%-buffered
estimate exceeds perDepositMaxFee, and assert the returned
expectedSatPerVByteFee is capped at the maximum allowed fee rather than the
buffered estimate. Keep the existing floor and error cases unchanged, and choose
inputs that exercise truncation without triggering an error.
🤖 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 `@pkg/tbtcpg/deposit_sweep_fee_test.go`:
- Around line 27-52: Add a table case to the existing tests map in deposit sweep
fee coverage where the 25%-buffered estimate exceeds perDepositMaxFee, and
assert the returned expectedSatPerVByteFee is capped at the maximum allowed fee
rather than the buffered estimate. Keep the existing floor and error cases
unchanged, and choose inputs that exercise truncation without triggering an
error.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 60647019-d7df-46fa-aedc-6ce2c3dac435

📥 Commits

Reviewing files that changed from the base of the PR and between 038b7ce and 6e64339.

📒 Files selected for processing (3)
  • pkg/tbtcpg/deposit_sweep.go
  • pkg/tbtcpg/deposit_sweep_fee_test.go
  • pkg/tbtcpg/internal/test/testdata/propose_sweep_scenario_0.json

mswilkison added a commit to mswilkison/keep-core that referenced this pull request Jul 20, 2026
Cover the branch where the 25%-buffered fee exceeds the Bridge maximum and
is bounded down to the cap (per CodeRabbit review on threshold-network#4172).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mswilkison added a commit to mswilkison/keep-core that referenced this pull request Jul 20, 2026
Extract the 25% buffer + minimum floor + Bridge-max bound into a shared
applyWalletTxFeeFloor helper and a shared minWalletTxSatPerVByteFee const,
then apply it to redemptions, moving funds, and moved funds sweeps in
addition to deposit sweeps. These are all non-RBF wallet transactions that
jam the wallet if they get stuck at the relay floor, so the same protection
applies (per lrsaturnino review on threshold-network#4172).

EstimateRedemptionFee now takes the redemption tx max total fee so the floor
can be bounded by it; the caller fetches it from GetRedemptionParameters.
deposit sweep fee estimation is refactored onto the shared helper with no
behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lrsaturnino
lrsaturnino previously approved these changes Jul 20, 2026

@lrsaturnino lrsaturnino left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@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)
pkg/tbtcpg/deposit_sweep_fee_test.go (1)

11-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid duplicating depositScriptByteSize here. pkg/tbtcpg/deposit_sweep_fee_test.go is in package tbtcpg_test, so it can’t reference the unexported constant in pkg/tbtcpg/deposit_sweep.go. Expose a tiny test-facing helper or move this check into the internal package so the literal doesn’t drift.

🤖 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 `@pkg/tbtcpg/deposit_sweep_fee_test.go` around lines 11 - 27, Update
sweepVirtualSize in the sweep fee tests to avoid hardcoding the deposit script
size literal; expose a minimal test-facing helper for the existing
depositScriptByteSize value or relocate the check into the internal tbtcpg
package, ensuring the estimator continues using the shared constant and cannot
drift.
🤖 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 `@pkg/tbtcpg/deposit_sweep_fee_test.go`:
- Around line 11-27: Update sweepVirtualSize in the sweep fee tests to avoid
hardcoding the deposit script size literal; expose a minimal test-facing helper
for the existing depositScriptByteSize value or relocate the check into the
internal tbtcpg package, ensuring the estimator continues using the shared
constant and cannot drift.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b3424641-c37a-46d7-b3a9-ca52d0c1f7ff

📥 Commits

Reviewing files that changed from the base of the PR and between 8b8972e and 54cf39b.

📒 Files selected for processing (2)
  • pkg/tbtcpg/deposit_sweep.go
  • pkg/tbtcpg/deposit_sweep_fee_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/tbtcpg/deposit_sweep.go

mswilkison and others added 7 commits July 22, 2026 11:12
A deposit sweep could be broadcast at the 1 sat/vByte relay floor when the fee
oracle returned an unusably low estimate in an uncongested mempool. Because
sweeps are not RBF-enabled, such a transaction can get stuck in the mempool and
jam the wallet: no new sweep can be built while the previous one is unconfirmed,
so eligible deposits pile up until it confirms or is evicted.

Clamp the estimated sweep fee up to a conservative minimum
(minSweepTxSatPerVByteFee) before the existing Bridge maximum-fee check, so a
sweep is never broadcast near the relay floor.

Refs threshold-network#4171

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Apply the Bridge maximum-fee check to the raw estimate before raising the
  fee to the minimum, and bound the minimum itself by the maximum, so raising
  the fee to the floor can never spuriously trigger the "exceeds maximum fee"
  error (the two were previously coupled in the wrong order).
- Add a unit test asserting a low estimate is floored to the minimum while a
  healthy estimate is left unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eeds the cap

- Return an error when the minimum safe fee (minSweepTxSatPerVByteFee sat/vByte)
  exceeds the Bridge maximum, instead of silently lowering the fee below the
  floor this PR is meant to enforce (lrsaturnino review).
- Apply a 25% buffer over the oracle estimate (max(floor, ceil(rate*1.25))) so
  the fee keeps a margin during the estimate-to-broadcast delay and stays
  adaptive under congestion, per the threshold-network#4171 reference design.
- Add a test asserting the cap-below-floor case returns an error, and update the
  buffered-fee expectation in the deposit sweep scenario testdata.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Assert the error message contains "minimum safe sweep fee" so the test
distinguishes the floor-exceeds-cap error from the raw-fee-exceeds-cap
error, rather than accepting any non-nil error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Note that the static floor + 25% buffer exist only because the current
sweep path is fire-and-forget and non-RBF; when RBF/fee-bumping (Part B,
threshold-network#4171) lands the policy should be revisited rather than carried forward.

Also note the floor is computed against a witness-only vsize estimate, so
sweeps with legacy P2SH deposits can land slightly below the floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cover the branch where the 25%-buffered fee exceeds the Bridge maximum and
is bounded down to the cap (per CodeRabbit review on threshold-network#4172).

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

Address review findings on the deposit sweep minimum-fee change:

- Assert the on-chain TotalFee in every success case, not just the
  informational SatPerVByteFee (which is lossy through math.Round).
- Cover the raw-estimate-exceeds-max error branch, previously untested.
- Exercise multi-deposit sweeps (N>1) on both the happy path and the
  floor-exceeds-cap error branch, where tx size grows sub-linearly while
  the Bridge max grows linearly.
- Log fee-estimation failures at WARN in ProposeDepositsSweep so a
  safe-minimum-fee abort is distinguishable from a benign no-op.
- Document the new minimum-safe-fee error in the exported godoc, note the
  buffer's dependency on exact rate truncation, and the narrowing-cast
  overflow invariant.
@piotr-roslaniec
piotr-roslaniec force-pushed the fix/deposit-sweep-min-fee branch from 54cf39b to 1b58142 Compare July 22, 2026 11:12
@piotr-roslaniec
piotr-roslaniec merged commit 6d97e5a into threshold-network:main Jul 22, 2026
30 checks passed
piotr-roslaniec pushed a commit to mswilkison/keep-core that referenced this pull request Jul 22, 2026
Extract the 25% buffer + minimum floor + Bridge-max bound into a shared
applyWalletTxFeeFloor helper and a shared minWalletTxSatPerVByteFee const,
then apply it to redemptions, moving funds, and moved funds sweeps in
addition to deposit sweeps. These are all non-RBF wallet transactions that
jam the wallet if they get stuck at the relay floor, so the same protection
applies (per lrsaturnino review on threshold-network#4172).

EstimateRedemptionFee now takes the redemption tx max total fee so the floor
can be bounded by it; the caller fetches it from GetRedemptionParameters.
deposit sweep fee estimation is refactored onto the shared helper with no
behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
piotr-roslaniec pushed a commit that referenced this pull request Jul 23, 2026
Extract the 25% buffer + minimum floor + Bridge-max bound into a shared
applyWalletTxFeeFloor helper and a shared minWalletTxSatPerVByteFee const,
then apply it to redemptions, moving funds, and moved funds sweeps in
addition to deposit sweeps. These are all non-RBF wallet transactions that
jam the wallet if they get stuck at the relay floor, so the same protection
applies (per lrsaturnino review on #4172).

EstimateRedemptionFee now takes the redemption tx max total fee so the floor
can be bounded by it; the caller fetches it from GetRedemptionParameters.
deposit sweep fee estimation is refactored onto the shared helper with no
behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lrsaturnino added a commit to lionakhnazarov/keep-core that referenced this pull request Jul 24, 2026
Resolve conflicts by final behavior, preserving the candidate's OOB
hardening and the no-cap / exact-target SPV semantics while taking main's
threshold-network#4039 work-accumulation model, threshold-network#4165 script-type isolation, threshold-network#4172 sweep
fee floor, and the threshold-network#3998 v1-random-beacon extraction.

SPV conflict resolution:
- pkg/maintainer/spv/spv.go: keep candidate side (no 144-header cap, header
  cache, exact decoded-target DIFF1 comparison + zero-target guard). Removed
  the `const maxProofHeaders = 144` that the 3-way merge silently re-added
  from main (merge-base lacked it, so the candidate's iteration-1 removal was
  invisible to the merge).
- pkg/maintainer/spv/deposit_sweep.go: combine candidate's bounds-checked
  OutputAt accessor with main's bitcoin.GetScriptType classification (threshold-network#4165).
- pkg/maintainer/spv/spv_test.go: keep candidate's superset of cases
  (non-DIFF1 rounding, zero-target, 145- and 201-header long-run cases);
  drop main's removed "minimum difficulty run exceeds header bound" cap test.

Accepted main's deletion of the extracted v1 random beacon and dead Ropsten
infra. NOTE: this drops two candidate-only v1 hardenings that main moved out
of this repo — require(entryCreatedSuccess) in KeepRandomBeaconOperator.sol
and the registry-approval modifier in KeepRandomBeaconServiceImplV1.sol. If
those are in security scope they must be ported to the keep-core-v1 repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lrsaturnino added a commit to lionakhnazarov/keep-core that referenced this pull request Jul 24, 2026
Resolve conflicts by final behavior, preserving the candidate's OOB
hardening and the no-cap / exact-target SPV semantics while taking main's
threshold-network#4039 work-accumulation model, threshold-network#4165 script-type isolation, threshold-network#4172 sweep
fee floor, and the threshold-network#3998 v1-random-beacon extraction.

SPV conflict resolution:
- pkg/maintainer/spv/spv.go: keep candidate side (no 144-header cap, header
  cache, exact decoded-target DIFF1 comparison + zero-target guard). Removed
  the `const maxProofHeaders = 144` that the 3-way merge silently re-added
  from main (merge-base lacked it, so the candidate's iteration-1 removal was
  invisible to the merge).
- pkg/maintainer/spv/deposit_sweep.go: combine candidate's bounds-checked
  OutputAt accessor with main's bitcoin.GetScriptType classification (threshold-network#4165).
- pkg/maintainer/spv/spv_test.go: keep candidate's superset of cases
  (non-DIFF1 rounding, zero-target, 145- and 201-header long-run cases);
  drop main's removed "minimum difficulty run exceeds header bound" cap test.

Accepted main's deletion of the extracted v1 random beacon and dead Ropsten
infra. NOTE: this drops two candidate-only v1 hardenings that main moved out
of this repo — require(entryCreatedSuccess) in KeepRandomBeaconOperator.sol
and the registry-approval modifier in KeepRandomBeaconServiceImplV1.sol. If
those are in security scope they must be ported to the keep-core-v1 repo.
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.

3 participants