noircashdocs

Gas and ERC-4337

How the vault pays for its users' gas as its own ERC-4337 account, what validation checks, how sales settle, and how the fee harvester keeps the deposit funded.

A private transaction must not be signed or paid for by the user's address: that would link the address to the spend. So PrivateVault is itself an ERC-4337 account. Anyone's bundler submits the operation, the canonical EntryPoint v0.8 pays the bundler from the vault's deposit, and the user pays a fee in shares out of the notes being spent.

The UserOperation

FieldRequired value
senderThe vault
nonceuint256(uint192(nf0)) << 64: key = low 192 bits of the first nullifier, sequence 0
initCode, paymasterAndDataEmpty
callDataabi.encodeCall(execute4337, (nf0))
signatureabi.encode(Transaction, proof)
ext.callerThe vault
ext.dataabi.encode(minCallGasLimit, ethRecipient, minEthOut); ethRecipient = 0 for anything but a sale

Any other value makes validateUserOp revert (WrongCaller, InvalidUserOp). One nonce key per first nullifier means operations from different users never queue behind each other, and each nullifier is usable once anyway.

Validation does the whole transition

function validateUserOp(PackedUserOperation calldata op, bytes32, uint256 missingAccountFunds)
    external returns (uint256 validationData);

function execute4337(uint256 saleId) external;

validateUserOp, callable only by the EntryPoint (NotEntryPoint), in order:

Decodes the transaction and proof and checks the fields in the table above.

Requires callGasLimit >= minCallGasLimit. The minimum is inside ext.data, bound to the proof, so a bundler cannot starve the sale's execution. The other gas fields are free, because bundlers replace them when estimating.

Checks that the fee covers the operation's maximum cost, at the vault's price average (below).

Runs the private transition: root, nullifiers, proof, nullifiers marked spent, both commitments inserted, the fee moved to gasReserve, and the exit transferred or, for a sale, put in escrow.

Execution only settles sales. Because every state change already happened in validation, a failing execution cannot undo it, and nobody can make the deposit pay for an operation that did nothing.

The fee check

maxCost = (verificationGasLimit + callGasLimit + preVerificationGas) * maxFeePerGas        wei
feeWei  = convertToAssets(ext.gasFee) * 1e18 / tokensPerEthEma
require   feeWei * 10_000 >= maxCost * (10_000 + GAS_MARGIN_BPS)                          GAS_MARGIN_BPS = 2_000

The fee in shares must be worth the maximum gas cost plus 20%, valued at tokensPerEthEma, the moving average of NOIR's market price kept by the vault (see The market). ERC-7562 forbids reading the curve or the pool during validation, but the vault's own storage is allowed: poke() folds the market price into the average outside validation, and validateUserOp reads only the stored value. With no price yet (tokensPerEthEma == 0) validation reverts (NoPrice). The check uses the operation's actual fields, so it protects the deposit whatever a bundler sets.

The fee covers the maximum declared gas, and actual gas is lower. The difference is not refunded to the user; it goes to holders once the deposit is full (see the harvester below).

Estimating with a placeholder proof

An invalid proof does not revert: validateUserOp returns SIG_VALIDATION_FAILED (1), as ERC-4337 expects of a bad signature, and writes nothing. The EntryPoint refuses to execute such an operation. This lets a wallet ask a bundler for gas estimates with a random 9,152-byte placeholder proof, then have the wallet sign once and prove once with the final gas numbers. The other checks (root, nullifiers, fee, fields) still revert, so the estimate is realistic.

The app's defaults: verification gas limit 5,500,000, call gas 650,000 for a sale and 60,000 otherwise.

Sales: escrow, settlement, fallback

A sale (ethRecipient != 0) cannot trade in validation, because ERC-7562 forbids touching the curve or the pool there. So:

  1. In validation, the exit amount (after the fee) is stored in pendingSales[nf0] = Sale(amount, ethRecipient, minEthOut). An amount of zero reverts.
  2. In execution, execute4337(nf0) deletes the sale, approves saleRouter for amount and calls PrivateRouter.onPrivateExit(amount, ethRecipient, minEthOut). The router pulls the NOIR with transferFrom, sells it on the live venue and sends the ETH. The vault emits SaleSettled(nf0, true).
  3. If the router call reverts (price moved past minEthOut, no liquidity, or the ETH transfer failed), the pull is undone with it. The vault resets the approval, transfers the NOIR to ethRecipient and emits SaleSettled(nf0, false). execute4337 never reverts on a failed sale.
  4. If execution ran out of gas, the sale stays pending. Anyone may then call claimSale(nf0), which transfers the NOIR to ethRecipient.
