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
4 changes: 2 additions & 2 deletions src/server/blockchain/CeloAdminWallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ const { celo, env } = conf
const options = {
ethereum: celo,
network: `${env}-celo`,
maxFeePerGas: undefined, // will force use of estimatefees
maxPriorityFeePerGas: (2e8).toFixed(0),
maxFeePerGas: (50e9).toFixed(0), // floor to stay above Celo base fee spikes
maxPriorityFeePerGas: (1e9).toFixed(0), // floor for tips
fetchGasPrice: false,
faucetTxCost: 250000
}
Expand Down
50 changes: 37 additions & 13 deletions src/server/blockchain/Web3Wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -1203,6 +1203,18 @@ export class Web3Wallet {
}
}

pickFeeWithFloor(value: string | number | void, floor: string | number | void) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (complexity): Consider simplifying the EIP-1559 fee handling by removing pickFeeWithFloor and doing all floor and estimate logic in a single numeric flow using Math.max and toNumber.

You can keep the “instance values as floors” behavior with a single, numeric flow and remove pickFeeWithFloor. That reduces branching and type juggling without changing semantics.

1. Drop pickFeeWithFloor and use numeric comparison

Instead of mixing BigInt, toString, and null-handling in a helper, normalize to numbers once and use Math.max:

// remove this entirely
pickFeeWithFloor(value: string | number | void, floor: string | number | void) {
  if (value === null || value === undefined) {
    return floor
  }

  if (floor === null || floor === undefined) {
    return value
  }

  return BigInt(value.toString()) < BigInt(floor.toString()) ? floor : value
}

Inline floor application after estimates:

// Instance values are treated as floors so live provider estimates can rise above them.
const maxFeeFloor = maxFeePerGas !== undefined ? maxFeePerGas : this.maxFeePerGas
const maxPriorityFloor = maxPriorityFeePerGas !== undefined ? maxPriorityFeePerGas : this.maxPriorityFeePerGas

const hasAnyFeeInput =
  maxFeePerGas != null ||
  maxPriorityFeePerGas != null

if (!hasAnyFeeInput) {
  try {
    const { baseFee, priorityFee } = await this.getFeeEstimates()

    const baseFeeNum = toNumber(baseFee)
    const priorityFeeNum = toNumber(priorityFee)
    const maxFeeFloorNum = toNumber(maxFeeFloor)
    const maxPriorityFloorNum = toNumber(maxPriorityFloor)

    // estimates can rise above floors
    const effectiveMaxFeeNum = Math.max(baseFeeNum, maxFeeFloorNum)
    const effectiveMaxPriorityNum = Math.max(priorityFeeNum, maxPriorityFloorNum)

    maxFeePerGas = effectiveMaxFeeNum.toString()
    maxPriorityFeePerGas = effectiveMaxPriorityNum.toString()
  } catch (error) {
    logger.warn('Failed to estimate EIP-1559 fees, falling back to configured values', {
      error: error.message,
      network: this.network,
      networkId: this.networkId,
    })
    maxFeePerGas = maxFeeFloor
    maxPriorityFeePerGas = maxPriorityFloor
  }
} else {
  maxFeePerGas = maxFeeFloor
  maxPriorityFeePerGas = maxPriorityFloor
}

2. Keep normalization and validation in one place

Now the rest of the logic stays as-is and operates in one numeric domain:

let maxFeeNum = toNumber(maxFeePerGas)
let maxPriorityNum = toNumber(maxPriorityFeePerGas)

if (maxPriorityNum > 0 && (maxFeeNum === 0 || maxFeeNum < maxPriorityNum)) {
  logger.warn('maxFeePerGas < maxPriorityFeePerGas or is 0, adjusting maxFeePerGas', {
    originalMaxFeePerGas: maxFeePerGas,
    maxPriorityFeePerGas: maxPriorityFeePerGas,
    adjustedMaxFeePerGas: maxPriorityFeePerGas,
  })
  maxFeePerGas = maxPriorityFeePerGas
}

This keeps the new behavior:

  • Instance values are floors.
  • Estimates can exceed floors.
  • On estimation failure, you fall back to floors.

But it removes the extra helper, avoids BigInt juggling, and makes the flow linear: compute floors → optionally fetch estimates → apply floors vs estimates numerically → enforce EIP‑1559 constraint.

if (value === null || value === undefined) {
return floor
}

if (floor === null || floor === undefined) {
return value
}

return BigInt(value.toString()) < BigInt(floor.toString()) ? floor : value
}

