diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index 7d77793a46..46843912d5 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -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 { @@ -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) diff --git a/client/asset/eth/eth_test.go b/client/asset/eth/eth_test.go index 684bd04185..3f0f46b821 100644 --- a/client/asset/eth/eth_test.go +++ b/client/asset/eth/eth_test.go @@ -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 @@ -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 @@ -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() @@ -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,