Private gas
How the vault acts as its own ERC-4337 account so that each private transaction pays its gas in HYPE from the notes it spends, what validation checks, where the gas fee goes, the bundler at bundler.voidcash.io, the SDK's submitters and gas prices on Elysium.
A private transaction must not be sent from the user's address: the transaction's sender field would name them. So
PrivateVault is itself an ERC-4337 account. A UserOperation names the vault as its sender and carries the
transaction and its proof in its signature field. A bundler submits it to the canonical EntryPoint v0.8
(0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108), and the operation pays its own gas in HYPE out of the notes it spends: the gas fee,
ext.gasFee, fixed by the proof. Gas is always paid in HYPE, also when the operation moves a token: the gas then
comes from a separate HYPE note spent in the same proof, so a user needs some shielded HYPE to move a token privately.
No deposit, relayer or paymaster pays for anyone. Each operation brings its own gas, and what it does not use goes to the fee collector.
The UserOperation
| Field | Required value | Otherwise |
|---|---|---|
sender | The vault | |
nonce | uint256(uint192(nf0)) << 64: key = the low 192 bits of the first nullifier, sequence 0 | InvalidUserOp |
initCode, paymasterAndData | Empty | InvalidUserOp |
callData | abi.encodeCall(execute4337, (nf0)) | InvalidUserOp |
signature | abi.encode(Transaction, proof) | |
ext.caller | The vault | WrongCaller |
ext.data | abi.encode(minCallGasLimit) | InvalidUserOp if callGasLimit is below it |
ext.gasFee | At least the operation's maximum gas cost (below) | GasFeeBelowCost |
ext.recipient | Receives the exit, if any, less the exit fee |
accountGasLimits packs verificationGasLimit (high 128 bits) and callGasLimit (low 128 bits); gasFees packs
maxPriorityFeePerGas and maxFeePerGas the same way. The proof does not fix these fields, so a bundler can estimate
and price the operation. It fixes minCallGasLimit and the gas fee, because both are in ext, whose hash is a public
input.
Every operation has the same sender, and its nonce key is its first nullifier, so operations from different users never queue behind each other. A nullifier can be spent once anyway.
Validation does the whole transition
function validateUserOp(PackedUserOperation calldata op, bytes32, uint256) external returns (uint256 validationData);
function execute4337(uint256 exitId) external;validateUserOp can be called only by the EntryPoint (NotEntryPoint) and reverts while the vault is paused
(EnforcedPause). Otherwise, in order:
Decodes the transaction and the proof from signature and checks the fields in the table above.
Requires callGasLimit >= minCallGasLimit, so no bundler can starve the execution step that pays the exit.
Computes the operation's maximum cost and requires the gas fee to cover it:
maxCost = (verificationGasLimit + callGasLimit + preVerificationGas) * maxFeePerGas wei
require ext.gasFee >= maxCost else GasFeeBelowCostRuns the private transition of the vault: root, nullifiers, proof,
the three nullifiers marked spent, the four commitments inserted, the HYPE leaving out of totalBacking (and a token
exit out of the token's backing), the exit fee to feesAccrued (or the token's) and the exit recorded in exits.
Deposits the gas fee into the vault's own EntryPoint deposit (depositTo) and returns 0. The EntryPoint takes the
operation's prefund from that deposit right after.
maxCost is exactly the prefund the EntryPoint requires of an operation with no paymaster. Because the operation
deposits at least that much in its own validation, the deposit never pays for another operation. The tests check this
with an earlier overpayment sitting in the deposit: an operation paying less than its own cost is still refused
(test_vaultNeverPaysSomeoneElsesGas).
The execution step, execute4337(exitId), only pays the exit to its recipient, unless the exit is held or stopped.
It never reverts on a recipient that refuses the HYPE or a token transfer that fails, and if it fails for any other
reason the exit stays for
releaseExit. Everything else already happened in validation, so a failing execution cannot undo a spend. See
Paying an exit.
Estimating with a placeholder proof
An invalid proof does not revert validation. validateUserOp returns SIG_VALIDATION_FAILED (1), as ERC-4337 expects
of a bad signature, and writes nothing; the EntryPoint then refuses to execute the operation. A verifier that reverts
is treated the same way. This lets a wallet ask a bundler for gas estimates with a random placeholder proof of the real
size (9,152 bytes), then sign and prove once with the final numbers. The other checks still revert, so the estimate is
realistic.
A proof made for ERC-4337 names the vault as ext.caller, so it cannot be submitted through transact
(WrongCaller), and a proof made for transact must carry a gas fee of zero.
Where the gas fee goes
The gas fee is part of the HYPE leaving the notes, and goes only to the EntryPoint deposit. Where it comes from depends on the asset the operation moves:
HYPE exit: exitAmount = gasFee + exit, feeAmount = 0 out of the main pair
HYPE send: feeAmount = gasFee, exitAmount = 0 out of a spare HYPE fee note, when there is one
exitAmount = gasFee, feeAmount = 0 otherwise out of the main pair
token operation: feeAmount = gasFee out of the HYPE fee pair
exitAmount = the token exit 0 for a send
exit fee = floor(exit * 1%) in the exit's asset; the gas fee pays noneFor a token operation the vault requires feeAmount == gasFee when a token leaves (FeeAmountNotGasFee), so the fee
pair pays the gas and nothing else. The app picks the smallest HYPE note that covers the gas fee, in the same tree as
the token's notes, and returns its change to the spender. Without such a note it refuses before anything is signed.
A private HYPE send of a fixed amount (to an address, to a private address, or into a payment link) does the same when
it can: the smallest HYPE note that covers the gas fee goes to the fee pair, and the two largest of the others must
hold the amount (selectHypeWithFeeNote in the SDK). The send then has the public figures of a token send. When no
note can be set aside, it pays its gas from the main pair as before.
- Validation deposits
gasFeeinto the vault's EntryPoint deposit. - The EntryPoint pays the bundler (the bundle's beneficiary) the gas the operation actually used, at the price it was charged, out of that deposit.
- What is left stays in the deposit.
sweepFees()sends the whole deposit, with the HYPE exit fees, to the fee collector (0x145618DF0662dCd38a2Cf2AC4d24Dfe1FC8A4991), which splits it between the team and buying VOID to burn. See Fees.
The deposit must not stay empty: a bundler simulates its gas estimate at a tiny gas price and still requires a prefund
from the account's deposit, so with none it refuses with AA21 didn't pay prefund. Since sweepFees() empties the
deposit and anyone can call it, the keeper (services/keeper) keeps it at 0.001 HYPE (MIN_DEPOSIT_HYPE): after any
sweep it calls EntryPoint.depositTo(vault) for what is missing. That HYPE is swept to the collector at the next sweep.
No HYPE or token leaves the notes except through the exit fee and the large-exit hold, or as gas the EntryPoint
spends.
contracts/test/VaultGasFeeBypass.t.sol checks that a gas fee cannot be used to skip either.
How much is overpaid
The gas fee is set to the operation's maximum cost: its limits at the bundler's maximum fee. An operation uses less than its limits and is charged less than its maximum fee, so it always overpays. The whitepaper's figures, from the prices of 3 November 2026, measured on the vault's first implementation:
| Item | Value |
|---|---|
| Gas at the operation's limits | about 6 million |
| Gas used | about 5 million |
| Maximum fee per gas paid for | 0.12 gwei |
| Price charged | 0.1006 gwei (base fee plus tip) |
| Gas fee | about 7 × 10⁻⁴ HYPE |
| Share swept to the fee collector | roughly 30% |
Since the v2 upgrade an operation spends three notes, inserts two pairs of leaves instead of one and runs a larger
verifier, so it uses more gas. The second pair alone adds about 800,000 gas to every spend, more than the larger
verifier does. The SDK's end-to-end test measured, for the whole handleOps of one operation with the real proof:
| v2 operation | Gas used |
|---|---|
| Private send of HYPE | 5.21M |
| Private send of a token | 5.22M |
| Unshield of a token | 5.33M |
| Payment link creation | 5.20M |
| Payment link claim to an address | 5.26M |
validateUserOp alone used 4.94M to 5.12M, the most for a token's first exit, which writes the token's window. The
SDK's verification gas limit is 6,000,000 to keep a margin above that. The gas fee of every operation rose
accordingly.
The deposit and the stake
- Deposit. The vault's EntryPoint deposit receives every operation's gas fee in validation. Between bundles it
holds only what operations overpaid, and anyone else's deposit for the vault.
sweepFeeswithdraws all of it to the fee collector. - Stake.
addStake(unstakeDelaySec)forwards the HYPE sent with it toEntryPoint.addStakefor the vault. Anyone can call it. The vault has no function that unlocks or withdraws stake. missingAccountFunds. The vault ignores it: its own deposit, topped up by the gas fee a line earlier, covers the prefund.
The bundler
No public ERC-4337 bundler serves Elysium, so voidcash runs one: Alto, Pimlico's
open-source bundler, at https://bundler.voidcash.io. Its code and configuration are in services/bundler, and the
choice of version and the security check behind it in docs/elysium/bundler.md.
| Setting | Value | Why |
|---|---|---|
| Version | @pimlico/alto 0.0.21, pinned by integrity hash, on Node.js 24.22.2 | Latest stable release; carries the fix for GHSA-c9v7-pgv6-hrcc |
| EntryPoint | v0.8 only | The vault's |
| Chain | 99901, checked against the RPC at start | Refuses to sign for another chain |
--api-version | v2 | Enforces the gas-price and preVerificationGas checks: an operation with a zero gas price would otherwise be bundled at the executor's expense |
--rpc-methods | 8 methods | No boost, instant-bundling, asset-simulation or debug methods |
--safe-mode | false | No Elysium RPC offers debug_traceCall, which ERC-7562 tracing needs |
--enforce-unique-senders-per-bundle | true | Every vault operation has the same sender, so each bundle carries one: a failing operation never takes another user's with it |
--ceiling-max-fee-per-gas | 1 gwei | Ten times Elysium's floor, so a broken RPC price cannot make users sign a large gas fee |
--gas-price-expiry | 300 s | The browser proves between the quote and the send |
--resubmit-stuck-timeout, --max-resubmits | 30,000 ms, 4 | Few replacements, within the SDK's 2-minute wait |
--eip-7702-support | false | The vault does not use it |
The allowed methods are eth_chainId, eth_supportedEntryPoints, eth_estimateUserOperationGas,
eth_sendUserOperation, eth_getUserOperationReceipt, eth_getUserOperationByHash,
pimlico_getUserOperationGasPrice and pimlico_getUserOperationStatus.
Alto sits behind a gate (services/bundler/gate.mjs), the only public port. It forwards an operation only if its
sender is the vault, its EntryPoint is v0.8 and it has no factory, paymaster or EIP-7702 authorization. It answers
CORS for the app's origins only, limits each client address to 120 requests a minute and 20 estimates or sends a
minute, caps request bodies at 64 KB, and passes on no client header.
Running with safe mode off is acceptable here because only the vault's operations get in, and the vault's validation is known. What can make a vault operation pass simulation and then fail on-chain is another transaction spending the same note first. With one operation per bundle, Alto's simulation of each bundle right before sending catches that. Without safe mode Alto also keeps no reputation, so the vault, everyone's sender, can never be throttled for one user's failures.
What the bundler can and cannot do:
- It sees the network address of each request, and can delay or drop an operation.
- It can be paid at most the operation's gas fee, and nothing beyond it.
- It cannot read a note, change a recipient, an amount or the gas fee, or spend a note without its owner's proof.
Anyone can run another bundler for the vault. Anyone can also submit through transact directly and pay the gas from
their own address, which makes that address visible.
The SDK's submitters
The SDK (packages/sdk/src/submitters.ts) sends operations through a Submitter, which prices, estimates and sends.
| Submitter | How it sends | Use |
|---|---|---|
bundlerSubmitter(url, opts) | eth_sendUserOperation to a bundler that speaks Pimlico's RPC | The app and the MCP server, with voidcash's bundler |
directSubmitter(publicClient, wallet, gas) | EntryPoint.handleOps from wallet, which is the beneficiary and is refunded from the gas fee | Tests, or knowingly as a fallback: the wallet's address is the transaction's sender |
A private action runs in this order (PrivacyClient.privateSpend):
Price. pimlico_getUserOperationGasPrice, the standard quote as it is. The gas fee is the limits times this
price, so any margin would raise every user's fee.
Estimate. The transaction is built with a placeholder proof and zero prices and sent to
eth_estimateUserOperationGas. The verification gas is not estimated (a placeholder fails early, so the bundler would
estimate far too little): it is set to 6,000,000 (DEFAULT_VERIFICATION_GAS). The call gas is 150,000 for a spend
with an exit, of HYPE or of a token, and 60,000 otherwise. Only preVerificationGas comes from the bundler, times 11/10.
Sign and prove. The transaction is built again with the final limits and price, and its gas fee set to
maxGasCost: (verificationGasLimit + callGasLimit + preVerificationGas) * maxFeePerGas. The wallet signs, the
browser proves.
Send and wait. eth_sendUserOperation, then eth_getUserOperationReceipt every 2 seconds, backing off to 5,
for up to 2 minutes.
bundlerSubmitter takes an optional fallbackUrl. A plain HTTP refusal from a gateway moves it there for the rest
of the session; a JSON-RPC error is the bundler's answer about the operation and is shown to the user. directSubmitter
sends handleOps with a gas limit of 20,000,000.
Gas prices on Elysium
Elysium charges gas in HYPE and has no parent-chain data fee: data availability is AnyTrust, and the per-byte price is 0. Read on 3 November 2026:
| Item | Value |
|---|---|
| Base fee | 0.1 gwei, the chain's floor (ArbGasInfo getMinimumGasPrice()) |
eth_gasPrice | 0.15 gwei |
eth_maxPriorityFeePerGas | 0 |
| Bundler quote | maximum fee 0.120036 gwei, tip 0.00060018 gwei, at a base fee of 0.10003 gwei |
| Charged to a UserOperation | 0.1006 gwei |
| Block and transaction gas limit | 32,000,000 |
| Speed limit | 7,000,000 gas per second: about 1.3 vault operations per second |
The bundler's executors bid 1.5 times the base fee for their bundles, and Elysium charges only the base fee. The chain's owner can change its gas parameters; the constants page lists the values the code relies on.
The vault
PrivateVault in depth: its state, shielding HYPE and listed tokens, private transactions, exits with the 1% fee and the 24-hour hold on large ones, per-token limits and books, the fee sweeps, the pause, the three roles, the upgrades and the invariants the tests check.
The note tree
The append-only Poseidon2 Merkle tree that holds every note commitment - depth, pair insertion, root history, rollover and how wallets rebuild it.