Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions client/asset/eth/eth.go
Original file line number Diff line number Diff line change
Expand Up @@ -8543,6 +8543,26 @@ func (w *assetWallet) amendPendingTx(txID string, f func(common.Hash, *types.Tra
return nil
}

// rbfPriceBumpPct is the minimum percentage by which both the fee cap and
// the tip cap of a replacement transaction must exceed those of the
// transaction it replaces. geth-family mempools require the replacement to be
// strictly greater than the old values AND at least PriceBump percent above
// them, on both components independently (go-ethereum
// core/txpool/legacypool/list.go). PriceBump defaults to 10 in geth and its
// forks, e.g. polygon's bor.
const rbfPriceBumpPct = 10

// rbfReplacementFloor is the minimum acceptable value for a fee component
// (fee cap or tip cap) of a transaction replacing one whose corresponding
// component had value old.
func rbfReplacementFloor(old *big.Int) *big.Int {
floor := new(big.Int).Mul(old, big.NewInt(100+rbfPriceBumpPct))
floor.Div(floor, big.NewInt(100))
// The strictly-greater-than requirement applies to the raw old values,
// and the threshold division truncates, so add 1 to cover both.
return floor.Add(floor, big.NewInt(1))
}

// userActionBumpFees is a request by a user to resolve a actionTypeTooCheap
// condition.
func (w *assetWallet) userActionBumpFees(actionB []byte) error {
Expand All @@ -8567,6 +8587,21 @@ func (w *assetWallet) userActionBumpFees(actionB []byte) error {
if err != nil {
return fmt.Errorf("error getting new fee rate: %w", err)
}
// The recommended rate is derived from current network conditions
// alone. If it doesn't sufficiently exceed the fees of the tx being
// replaced, e.g. when a cached tip suggestion returns the same tip
// that the original tx was created with, the mempool will reject the
// replacement as underpriced. Raise both components to at least
// their replace-by-fee floors.
if floor := rbfReplacementFloor(tx.GasFeeCap()); maxFeeRate.Cmp(floor) < 0 {
maxFeeRate = floor
}
if floor := rbfReplacementFloor(tx.GasTipCap()); tipCap.Cmp(floor) < 0 {
tipCap = floor
}
if tipCap.Cmp(maxFeeRate) > 0 {
maxFeeRate = tipCap
}
txOpts, err := w.node.txOpts(w.ctx, 0 /* set below */, tx.Gas(), maxFeeRate, tipCap, nonce)
if err != nil {
return fmt.Errorf("error preparing tx opts: %w", err)
Expand Down
71 changes: 71 additions & 0 deletions client/asset/eth/eth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ type testNode struct {
estimateGasErr error
simBackend bind.ContractBackend
maxFeeRate *big.Int
lastTxOptsMaxFeeRate *big.Int
lastTxOptsTipRate *big.Int
tContractor *tContractor
tokenContractor *tTokenContractor
signedRedeemContractor *tSignedRedeemContractor
Expand Down Expand Up @@ -223,6 +225,8 @@ func (n *testNode) txOpts(ctx context.Context, val, maxGas uint64, maxFeeRate, t
if maxFeeRate == nil {
maxFeeRate = n.maxFeeRate
}
n.lastTxOptsMaxFeeRate = maxFeeRate
n.lastTxOptsTipRate = tipRate
txOpts := newTxOpts(ctx, n.addr, val, maxGas, maxFeeRate, dexeth.GweiToWei(2))
txOpts.Nonce = big.NewInt(1)
return txOpts, nil
Expand Down Expand Up @@ -1327,6 +1331,39 @@ func TestCheckPendingTxs(t *testing.T) {
}
}

func TestRBFReplacementFloor(t *testing.T) {
gwei := func(v int64) *big.Int { return dexeth.GweiToWei(uint64(v)) }
for _, tt := range []struct {
name string
old, want *big.Int
}{
// A zero old value still requires a strictly greater replacement.
{"zero", big.NewInt(0), big.NewInt(1)},
// Truncating division must not undercut the percentage threshold.
{"small odd", big.NewInt(99), big.NewInt(109)}, // 99*1.1 = 108.9
{"small even", big.NewInt(100), big.NewInt(111)}, // 110 fails strictly-greater margin
{"one gwei", gwei(1), new(big.Int).Add(big.NewInt(1_100_000_000), big.NewInt(1))},
{"200 gwei", gwei(200), new(big.Int).Add(big.NewInt(220_000_000_000), big.NewInt(1))},
} {
if got := rbfReplacementFloor(tt.old); got.Cmp(tt.want) != 0 {
t.Fatalf("%s: rbfReplacementFloor(%s) = %s, want %s", tt.name, tt.old, got, tt.want)
}
}
// Every floor must satisfy geth's acceptance rule: strictly greater than
// old AND >= old*(100+priceBump)/100.
for _, old := range []*big.Int{big.NewInt(0), big.NewInt(1), big.NewInt(7), gwei(2), gwei(30), gwei(1000)} {
floor := rbfReplacementFloor(old)
if floor.Cmp(old) <= 0 {
t.Fatalf("floor %s not strictly greater than old %s", floor, old)
}
threshold := new(big.Int).Mul(old, big.NewInt(100+rbfPriceBumpPct))
threshold.Div(threshold, big.NewInt(100))
if floor.Cmp(threshold) < 0 {
t.Fatalf("floor %s below percentage threshold %s for old %s", floor, threshold, old)
}
}
}

func TestTakeAction(t *testing.T) {
_, eth, node, shutdown := tassetWallet(BipID)
defer shutdown()
Expand Down Expand Up @@ -1370,6 +1407,40 @@ func TestTakeAction(t *testing.T) {
t.Fatal("didn't save to DB")
}

// A bump of a tx whose fees are at or above the network-based
// recommendation must still clear the mempool's replace-by-fee floors,
// on both the fee cap and the tip cap independently. With node.baseFee
// = 100 gwei and node.tip = 2 gwei, the recommendation is 202 gwei /
// 2 gwei, so both floors bind here.
oldFeeCap := dexeth.GweiToWei(500)
oldTip := dexeth.GweiToWei(2) // same as the (possibly cached) tip suggestion
highFeeRecipient := common.BytesToAddress(encode.RandomBytes(20))
highFeeTx, _ := types.SignTx(types.NewTx(&types.DynamicFeeTx{
Nonce: 2,
GasTipCap: oldTip,
GasFeeCap: oldFeeCap,
Gas: 50_000,
To: &highFeeRecipient,
ChainID: node.chainConfig().ChainID,
}), signer, node.privKey)
pendingTx = eth.extendedTx(&genTxResult{
tx: highFeeTx,
txType: asset.Send,
amt: 1,
})
eth.pendingTxs = []*extendedWalletTx{pendingTx}

tooCheapAction = []byte(fmt.Sprintf(`{"txID":"%s","bump":true}`, pendingTx.ID))
if err := eth.TakeAction(actionTypeTooCheap, tooCheapAction); err != nil {
t.Fatalf("TakeAction high-fee bump error: %v", err)
}
if wantFeeCap := rbfReplacementFloor(oldFeeCap); node.lastTxOptsMaxFeeRate.Cmp(wantFeeCap) != 0 {
t.Fatalf("replacement fee cap not raised to RBF floor. wanted %s, got %s", wantFeeCap, node.lastTxOptsMaxFeeRate)
}
if wantTip := rbfReplacementFloor(oldTip); node.lastTxOptsTipRate.Cmp(wantTip) != 0 {
t.Fatalf("replacement tip cap not raised to RBF floor. wanted %s, got %s", wantTip, node.lastTxOptsTipRate)
}

pendingTx = eth.extendedTx(&genTxResult{
tx: node.newTransaction(1, aGwei),
txType: asset.Send,
Expand Down
Loading