function claimSale(uint256 saleId) external;
function pendingSales(uint256 saleId) external view returns (uint256 amount, address ethRecipient, uint256 minEthOut);

claimSale can also be called by another operation in the same bundle before a sale executes. The sale then arrives at the recipient as public NOIR instead of ETH. The caller gains nothing; the recipient can sell from there. This is an accepted limit: the EntryPoint exposes no "same transaction" signal.

The gas reserve and the deposit

Gas fees accumulate in gasReserve, in NOIR held by the vault. Only the harvester can take it (releaseGasReserve reverts with NotHarvester for anyone else). Every harvest() keeps the deposit funded, in this order (full detail in The vault):

  1. ETH first. The harvested Pons creator fees, less the 0.5% caller tip, top up EntryPoint.balanceOf(vault) to targetDeposit.
  2. Reserve sale, if still short. The harvester sells just enough of the gas reserve to cover the shortfall, and only within 3% of the price average after the Pons fees, halving the size up to 8 attempts. A manipulated price makes it sell nothing rather than sell cheap.
  3. Reserve donation, if full. When the deposit is at or above the target, the whole reserve is donated to the vault. The overpayment that operations made goes back to every holder.
ParameterValue
targetDepositFeeHarvester immutable; deploy default 0.1 ETH (TARGET_DEPOSIT)
MAX_SLIPPAGE_BPS300: a reserve sale must land within 3% of the average, after the Pons fees
CALLER_TIP_BPS50: the caller receives 0.5% of the harvester's ETH

Deposit and stake

  • Deposit. The deploy script deposits GAS_DEPOSIT (default 0.1 ETH). The harvester tops it up; anyone else can with EntryPoint.depositTo(vault). The vault has no function that calls withdrawTo, so nobody can withdraw the deposit.
  • Stake. PrivateVault.addStake(unstakeDelaySec) forwards ETH to EntryPoint.addStake, callable by anyone. The vault has no unlockStake or withdrawStake call, so nobody can ever unlock it.
  • missingAccountFunds. Normally zero, since the deposit pays. If it is not, the vault tries to pay from its own ETH balance; if that fails, the EntryPoint rejects the operation and the bundler drops it.

One sender for everyone

Every private transaction is a UserOperation with the same sender, the vault; the nonce key is the transaction's first nullifier, so operations of different users never queue behind each other. Bundlers limit how many operations of one sender they hold: ERC-7562 allows an unstaked sender 4 in the mempool (SAME_SENDER_MEMPOOL_COUNT), and a staked one has no limit.

Alto, the bundler behind Pimlico's endpoint, was run in front of a fork with a noircash deployment. It accepted the vault's validation and, with 3-second blocks, took six users' private sends at once, bundling operations of the vault together, with no stake. Traced with the ERC-7562 collector, the validation uses no banned opcode and touches only the vault's own storage, plus the code of the verifier, the hasher and two libraries.

The vault is not staked at deploy: stake is locked forever (the vault cannot unlock it), and the bundler that serves Robinhood Chain does not need it. If a bundler ever does, anyone can stake the vault with addStake.

If the deposit is empty or no bundler serves the vault, users can still submit transact directly or sell through PrivateRouter.sell, paying gas from an address of their choice. See Architecture.

Measured on testnet

MeasureValue
Sale through handleOpsabout 4.76M to 5.25M gas
Gas price on testnet0.01 gwei
End-to-end sale with Pimlico's public bundler9.4 s, one wallet signature
Fee paid versus actual costabout 2.5 times the real cost (maximum-gas pricing plus the 20% margin)

These were measured on the testnet deployment (chain 46630), which predates the Pons model: the same circuit, verifier, ERC-4337 validation and sale escrow, with NOIR held in the token contract itself rather than in a separate vault, and sales swapped on a noircash pool. Validation passed Pimlico's ERC-7562 simulation there. Current values are in Constants.

On this page