-
Notifications
You must be signed in to change notification settings - Fork 87
Add follower-side soft check for below-floor sweep fees #4180
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
mswilkison
wants to merge
9
commits into
threshold-network:main
from
mswilkison:feat/follower-sweep-fee-soft-check
Closed
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
e78e624
Enforce a minimum fee rate for deposit sweep transactions
mswilkison 5ef1c49
Address review: bound sweep-fee minimum by the Bridge maximum + add test
mswilkison a21be7f
Address review: buffer the sweep fee 25% and error when the floor exc…
mswilkison f630e8d
Pin sweep-fee error test to the floor-exceeds-cap branch
mswilkison 6e64339
Document floor's non-RBF stopgap status and P2SH sizing caveat
mswilkison dfcedb7
Add test for cap-bounded buffered sweep fee
mswilkison 9785c88
Apply the safe minimum sweep-fee floor to all wallet transactions
mswilkison 7d8909e
Add follower-side soft check for below-floor sweep fees
mswilkison d6bba6f
test: guard mirrored sweep-fee constants against drift
piotr-roslaniec File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| package tbtc_test | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/keep-network/keep-core/pkg/tbtcpg" | ||
| ) | ||
|
|
||
| // TestSweepFeeConstantsMirrorTbtcpg guards the sweep-fee constants that | ||
| // pkg/tbtc/deposit_sweep.go duplicates from pkg/tbtcpg. The follower-side soft | ||
| // check (threshold-network/keep-core#4171) recomputes the safe minimum sweep | ||
| // fee, but pkg/tbtcpg imports pkg/tbtc, so pkg/tbtc cannot import the canonical | ||
| // constants without a dependency cycle and hand-copies them instead. | ||
| // | ||
| // This test lives in the external tbtc_test package precisely because that | ||
| // package can import pkg/tbtcpg without forming the cycle. It pins the canonical | ||
| // tbtcpg values to the literals mirrored in pkg/tbtc/deposit_sweep.go | ||
| // (minSweepTxSatPerVByteFee and depositScriptByteSize). If the canonical values | ||
| // drift, this test fails, forcing the pkg/tbtc mirrors - and these expected | ||
| // literals - to be updated together. | ||
| func TestSweepFeeConstantsMirrorTbtcpg(t *testing.T) { | ||
| // Mirrored by pkg/tbtc/deposit_sweep.go:minSweepTxSatPerVByteFee. | ||
| const expectedMinWalletTxSatPerVByteFee = 5 | ||
| // Mirrored by pkg/tbtc/deposit_sweep.go:depositScriptByteSize. | ||
| const expectedDepositScriptByteSize = 126 | ||
|
|
||
| if tbtcpg.MinWalletTxSatPerVByteFee != expectedMinWalletTxSatPerVByteFee { | ||
| t.Errorf( | ||
| "tbtcpg.MinWalletTxSatPerVByteFee is [%d]; the pkg/tbtc mirror "+ | ||
| "minSweepTxSatPerVByteFee [%d] is now stale and must be updated "+ | ||
| "along with this test", | ||
| tbtcpg.MinWalletTxSatPerVByteFee, | ||
| expectedMinWalletTxSatPerVByteFee, | ||
| ) | ||
| } | ||
|
|
||
| if tbtcpg.DepositScriptByteSize != expectedDepositScriptByteSize { | ||
| t.Errorf( | ||
| "tbtcpg.DepositScriptByteSize is [%d]; the pkg/tbtc mirror "+ | ||
| "depositScriptByteSize [%d] is now stale and must be updated "+ | ||
| "along with this test", | ||
| tbtcpg.DepositScriptByteSize, | ||
| expectedDepositScriptByteSize, | ||
| ) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| package tbtcpg_test | ||
|
|
||
| import ( | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/keep-network/keep-core/pkg/bitcoin" | ||
| "github.com/keep-network/keep-core/pkg/tbtcpg" | ||
| ) | ||
|
|
||
| // TestEstimateDepositsSweepFee_MinimumFloorAndBuffer verifies the sweep fee | ||
| // logic: a low estimate is raised to the minimum floor, an estimate above the | ||
| // floor is buffered by 25%, and a Bridge maximum below the minimum floor | ||
| // returns an error rather than silently broadcasting an underpriced sweep. | ||
| func TestEstimateDepositsSweepFee_MinimumFloorAndBuffer(t *testing.T) { | ||
| // Virtual size of a one-deposit sweep, used to size the cap for the error | ||
| // case relative to the minimum floor. 126 == DepositScriptByteSize. | ||
| size, err := bitcoin.NewTransactionSizeEstimator(). | ||
| AddPublicKeyHashInputs(1, true). | ||
| AddScriptHashInputs(1, 126, true). | ||
| AddPublicKeyHashOutputs(1, true). | ||
| VirtualSize() | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| tests := map[string]struct { | ||
| estimateSatPerVByte int64 | ||
| perDepositMaxFee uint64 | ||
| expectedSatPerVByteFee int64 | ||
| expectErrorContains string | ||
| }{ | ||
| "low estimate is raised to the minimum floor": { | ||
| estimateSatPerVByte: 1, | ||
| perDepositMaxFee: 100000, | ||
| expectedSatPerVByteFee: 5, // max(5, ceil(1*1.25)=2) = 5 | ||
| }, | ||
| "estimate above the floor is buffered by 25%": { | ||
| estimateSatPerVByte: 20, | ||
| perDepositMaxFee: 100000, | ||
| expectedSatPerVByteFee: 25, // ceil(20*1.25) = 25 | ||
| }, | ||
| "buffered estimate above the cap is bounded to the cap": { | ||
| estimateSatPerVByte: 20, | ||
| // ceil(20*1.25)=25 sat/vByte buffered fee exceeds the 22*size cap, | ||
| // so it is bounded down to the cap (rate 22), not the buffered 25. | ||
| perDepositMaxFee: uint64(22 * size), | ||
| expectedSatPerVByteFee: 22, | ||
| }, | ||
| "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 transaction fee", | ||
| }, | ||
| } | ||
|
|
||
| for name, test := range tests { | ||
| t.Run(name, func(t *testing.T) { | ||
| tbtcChain := tbtcpg.NewLocalChain() | ||
| tbtcChain.SetDepositParameters(0, 0, test.perDepositMaxFee, 0) | ||
|
|
||
| btcChain := tbtcpg.NewLocalBitcoinChain() | ||
| btcChain.SetEstimateSatPerVByteFee(1, test.estimateSatPerVByte) | ||
|
|
||
| fees, err := tbtcpg.EstimateDepositsSweepFee(tbtcChain, btcChain, 1) | ||
|
|
||
| if test.expectErrorContains != "" { | ||
| if err == nil { | ||
| t.Fatalf("expected an error, got fee result [%v]", fees) | ||
| } | ||
| if !strings.Contains(err.Error(), test.expectErrorContains) { | ||
| t.Fatalf( | ||
| "expected error containing [%s]; got [%v]", | ||
| test.expectErrorContains, err, | ||
| ) | ||
| } | ||
| return | ||
| } | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: [%v]", err) | ||
| } | ||
| if got := fees[1].SatPerVByteFee; got != test.expectedSatPerVByteFee { | ||
| t.Errorf( | ||
| "unexpected sweep fee rate\nexpected: [%d] sat/vByte\nactual: [%d] sat/vByte", | ||
| test.expectedSatPerVByteFee, got, | ||
| ) | ||
| } | ||
| }) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| package tbtcpg | ||
|
|
||
| import "fmt" | ||
|
|
||
| // MinWalletTxSatPerVByteFee is the minimum fee rate, in sat/vByte, applied to | ||
| // wallet Bitcoin transactions (deposit sweeps, redemptions, moving funds, moved | ||
| // funds sweeps). A fee oracle can return an unusably low estimate (down to the | ||
| // 1 sat/vByte relay floor enforced by the Electrum client) in an uncongested | ||
| // mempool. Because these transactions spend or consolidate significant wallet | ||
| // value and are not RBF-enabled, they cannot be replaced once broadcast, so a | ||
| // floor-rate transaction can get stuck in the mempool and jam the wallet: no | ||
| // new wallet transaction can be built while the previous one is unconfirmed. | ||
| // This minimum keeps the fee safely above the relay floor while remaining far | ||
| // below the Bridge's maximum fee. The value is intentionally conservative and | ||
| // could be made configurable; see threshold-network/keep-core#4171. | ||
| // | ||
| // NOTE: this static floor and the 25% buffer applied in applyWalletTxFeeFloor | ||
| // are a stopgap for the current fire-and-forget, non-RBF wallet transaction | ||
| // path: because a stuck transaction cannot be fee-bumped, the fee must be right | ||
| // on the first broadcast. Once RBF / fee-bumping lands (Part B, tracked in | ||
| // #4171) the safety net shifts to monitor-and-bump, and this policy should be | ||
| // revisited rather than carried forward unchanged: the defensive buffer can be | ||
| // dropped and the floor relaxed toward the live estimate, keeping only a small | ||
| // relay-propagation minimum. | ||
| const MinWalletTxSatPerVByteFee = 5 | ||
|
|
||
| // applyWalletTxFeeFloor raises a raw oracle fee estimate to a safe value for a | ||
| // non-RBF wallet transaction. It: | ||
| // - adds a 25% buffer over the oracle estimate so there is margin during the | ||
| // estimate-to-broadcast delay and the fee stays adaptive under congestion, | ||
| // - enforces a floor of MinWalletTxSatPerVByteFee sat/vByte, and | ||
| // - bounds the result by maxTotalFee (the Bridge maximum for the transaction). | ||
| // | ||
| // It returns an error if the minimum floor alone would exceed maxTotalFee - a | ||
| // safe transaction cannot be built, so the caller must not broadcast an | ||
| // underpriced one. estimatedFee is the raw oracle fee and txVsize is the | ||
| // estimated transaction virtual size, both in the usual sat / vByte units. | ||
| // | ||
| // The buffer and floor are applied to the estimated vsize; a transaction whose | ||
| // real on-wire vsize is larger than estimated (e.g. a deposit sweep containing | ||
| // legacy P2SH inputs) can land slightly below the intended rate, but still far | ||
| // above the relay floor this guards against. | ||
| func applyWalletTxFeeFloor( | ||
| estimatedFee int64, | ||
| txVsize int64, | ||
| maxTotalFee uint64, | ||
| ) (int64, error) { | ||
| if txVsize <= 0 { | ||
| return 0, fmt.Errorf("invalid transaction virtual size [%d]", txVsize) | ||
| } | ||
|
|
||
| // If even the minimum floor exceeds the Bridge maximum, a safe transaction | ||
| // cannot be constructed; error rather than silently broadcast underpriced. | ||
| if uint64(MinWalletTxSatPerVByteFee*txVsize) > maxTotalFee { | ||
| return 0, fmt.Errorf( | ||
| "minimum safe transaction fee [%d] exceeds the maximum fee [%d]", | ||
| MinWalletTxSatPerVByteFee*txVsize, | ||
| maxTotalFee, | ||
| ) | ||
| } | ||
|
|
||
| rate := estimatedFee / txVsize | ||
| rate = (rate*5 + 3) / 4 // ceil(rate * 1.25) | ||
| if rate < MinWalletTxSatPerVByteFee { | ||
| rate = MinWalletTxSatPerVByteFee | ||
| } | ||
|
|
||
| totalFee := rate * txVsize | ||
| if uint64(totalFee) > maxTotalFee { | ||
| totalFee = int64(maxTotalFee) | ||
| } | ||
|
|
||
| return totalFee, nil | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
Preserve the 25% buffer for fractional fee rates.
Integer-dividing before buffering underprices non-integral estimates. For
estimatedFee=999andtxVsize=200, this returns1000, whileceil(999 * 1.25)is1249. Buffer the total fee directly (or round the raw rate up before applying the buffer), then apply the floor and cap.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents