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
87 changes: 81 additions & 6 deletions client/asset/eth/eth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3727,6 +3727,67 @@ func (*swapReceipt) SignedRefund() dex.Bytes {

var _ asset.Receipt = (*swapReceipt)(nil)

// checkSwapFeeRateMinable errors if the swap fee rate, which becomes the
// transaction's gas fee cap, is below the network's current base fee. Such a
// transaction cannot be mined until the base fee recedes, and broadcasting it
// would occupy the nonce and block every subsequent transaction from this
// wallet until it is mined or replaced. Erroring before the nonce is consumed
// leaves the wallet unencumbered, and core will retry the swap, which will
// proceed if the base fee recedes within the broadcast timeout. This is a
// last resort: swapFeeRateRescue will already have attempted to raise the
// rate, so this only trips when funds are insufficient to cover a minable
// fee cap.
func checkSwapFeeRateMinable(feeRateGwei uint64, baseRate *big.Int) error {
if dexeth.GweiToWei(feeRateGwei).Cmp(baseRate) < 0 {
return fmt.Errorf("swap fee rate cap %d gwei is below the current network base fee %d gwei; "+
"refusing to broadcast a swap transaction that cannot be mined",
feeRateGwei, dexeth.WeiToGweiCeil(baseRate))
}
return nil
}

// swapFeeRateRescue raises the swap tx's fee rate when the network base fee
// exceeds the server-assigned rate, which would otherwise produce a tx that
// cannot be mined. The target is 2*baseFee, funds permitting, mirroring the
// equivalent rescue in Redeem. Under EIP-1559 the fee cap is not the fee
// paid, and the server's ValidateFeeRate only requires the cap to be at
// least the assigned rate, so a higher cap is protocol-legal. feesReserved
// is the fee budget already accounted for at the assigned rate; anything
// above it must be covered by the fee wallet's available balance.
func swapFeeRateRescue(assignedGwei uint64, baseRate *big.Int, gasLimit, feesReserved uint64,
feeWallet *assetWallet, log dex.Logger) (feeRateGwei uint64, err error) {

feeRateGwei = assignedGwei
baseFeeGwei := dexeth.WeiToGweiCeil(baseRate)
if baseFeeGwei <= assignedGwei {
return feeRateGwei, nil
}
if gasLimit == 0 {
return 0, errors.New("zero gas limit in fee rate rescue")
}
bal, err := feeWallet.Balance()
if err != nil {
return 0, fmt.Errorf("error getting balance for fee rate rescue: %w", err)
}
// big.Int arithmetic: the base fee is reported by an RPC provider, so an
// absurd value must not overflow the rescue decision.
gasLimitBig := new(big.Int).SetUint64(gasLimit)
targetRate := new(big.Int).Lsh(new(big.Int).SetUint64(baseFeeGwei), 1) // 2 * baseFeeGwei
neededFunds := new(big.Int).Mul(targetRate, gasLimitBig)
budget := new(big.Int).Add(new(big.Int).SetUint64(bal.Available), new(big.Int).SetUint64(feesReserved))
rate := targetRate
if budget.Cmp(neededFunds) < 0 {
rate = budget.Div(budget, gasLimitBig)
}
if !rate.IsUint64() {
return 0, fmt.Errorf("unreasonable rescue fee rate %s gwei", rate)
}
feeRateGwei = rate.Uint64()
log.Warnf("network base fee %d gwei exceeds assigned swap fee rate %d gwei. using %d gwei as fee cap",
baseFeeGwei, assignedGwei, feeRateGwei)
return feeRateGwei, nil
}

// Swap sends the swaps in a single transaction. The fees used returned are the
// max fees that will possibly be used, since in ethereum with EIP-1559 we cannot
// know exactly how much fees will be used.
Expand Down Expand Up @@ -3781,10 +3842,9 @@ func (w *ETHWallet) Swap(ctx context.Context, swaps *asset.Swaps) ([]asset.Recei
}
}

maxFeeRate := dexeth.GweiToWei(swaps.FeeRate)
_, tipRate, err := w.currentNetworkFees(ctx)
baseRate, tipRate, err := w.currentNetworkFees(ctx)
if err != nil {
return fail("Swap: failed to get network tip cap: %w", err)
return fail("Swap: failed to get network fees: %w", err)
}

// Only check on-chain state on retry (cache hit) to avoid unnecessary
Expand Down Expand Up @@ -3831,6 +3891,14 @@ func (w *ETHWallet) Swap(ctx context.Context, swaps *asset.Swaps) ([]asset.Recei
return receipts, change, fees, nil
}
}
feeRateGwei, err := swapFeeRateRescue(swaps.FeeRate, baseRate, gasLimit, fees, w.assetWallet, w.log)
if err != nil {
return fail("Swap: %v", err)
}
if err := checkSwapFeeRateMinable(feeRateGwei, baseRate); err != nil {
return fail("Swap: %v", err)
}
maxFeeRate := dexeth.GweiToWei(feeRateGwei)
tx, err := w.initiate(ctx, w.assetID, swaps.Contracts, gasLimit, maxFeeRate, tipRate, contractVer)
if err != nil {
return fail("Swap: initiate error: %w", err)
Expand Down Expand Up @@ -3937,10 +4005,9 @@ func (w *TokenWallet) Swap(ctx context.Context, swaps *asset.Swaps) ([]asset.Rec
} // See (*ETHWallet).Swap comments for a third option.
}

