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
| Field | Required value |
|---|---|
sender | The vault |
nonce | uint256(uint192(nf0)) << 64: key = low 192 bits of the first nullifier, sequence 0 |
initCode, paymasterAndData | Empty |
callData | abi.encodeCall(execute4337, (nf0)) |
signature | abi.encode(Transaction, proof) |
ext.caller | The vault |
ext.data | abi.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_000The 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:
- In validation, the exit
amount(after the fee) is stored inpendingSales[nf0] = Sale(amount, ethRecipient, minEthOut). An amount of zero reverts. - In execution,
execute4337(nf0)deletes the sale, approvessaleRouterforamountand callsPrivateRouter.onPrivateExit(amount, ethRecipient, minEthOut). The router pulls the NOIR withtransferFrom, sells it on the live venue and sends the ETH. The vault emitsSaleSettled(nf0, true). - 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 toethRecipientand emitsSaleSettled(nf0, false).execute4337never reverts on a failed sale. - If execution ran out of gas, the sale stays pending. Anyone may then call
claimSale(nf0), which transfers the NOIR toethRecipient.
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):
- ETH first. The harvested Pons creator fees, less the 0.5% caller tip, top up
EntryPoint.balanceOf(vault)totargetDeposit. - 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.
- 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.
| Parameter | Value |
|---|---|
targetDeposit | FeeHarvester immutable; deploy default 0.1 ETH (TARGET_DEPOSIT) |
MAX_SLIPPAGE_BPS | 300: a reserve sale must land within 3% of the average, after the Pons fees |
CALLER_TIP_BPS | 50: 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 withEntryPoint.depositTo(vault). The vault has no function that callswithdrawTo, so nobody can withdraw the deposit. - Stake.
PrivateVault.addStake(unstakeDelaySec)forwards ETH toEntryPoint.addStake, callable by anyone. The vault has nounlockStakeorwithdrawStakecall, 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
| Measure | Value |
|---|---|
Sale through handleOps | about 4.76M to 5.25M gas |
| Gas price on testnet | 0.01 gwei |
| End-to-end sale with Pimlico's public bundler | 9.4 s, one wallet signature |
| Fee paid versus actual cost | about 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.
The market and the router
Where NOIR trades - its Pons bonding curve, then the Pons Uniswap v4 pool after graduation - how noircash identifies that pool, the price average the vault keeps, and PrivateRouter's buy, sell and quote.
NOIR on Pons
What NOIR is, the Pons V2 terms it trades under, who receives its fees and how to check them on-chain, and how the noircash contracts are deployed around it.