Why FHE writes need headroom#
FHEVM derives a computed handle from blockhash(block.number - 1) and block.timestamp. An eth_estimateGas against the latest block can therefore recompute handles an earlier transaction in that block already granted, and price the ACL.allow that re-grants them at about 6k gas instead of about 114k. A Merkle claim sent right after a getClaimAmount preview was estimated about 330k (some 16%) short that way, and ran out of gas on Sepolia.
Every write in /fhe-airdrop, /fhe-vesting, /fhe-disperse, /testnet-faucet and the /fheoperator and mock-mint helpers now sends an explicit, padded limit instead of the wallet's bare estimate. Only the gas used is charged, so headroom costs nothing on success, though the account balance must cover the full limit.
The constants#
| Export | Value | Meaning |
|---|---|---|
DEFAULT_GAS_HEADROOM_PERCENT | 25 | Percent added on top of every estimate unless a client or hook sets gasHeadroomPercent. |
MAX_TRANSACTION_GAS | 16777216n | The EIP-7825 per-transaction cap (2^24), live on mainnet and Sepolia since Fusaka. A limit above it makes the node refuse the transaction. |
Both, with applyGasHeadroom and the GasHeadroomOption and GasOverride types, are exported from the root @tokenops/sdk. The percent and the two types are also exported from each product subpath and /fhe.
Per client: gasHeadroomPercent#
Every client config and React hook option takes gasHeadroomPercent (GasHeadroomOption). 0 sends the bare estimate. A negative, non-finite or non-number value throws InvalidArgumentError when the client is built. React hooks do not throw it during render: their queries stay disabled and their mutations reject with it.
import { MerkleAirdropClient } from "@tokenops/sdk/fhe-airdrop";
const airdrop = new MerkleAirdropClient({
publicClient,
walletClient,
address,
gasHeadroomPercent: 40, // 0 sends the bare estimate
});
// Per call: sent as is, skipping the estimate and the headroom.
await airdrop.claim({ entry, gas: 2_500_000n });Per call: gas#
Every write that takes an argument object accepts gas (GasOverride), and so do the hook mutations that wrap those writes. It is sent as is, skipping both the estimate and the headroom, and must be a positive bigint.
import { useCreateVesting } from "@tokenops/sdk/fhe-vesting/react";
const create = useCreateVesting({ address: manager, encryptor: () => sdk, gasHeadroomPercent: 40 });
create.mutate({ params, amount: 1_000_000n, gas: 3_000_000n });- The airdrop factory's positional admin setters take
gasas a trailing argument afteraccount. - The other positional setters (the vesting manager and factory admin methods, the disperse admin methods) and their hooks use the client's
gasHeadroomPercent. - A wallet or Safe that sets its own limit may ignore the SDK's; the write still goes through.
applyGasHeadroom and the cap#
applyGasHeadroom(estimate, percent) pads the estimate by the percent, rounded up, and clamps the result to MAX_TRANSACTION_GAS unless the estimate alone already exceeds the cap. The values below are computed by the installed build at the default percent.
| Estimate | applyGasHeadroom(estimate, 25) |
|---|---|
1000000n | 1250000n |
15000000n | 16777216n |
20000000n | 25000000n |
import { applyGasHeadroom, DEFAULT_GAS_HEADROOM_PERCENT } from "@tokenops/sdk";
const estimate = await publicClient.estimateContractGas(request);
const gas = applyGasHeadroom(estimate, DEFAULT_GAS_HEADROOM_PERCENT);A reverting estimate throws, it does not fall through#
When the estimate reverts, the SDK throws its typed error from the estimate (a ContractRevertErroror a more specific mapped class) instead of handing the call to the wallet to estimate again. With a local account under viem's nonce manager, that second attempt used to take a nonce for a transaction that was never sent, and every later write from the account waited behind the gap; Sepolia nodes refused them as gapped-nonce tx.
The wallet-chain check runs first#
Before estimating, every SDK write refuses a wallet whose chain is not the public client's with WalletChainMismatchError. A Sepolia public client paired with a mainnet wallet used to simulate and estimate on Sepolia and then send on mainnet, where the airdrop contracts share their Sepolia addresses. A public client without a chain is asked for its chain id. See the error palette.