maxFeeRate := dexeth.GweiToWei(swaps.FeeRate)
_, tipRate, err := w.currentNetworkFees(ctx)
baseRate, tipRate, err := w.currentNetworkFees(ctx)
if err != nil {
return fail("Swap: failed to get network tip cap: %w", err)
return fail("Swap: failed to get network fees: %w", err)
}

if w.netToken.SwapContracts[swaps.AssetVersion] == nil {
Expand Down Expand Up @@ -3995,6 +4062,14 @@ func (w *TokenWallet) Swap(ctx context.Context, swaps *asset.Swaps) ([]asset.Rec
return receipts, change, fees, nil
}
}
feeRateGwei, err := swapFeeRateRescue(swaps.FeeRate, baseRate, gasLimit, fees, w.parent, w.log)
if err != nil {
return fail("Swap: %v", err)
}
if err := checkSwapFeeRateMinable(feeRateGwei, baseRate); err != nil {
return fail("Swap: %v", err)
}
maxFeeRate := dexeth.GweiToWei(feeRateGwei)
tx, err := w.initiate(ctx, w.assetID, swaps.Contracts, gasLimit, maxFeeRate, tipRate, contractVer)
if err != nil {
return fail("Swap: initiate error: %w", err)
Expand Down
42 changes: 42 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,11 @@ func (n *testNode) txOpts(ctx context.Context, val, maxGas uint64, maxFeeRate, t
if maxFeeRate == nil {
maxFeeRate = n.maxFeeRate
}
n.lastTxOptsMaxFeeRate = maxFeeRate
n.lastTxOptsTipRate = tipRate
// The fixed tip below is baked into many test fixtures (e.g. gasless
// redeem viability decisions). Assertions about the tip passed to txOpts
// should use lastTxOptsTipRate rather than the built TransactOpts.
txOpts := newTxOpts(ctx, n.addr, val, maxGas, maxFeeRate, dexeth.GweiToWei(2))
txOpts.Nonce = big.NewInt(1)
return txOpts, nil
Expand Down Expand Up @@ -3010,6 +3017,10 @@ func testSwap(t *testing.T, assetID uint32) {
gases = &tokenGasesV0
}

// The swap fee rate (assetCfg.MaxFeeRate) is the tx's gas fee cap and
// must not be below the current base fee, or Swap refuses to broadcast.
node.baseFee = dexeth.GweiToWei(5)

receivingAddress := "0x2b84C791b79Ee37De042AD2ffF1A253c3ce9bc27"
node.tContractor.initTx = types.NewTx(&types.DynamicFeeTx{})

Expand Down Expand Up @@ -3270,6 +3281,37 @@ func testSwap(t *testing.T, assetID uint32) {
LockChange: false,
}
testSwap("v1", swaps, false)

// An assigned fee rate below the current base fee would produce a tx
// that cannot be mined. With available balance, Swap raises the fee cap
// to 2*baseFee instead.
node.baseFee = dexeth.GweiToWei(assetCfg.MaxFeeRate + 1)
inputs = refreshWalletAndFundCoins(5, []uint64{ethToGwei(2) + (2 * 200 * dexeth.InitGas(1, 1))}, 2)
swaps = asset.Swaps{
Inputs: inputs,
AssetVersion: assetCfg.Version,
Contracts: contracts,
FeeRate: assetCfg.MaxFeeRate,
LockChange: false,
}
testSwap("fee rate rescue", swaps, false)
if wantCap := dexeth.GweiToWei(2 * (assetCfg.MaxFeeRate + 1)); node.lastTxOptsMaxFeeRate.Cmp(wantCap) != 0 {
t.Fatalf("fee cap not raised to 2*baseFee. wanted %s, got %s", wantCap, node.lastTxOptsMaxFeeRate)
}

// Without enough available balance to raise the cap to a minable level,
// Swap must refuse to broadcast.
node.baseFee = dexeth.GweiToWei(1_000_000)
inputs = refreshWalletAndFundCoins(5, []uint64{ethToGwei(2) + (2 * 200 * dexeth.InitGas(1, 1))}, 2)
swaps = asset.Swaps{
Inputs: inputs,
AssetVersion: assetCfg.Version,
Contracts: contracts,
FeeRate: assetCfg.MaxFeeRate,
LockChange: false,
}
testSwap("fee rate rescue unfunded", swaps, true)
node.baseFee = dexeth.GweiToWei(5)
}

func TestPreRedeem(t *testing.T) {
Expand Down
Loading