/**
* Normalize gas pricing parameters - handles legacy gasPrice vs EIP-1559 fees
* Deduplicates gas pricing logic used in sendTransaction and sendNative
Expand Down Expand Up @@ -1233,24 +1245,36 @@ export class Web3Wallet {
})
return { gasPrice, maxFeePerGas: undefined, maxPriorityFeePerGas: undefined }
} else {
// Network supports EIP-1559, process EIP-1559 fees
// Use instance defaults if not provided
maxFeePerGas = maxFeePerGas !== undefined ? maxFeePerGas : this.maxFeePerGas
maxPriorityFeePerGas = maxPriorityFeePerGas !== undefined ? maxPriorityFeePerGas : this.maxPriorityFeePerGas
// Network supports EIP-1559, process EIP-1559 fees.
// Instance values are treated as floors so live provider estimates can rise above them.
const maxFeeFloor = maxFeePerGas !== undefined ? maxFeePerGas : this.maxFeePerGas
const maxPriorityFloor = maxPriorityFeePerGas !== undefined ? maxPriorityFeePerGas : this.maxPriorityFeePerGas

const needsEstimation = !maxFeePerGas || !maxPriorityFeePerGas
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): The needsEstimation check now treats '0'/0 differently than before and may skip estimation when values are numerically zero.

Previously, toNumber ensured that 0 and invalid values were treated as needing estimation via if (!maxFeeNum || !maxPriorityNum). The new check !maxFeePerGas || !maxPriorityFeePerGas works on the raw inputs, so a string like '0' is truthy and will no longer trigger estimation. This changes behavior for callers passing zero-like values and may result in using a zero max fee/tip instead of estimating. If you want to preserve the old behavior (zero → re-estimate), needsEstimation should be based on numeric-converted values or an explicit zero check rather than the raw inputs.


if (needsEstimation) {
try {
const { baseFee, priorityFee } = await this.getFeeEstimates()
maxFeePerGas = this.pickFeeWithFloor(baseFee, maxFeeFloor)
maxPriorityFeePerGas = this.pickFeeWithFloor(priorityFee, maxPriorityFloor)
} catch (error) {
logger.warn('Failed to estimate EIP-1559 fees, falling back to configured values', {
error: error.message,
network: this.network,
networkId: this.networkId
})
maxFeePerGas = maxFeeFloor
maxPriorityFeePerGas = maxPriorityFloor
}
} else {
maxFeePerGas = maxFeeFloor
maxPriorityFeePerGas = maxPriorityFloor
}

// Convert to numbers for comparison
let maxFeeNum = toNumber(maxFeePerGas)
let maxPriorityNum = toNumber(maxPriorityFeePerGas)
Comment on lines 1275 to 1276
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion (bug_risk): Loss of the previous !maxFeeNum || !maxPriorityNum validation means NaN values are no longer corrected via estimation.

With the removal of this guard, non-numeric maxFeePerGas / maxPriorityFeePerGas can now become NaN via toNumber while needsEstimation is false, so they’ll flow into the comparison logic and downstream fee handling as NaN. To keep the prior safety behavior, add a post-conversion check (e.g. !Number.isFinite(maxFeeNum) or maxFeeNum <= 0, same for priority fee) and fall back to estimates or a safe default when the values are invalid.


// Fetch fee estimates if EIP-1559 fees are not provided or invalid
if (!maxFeeNum || !maxPriorityNum) {
const { baseFee, priorityFee } = await this.getFeeEstimates()
maxFeePerGas = maxFeeNum || baseFee
maxPriorityFeePerGas = maxPriorityNum || priorityFee
maxFeeNum = toNumber(maxFeePerGas)
maxPriorityNum = toNumber(maxPriorityFeePerGas)
}

// Ensure maxFeePerGas >= maxPriorityFeePerGas (EIP-1559 requirement)
if (maxPriorityNum > 0 && (maxFeeNum === 0 || maxFeeNum < maxPriorityNum)) {
logger.warn('maxFeePerGas < maxPriorityFeePerGas or is 0, adjusting maxFeePerGas', {
Expand Down
103 changes: 103 additions & 0 deletions src/server/blockchain/__tests__/web3wallet.gas.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
jest.mock('../../db/mongo-db')

import { Web3Wallet } from '../Web3Wallet'

const createTestWallet = options =>
new Web3Wallet(
'TestWallet',
{
env: 'test',
mnemonic: '',
network: 'test',
kmsEnabled: false,
fuse: {
network_id: 42220,
httpWeb3Provider: 'http://localhost:8545'
}
},
{
ethereum: {
network_id: 42220,
httpWeb3Provider: 'http://localhost:8545'
},
network: 'test-celo',
...options
},
false
)

describe('Web3Wallet gas pricing', () => {
const logger = {
debug: jest.fn(),
warn: jest.fn()
}

beforeEach(() => {
jest.clearAllMocks()
})

test('prefers provider EIP-1559 fees when they are above configured floors', async () => {
const wallet = createTestWallet({
maxFeePerGas: '40000000000',
maxPriorityFeePerGas: '1000000000'
})

wallet.supportsEIP1559 = jest.fn().mockResolvedValue(true)
wallet.getFeeEstimates = jest.fn().mockResolvedValue({
baseFee: 52000000000,
priorityFee: 2000000000
Comment on lines +39 to +48
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion (testing): Add tests for the branch where both EIP-1559 fees are fully provided and no estimation is performed.

The current tests only cover cases where needsEstimation is true (at least one of maxFeePerGas / maxPriorityFeePerGas is missing). Please add a test that passes both values in txParams so needsEstimation is false, and verify that:

  • getFeeEstimates is not called
  • The returned maxFeePerGas / maxPriorityFeePerGas match the provided values (used as floors) and still satisfy the EIP-1559 constraint after adjustment.

This will cover the non-estimation path when callers fully specify fees.

Suggested implementation:

  beforeEach(() => {
    jest.clearAllMocks()
  })

  test('uses fully specified EIP-1559 fees without estimation', async () => {
    const wallet = createTestWallet({
      maxFeePerGas: '60000000000',
      maxPriorityFeePerGas: '3000000000'
    })

    wallet.supportsEIP1559 = jest.fn().mockResolvedValue(true)
    wallet.getFeeEstimates = jest.fn()

    const txParams = {
      maxFeePerGas: '60000000000',
      maxPriorityFeePerGas: '3000000000'
    }

    const result = await wallet.getGasPricing(txParams, logger)

    expect(wallet.getFeeEstimates).not.toHaveBeenCalled()
    expect(result.maxFeePerGas).toBe('60000000000')
    expect(result.maxPriorityFeePerGas).toBe('3000000000')
    expect(BigInt(result.maxFeePerGas)).toBeGreaterThanOrEqual(BigInt(result.maxPriorityFeePerGas))
  })

  test('prefers provider EIP-1559 fees when they are above configured floors', async () => {
  • If the helper used in this file is not wallet.getGasPricing(txParams, logger) but some other function (e.g. a standalone getGasPricing(wallet, txParams, logger)), adjust the call accordingly while keeping the assertions identical.
  • If maxFeePerGas / maxPriorityFeePerGas in the rest of the suite are BigNumber/BN/numeric types rather than strings, convert the literals and comparisons to match that convention, and adapt the EIP-1559 constraint assertion to use the same big-number utilities.

})

const gas = await wallet.normalizeGasPricing({}, logger)

expect(gas).toEqual({
gasPrice: undefined,
maxFeePerGas: 52000000000,
maxPriorityFeePerGas: 2000000000
})
})

test('uses configured floors when provider underbids Celo fees', async () => {
const wallet = createTestWallet({
maxFeePerGas: '40000000000',
maxPriorityFeePerGas: '1000000000'
})

wallet.supportsEIP1559 = jest.fn().mockResolvedValue(true)
wallet.getFeeEstimates = jest.fn().mockResolvedValue({
baseFee: 30000000000,
priorityFee: 200000000
})

const gas = await wallet.normalizeGasPricing({}, logger)

expect(gas).toEqual({
gasPrice: undefined,
maxFeePerGas: '40000000000',
maxPriorityFeePerGas: '1000000000'
})
})

test('falls back to configured floors when fee estimation fails', async () => {
const wallet = createTestWallet({
maxFeePerGas: '40000000000',
maxPriorityFeePerGas: '1000000000'
})

wallet.supportsEIP1559 = jest.fn().mockResolvedValue(true)
wallet.getFeeEstimates = jest.fn().mockRejectedValue(new Error('rpc failure'))

const gas = await wallet.normalizeGasPricing({}, logger)

expect(gas).toEqual({
gasPrice: undefined,
maxFeePerGas: '40000000000',
maxPriorityFeePerGas: '1000000000'
})
expect(logger.warn).toHaveBeenCalledWith('Failed to estimate EIP-1559 fees, falling back to configured values', {
error: 'rpc failure',
network: 'test-celo',
networkId: 42220
})
})
})
Loading