ethers.js: Polygon transactions are stuck/transaction underpriced error

Ethers Version

5.6.1

Search Terms

polygon maxPriorityFeePerGas maxFeePerGas

Describe the Problem

There is a harcoded (1.5 gwei) maxPriorityFeePerGas (and maxFeePerGas) in index.ts.

This value is used in populateTransaction.

In case of Polygon, this will either result in a transaction stuck in the mempool, or in case og e.g an Alchemy endpoint, “transaction underpriced” error.

Code Snippet

signer.populateTransaction(txParams)
signer.sendTransaction(txParams)

Where txParams don't contain maxPriorityFeePerGas/maxFeePerGas, and is a type 2 transaction.
(Legacy transactions pass as they use gasPrice only)

Contract ABI

No response

Errors

processing response error (body="{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32000,\"message\":\"transaction underpriced\"},\"id\":64}", error={"code":-32000}, requestBody="{\"method\":\"eth_sendRawTransaction\",\"params\":[\"0x02f873818981c98459682f0084596898e882520894c54c244200d657650087455869f1ad168537d3b387038d7ea4c6800080c080a07cb6e05c60a2cb7ffc83349bc52ade79afaf9fdb911c64d57aed421caa1ecbcfa05f30023a4d21dd2eab6ea619c8dbb4820ce40c71841baacf8e82cbde7e87602a\"],\"id\":64,\"jsonrpc\":\"2.0\"}", requestMethod="POST",

Environment

No response

Environment (Other)

No response

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 28
  • Comments: 49 (4 by maintainers)

Commits related to this issue

Most upvoted comments

My current workaround:

// get max fees from gas station
let maxFeePerGas = ethers.BigNumber.from(40000000000) // fallback to 40 gwei
let maxPriorityFeePerGas = ethers.BigNumber.from(40000000000) // fallback to 40 gwei
try {
    const { data } = await axios({
        method: 'get',
        url: isProd
        ? 'https://gasstation-mainnet.matic.network/v2'
        : 'https://gasstation-mumbai.matic.today/v2',
    })
    maxFeePerGas = ethers.utils.parseUnits(
        Math.ceil(data.fast.maxFee) + '',
        'gwei'
    )
    maxPriorityFeePerGas = ethers.utils.parseUnits(
        Math.ceil(data.fast.maxPriorityFee) + '',
        'gwei'
    )
} catch {
    // ignore
}

// send tx with custom gas
const tx = await contract.multicall(calldata, {
    maxFeePerGas,
    maxPriorityFeePerGas,
})

And my current workaround:

  const feeData = await maticProvider.getFeeData()

  // Start transaction: populate the transaction
  let populatedTransaction
  try {
    populatedTransaction = await contract.populateTransaction.mint(wallet, arg, { gasPrice: feeData.gasPrice })
  } catch (e) {
    return
  }

I removed gasLimit from the options as it never worked;

What’s the mulDiv function do? Can you kindly put out the complete code? I am trying to make it work on polygon too. But the transaction is always stuck at pending.

Yeah I was reading this thread like “oh yay a solution” and then “wtf is mulDiv?”

After some significant googling, I found that it’s an old timey method that just means (a * b) / c. He’s using it to calculate 10% higher than the given number.

But after getting it to work, I found that I didn’t need to go 10% higher. I included the 10% higher code in a comment, in case anyone needs it.

I adapted and cleaned up the code, and here is what I ended up with.

function parse(data) {
    return ethers.utils.parseUnits(Math.ceil(data) + '', 'gwei');
}

async function calcGas(gasEstimated) {
    let gas = {
        gasLimit: gasEstimated, //.mul(110).div(100)
        maxFeePerGas: ethers.BigNumber.from(40000000000),
        maxPriorityFeePerGas: ethers.BigNumber.from(40000000000)
    };
    try {
        const {data} = await axios({
            method: 'get',
            url: 'https://gasstation-mainnet.matic.network/v2'
        });
        gas.maxFeePerGas = parse(data.fast.maxFee);
        gas.maxPriorityFeePerGas = parse(data.fast.maxPriorityFee);
    } catch (error) {

    }
    return gas;
};

const gasEstimated = await contract.estimateGas.yourMethod();
const gas = await calcGas(gasEstimated);
const tx = await contract.yourMethod(gas);
await tx.wait();

Provided I didn’t make any mistakes in converting to ES, this should be a full working snippet. If not, let me know.

Honestly I don’t know how I would have ever solved this if not for stumbling upon this particular thread. So thanks for your efforts, and your help.

I’m so sick of this constantly changing. What worked 5 months ago didn’t work a month ago and what worked 5 days ago doesn’t work today. What is changing?

Ok so as much as I can tell maxFeePerGas and maxPriorityFeePerGas are estimated completely wrong by the ethers on mainnet polygon. gasPrice gets closer, but is still dramatically underpriced. So now I’m just back to using gasPrice again.

So, basically Polygon wanted to keep “backwards compatibility” with Geth by using EIP-1559 but without actually using EIP-1559.

The purpose of EIP-1559 is to stabilize fee pricing and make it highly predictable (for example; I’ve not had a single tx take more than 2 blocks on Ethereum since it was added, and almost all are in the next block). But that involves setting a reasonable floating baseFee and a fixed priorityFee.

Polygon wants their prices to remain “looking low”, so they effective hard coded the baseFee, and a floating priorityFee (notice this is backwards).

This results in mimicking the old pre-EIP-1559 behaviour, creating a bidding war for priorityFee during congestion (which per-EIP-1559 txs did, creating a bidding war for gasPrice during congestion). The only viable solution Ethereum had that worked back then was a gas station; a separate service which would track the mempool and store stats on when a tx enters the mempool and when it was mined, map this duration against its gasPrice, and generate histograms that map gasPrice to wait times. These histograms over short periods of time could then be used to pick a gas price that would get a tx included within a reasonable time. During congestion ramp up, the prices would possibly lag and would result in slower transactions and during ramp down would cause overpayment for a transaction. It’s a terrible solution, it the best possible solution at the same time. 😃

So, the only really way to probably “solve” this is to find/host a Gas Station for Polygon.

FYI. I have a gas station implementation you can adapt; but with proper EIP-1559 networks, it is no longer necessary, so I don’t maintain it much anymore. Basically, change the gasPrice property to maxPriorityFeePerGas and run it against a local Polygon node; it has to be a local Polygon node you sync, since the mempool is busy.

I’d also be welcome to developers convincing Polygon to correctly use the specification, and that you prefer predicable correct pricing rather than chaotic “perceived” cheaper pricing. 😉

Or get Polygon to add gas tracking to their node and provide a gas station API on their node. If anyone has any Polygon contacts. 😃

@Benjythebee seems like you could then just call provider.getGasPrice() directly

What was the fix?

Just completed minting 1000 nfts yesterday on polygon RELIABLY - finally. Here’s my code to get around this very frustrating gas pricing issue on Polygon/Mumbai:

	const gasEstimated = await contract.estimateGas.yourMethod(...methodParams)

	const gas = await calcGas('fast', chain) // this is @robertu7 's code - thanks!

	let response = await (
		await contract.connect(signer).yourMethod(...methodParams,
			{ gasLimit: mulDiv(gasEstimated, 110, 100), 
			maxFeePerGas: gas.maxFeePerGas, 
			maxPriorityFeePerGas: gas.maxPriorityFeePerGas }
		)
	).wait()

it irks me that the maxPriorityFeePerGas is so high. I know I saw random transactions I had with default gas paying 1.5gwei but I havent been able to re-achieve this reliably.

Note that I had to include a gasLimit at a 10% premium for improved reliability. the ethers getFeeData did NOT work. it was always underpriced.

But the code is demonstrably reliable in sending the tx and getting it included in the block within a few seconds each time. I added a check to retry the tx later if the maxFeePerGas exceeds 50gwei in production.

Hope that helps someone.

Update. SO the nonce was pending with a previous accepted tx. So, the maxFeePerGas value from the gasstation is getting accepted. However, if I wait for the transaction to confirm, it never returns. If i submit another TX, it gets a REPLACEMENT_UNDERPRICED error. I then have to go to metamask, and send a 0Matic transaction with the SAME NONCE at the fast price to clear the pending tx. GREAT. YAY.

So as a part of all this pricing business, I have TX with an accepted price and a hash, that doesnt confirm within any reasonable timeframe, hence still blocking all other TX. Looking up the TX in polygonscan - i get this: Status: Pending This txn hash was found in our secondary node and should be picked up by our indexer in a short while. for a tx made 5 minutes ago.

trying a different RPC to see if there’s any improvement.

I’m updating this info so maybe it helps others? But I sure could use some feedback from the more experienced Polygoner’s out there who have figured this out. If there is such a state…

Meanwhile, more 0 matic transfers to reset Tx.

Update: after maybe 15min? explorer shows this: Sorry, We are unable to locate this TxnHash Sooo… the accepted fee wasn’t good enough???

Update #2: so it seems now we’re cooking with gas 😃 So a number of issues:

  1. getFeeData is apparently WAY UNDERPRICED compared to the gasstation values - USE the gasstation values for maxFeePerGas and maxPriorityFeePerGas (the tip is the same as the limit??? - whatever)
  2. I changed my RPC provider from maticvigil to polygon-rpc. It apparently was the difference between the await for confirmation timing out and the tx should as pending on a secondary node TO ACTUALLY being confirmed within 30s.
  3. If you see a REPLACEMENT_UNDERPRICED - that means you ALREADY have a pending TX and should ensure this is cancelled or processed before you create a NEW ONE. Your current NODE# is stuck and you will need to unstick it. Doing a zero matic transfer on Metamask for that node # at Aggressive gas seems to take care of that within 30s. And what do you know - Metamask is using polygon-rpc.

So… my batcher is finally chugging await and logging successful tx now. YAY but what a week from HELL. I cannot say I would recommend Polygon, or the support that is available to help. Thanks to the community here for the gas pricing info

NEXT STOP: anyone have any experience setting default gas for the provider, versus per tx. when using an sdk - direct access to setting the gas for a tx is sometimes not available - ie Rarible API or Opensea perhaps. I’m told Web3 has Web3Ethereum that can be supplied with gas option - does this work for maxFeeForGas? Is there an ETHERS equivalent?

I’ve updated @robertu7’s answer to make it work with ethers 6.6.3 and the new Polygon gas station URLs:

const gasStationURL = (network === 'mainnet') ? "https://gasstation.polygon.technology/v2" : "https://gasstation-testnet.polygon.technology/v2";
let maxFeePerGas = BigInt("40000000000") // fallback to 40 gwei
let maxPriorityFeePerGas = BigInt("40000000000") // fallback to 40 gwei


// Call this function every time before a contract call
async function setOptimalGas() {
    try {
        const { data } = await axios({
            method: 'get',
            url: gasStationURL
        })
        maxFeePerGas = ethers.parseUnits(
            Math.ceil(data.fast.maxFee) + '',
            'gwei'
        )
        maxPriorityFeePerGas = ethers.parseUnits(
            Math.ceil(data.fast.maxPriorityFee) + '',
            'gwei'
        )
    } catch {
        // ignore
    }
}

await setOptimalGas();
await contract.myMethod(myParams, {
        maxFeePerGas,
        maxPriorityFeePerGas,
});

My current workaround

import axios from 'axios'
import { ethers } from 'ethers'

const provider = new ethers.providers.JsonRpcProvider('')

const originalGetFeeData = provider.getFeeData.bind(provider)
provider.getFeeData = async () => {
  type GasStationData = {
    safeLow: { maxPriorityFee: number, maxFee: number }
    standard: { maxPriorityFee: number, maxFee: number }
    fast: { maxPriorityFee: number, maxFee: number }
    estimatedBaseFee: number
    blockTime: number
    blockNumber: number
  }

  const { data: { standard } } = await axios.get<GasStationData>(`https://gasstation-mainnet.matic.network/v2`)

  const data = await originalGetFeeData()

  data.maxFeePerGas = ethers.utils.parseUnits(Math.round(standard.maxFee).toString(), 'gwei')
  data.maxPriorityFeePerGas = ethers.utils.parseUnits(Math.round(standard.maxPriorityFee).toString(), 'gwei')

  return data
}

@jschiarizzi It’s official: https://docs.polygon.technology/docs/develop/tools/polygon-gas-station

Why don’t they just fix their node software to respect the minimum priority gwei rate. Running a full node will recommend lower rates…

For your interest @robertu7 I pulled what you did into a new wallet subclass that one day may or may not get sent back into the main lib. It’s tuned for our use cases with Polygon but I can’t see why it can’t be generalized with the help of some interfaces:

import { TransactionRequest } from "@ethersproject/abstract-provider"
import { Provider } from "@ethersproject/abstract-provider"
import { ExternallyOwnedAccount } from "@ethersproject/abstract-signer"
import { BytesLike } from "@ethersproject/bytes"
import { Deferrable } from "@ethersproject/properties"
import { SigningKey } from "@ethersproject/signing-key"
import fetch from "cross-fetch"
import { Wallet as EthersWallet, ethers } from "ethers"

type GasData = {
  fast: {
    maxPriorityFee: number
    maxFee: number
  }
}

const DEFAULT_GAS_DATA: GasData = {
  fast: {
    maxPriorityFee: 40,
    maxFee: 40,
  },
}

class AutomaticGasWallet extends EthersWallet {
  gasStationUrl: string

  constructor(
    privateKey: BytesLike | ExternallyOwnedAccount | SigningKey,
    provider: Provider,
    gasStationUrl: string
  ) {
    super(privateKey, provider)
    this.gasStationUrl = gasStationUrl
  }

  async populateTransaction(
    transaction: Deferrable<TransactionRequest>
  ): Promise<TransactionRequest> {
    const tx = await super.populateTransaction(transaction)

    const data = await this.getGasData()
    const maxFee = ethers.utils.parseUnits(
      Math.ceil(data.fast.maxFee).toString(),
      "gwei"
    )
    const maxPriorityFee = ethers.utils.parseUnits(
      Math.ceil(data.fast.maxPriorityFee).toString(),
      "gwei"
    )

    tx.maxFeePerGas = maxFee
    tx.maxPriorityFeePerGas = maxPriorityFee

    return tx
  }

  async getGasData(): Promise<GasData> {
    if (!this.gasStationUrl) {
      return DEFAULT_GAS_DATA
    }

    try {
      const response = await fetch(this.gasStationUrl)
      const data = (await response.json()) as GasData
      return data
    } catch (e) {
      logger.error(
        `Could not fetch gas data from ${this.gasStationUrl}: ${e.toString()}`
      )
      return DEFAULT_GAS_DATA
    }
  }
}

export default AutomaticGasWallet