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.
PrivateVault holds the HYPE, and the listed ERC-20 tokens, behind every note. Shielding adds an asset to it, a
private transaction moves value between notes inside it, and an exit pays an asset out of it to a public address. It
is also the ERC-4337 account that every private transaction is sent from; that part has
its own page.
A note holds an amount of one asset in its base units: HYPE in wei, a token in its smallest unit. The circuit calls
that amount shares, but there is no share price: one share is one base unit, and nothing ever changes a note's
value. Since the v2 upgrade of 5 November 2026 the vault holds tokens beside HYPE; the listed tokens are VOID
(0xD31B21Cb80E431AC996269deC31A40fCf048B77f), VOLTA, BLOB and YIELDEX. Tokens covers what differs for them.
The vault runs behind an ERC-1967 proxy. The proxy, 0x5535fa0d95E4e03783C3A5d693E2f7225e6d23d4, is the address users, integrators and the app
use; it holds the state and the HYPE. The code runs from its implementation, 0x45648A8aE69C2c4EAE0dc5727E66F8921F771181. See
Upgrades.
State
| Storage | Type | Meaning |
|---|---|---|
totalBacking | uint256 | HYPE backing every unspent HYPE note |
pendingExitTotal | uint256 | HYPE of exits decided and not yet paid. It no longer backs any note |
feesAccrued | uint256 | HYPE exit fees collected and not yet swept to the fee collector |
largeExitThreshold | uint256 | A HYPE exit paying out more than this waits 24 hours. 1,000 HYPE at deployment; the admin can set it between 1 and 1,000,000 HYPE |
feeCollector | address | Receives the HYPE exit fees and what operations overpaid for gas: 0x145618DF0662dCd38a2Cf2AC4d24Dfe1FC8A4991 |
entriesPaused | bool | While true, shield and every shieldToken revert. Nothing else is affected |
exits(exitId) | Exit | Exits not yet paid, of any asset, keyed by the transaction's first nullifier |
nullifierSpent(nf) | bool | Spent nullifiers |
keysOf(owner) | (uint256 opk, bytes32 viewKey) | The registered owner key and X25519 viewing key of each address |
registerNonces(owner) | uint256 | Nonces of registerFor signatures |
tokenConfig(token) | TokenConfig | Whether a token is listed, whether its entries are paused, and its limits. See Tokens |
tokenBook(token) | TokenBook | A token's backing, pending exits, accrued fees and current window |
tokenFeeRecipient | address | Receives the token exit fees, in each token: the team's recipient |
struct Exit {
uint128 amount; // what the recipient receives, after the exit fee, in the exit's asset
uint64 payableAt; // when it can be paid
bool stopped; // set by the guardian
address recipient;
address asset; // 0 = HYPE, otherwise the token
}asset was added by the v2 upgrade in a slot of its own, so exits created before it read asset == 0, HYPE.
exits(exitId) returns the five fields, asset last.
The note tree's state (root(), nextLeafIndex, treeNumber, isFinalRoot) is described in
The note tree. Four addresses are immutables of the implementation, set in its
constructor: verifier (0x29688590CA80928f8f0039334D9f723180cd482a), hasher (0x46c60Cbe41b4A18751b85b7cDfC8d03BeC35E9e5), hasher4, the four-input hasher of
token notes (0xdf0EDcbbBBdd91ec58a7dBbc2b7457d553Aa2bc0), and entryPoint (0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108, ERC-4337 EntryPoint v0.8).
VaultUpdated(totalBacking, pendingExitTotal) is emitted after every change to the HYPE totals, and
TokenBookUpdated(token, backing, pendingExits) after every change to a token's, so their history can be rebuilt from
events alone.
Shielding
function shield(uint256 stub, bytes calldata ciphertext) external payable returns (uint32 index);shield moves msg.value HYPE into a new note. The caller chooses the note's stub, H(opk, rho, r), so nobody else
can recognise the note, and the vault completes it with the amount it received:
cm = H(1, stub, msg.value)
totalBacking += msg.valueThe commitment is inserted into the tree paired with an empty leaf, so one shield takes two leaves. The vault emits
Shielded(from, index, amount), NoteAdded(index, cm, ciphertext) and VaultUpdated. There is no fee.
| Check | Error |
|---|---|
| Entries are not paused | EntriesArePaused |
stub is a field element (below the BN254 scalar modulus) | NotAFieldElement |
msg.value is not zero | ZeroAmount |
msg.value is below 2^120 wei, the circuit's range | AmountTooLarge |
The amount and the sending address are public: a shield shows who put how much HYPE in. What happens to the note afterwards does not. See Notes.
Shielding a token
function shieldToken(address token, uint256 amount, uint256 stub, bytes calldata ciphertext)
external returns (uint32 index);shieldToken moves amount of a listed token from the caller, who must have approved the vault for it, into a new
note:
cm = H(3, stub, amount, uint160(token))
tokenBook[token].backing += amountLike a HYPE shield, it inserts the note beside an empty leaf and charges no fee. It emits
TokenShielded(from, token, index, amount), NoteAdded(index, cm, ciphertext) and TokenBookUpdated.
| Check | Error |
|---|---|
| The token is listed | TokenNotListed |
| Neither the global entries pause nor the token's own is on | EntriesArePaused |
stub is a field element | NotAFieldElement |
amount is not zero | ZeroAmount |
amount is below 2^120 | AmountTooLarge |
The vault's balance of the token grew by exactly amount | TokenAmountMismatch |
The last check refuses a token that takes a fee on transfer: such a token could otherwise back a note with less than the note claims.
Tokens sent to the vault with a plain transfer create no note, and no function of the vault returns them. The
only way to put a token in is shieldToken.
Private transactions
Every spend is one Transaction: three notes in, four notes out, and optionally an asset out of the notes. The main
pair moves one asset, HYPE or a token; the fee pair is HYPE and pays a token operation's gas; the fourth output is a
zero-value HYPE pad. See The circuit.
struct ExtData {
address recipient; // receives the exit, less the gas fee and the exit fee
address caller; // if non-zero, only this address may submit; the vault itself for ERC-4337
uint256 gasFee; // wei of HYPE leaving, for the operation's gas (ERC-4337 only)
bytes data; // ERC-4337: abi.encode(minCallGasLimit)
bytes ciphertext0;
bytes ciphertext1;
bytes ciphertext2;
bytes ciphertext3;
}
struct Transaction {
uint256 root;
uint256[3] nullifiers; // main pair, then the HYPE fee note
uint256[4] commitments; // main pair, the HYPE fee change, the zero-value pad
address exitAsset; // 0 = HYPE, or nothing leaves the main pair
uint256 exitAmount; // in exitAsset units, out of the main pair
uint256 feeAmount; // HYPE out of the fee pair
ExtData ext;
}The proof's 14 public inputs, in the order the circuit declares them, are root, the three nullifiers, the four
commitments, exitAmount, uint160(exitAsset), feeAmount, extDataHash(ext) and the vault's EIP-712 domain
separator as two 128-bit halves. extDataHash is keccak256(abi.encode(ext)) mod p, so every field of ExtData, the
gas fee and the recipient included, is fixed by the proof and by the wallet signature the proof checks.
What leaves the notes follows one rule for every operation:
HYPE leaving = feeAmount + (exitAsset == 0 ? exitAmount : 0)
token leaving = exitAmount of exitAsset, when exitAsset != 0| Operation | exitAsset | exitAmount | feeAmount |
|---|---|---|---|
| Private send of HYPE, with a spare HYPE note | 0 | 0 | gas fee |
| Private send of HYPE, without one | 0 | gas fee | 0 |
| Unshield of HYPE | 0 | gas fee + exit | 0 |
| Private send of a token | 0 | 0 | gas fee |
| Unshield of a token | the token | exit | gas fee |
The vault cannot tell a private send of a token from one of HYPE by exitAsset, which is 0 for both, nor by where
the gas fee sits when the HYPE send pays it from a spare HYPE note, as the SDK does whenever it can. A HYPE send that
pays its gas from the main pair shows it in the calldata.
What every spend checks
Both entry points, transact and validateUserOp, run the same transition. In order:
| Check | Error |
|---|---|
root is a known root of the note tree | UnknownRoot |
| The three nullifiers differ | DuplicateNullifier |
| No nullifier is spent | NullifierAlreadySpent |
| Nullifiers and commitments are field elements | NotAFieldElement |
exitAmount and feeAmount are below 2^120 | AmountTooLarge |
| For a token exit: the token is listed | TokenNotListed |
For a token exit: exitAmount is not zero | ZeroAmount |
For a token exit: feeAmount equals ext.gasFee, so the HYPE side pays the gas and nothing else | FeeAmountNotGasFee |
| For a token exit: it names a recipient | ZeroAddress |
ext.gasFee is at most the HYPE leaving | FeeExceedsExit |
| HYPE leaving beyond the gas fee names a recipient | ZeroAddress |
| The verifier accepts the proof | InvalidProof (through ERC-4337, validation fails instead: see Gas) |
Then the vault marks the three nullifiers spent (NullifierSpent three times) and inserts the four commitments as two
pairs (NoteAdded four times, ciphertexts 0 to 3). A token exit is recorded as described under Tokens.
If HYPE leaves the notes, the vault takes it out:
totalBacking -= HYPE leaving
gas = ext.gasFee to the EntryPoint deposit (ERC-4337 only)
out = HYPE leaving - gas a HYPE exit, before its fee (0 for a token operation)
fee = floor(out * 100 / 10_000) EXIT_FEE_BPS = 100, 1%
net = out - fee what the recipient receives
feesAccrued += fee
pendingExitTotal += net
exits[nullifiers[0]] = Exit(net, payableAt, false, recipient, 0)For a HYPE operation it emits ExitCreated(exitId, recipient, net, fee, gas, payableAt) and VaultUpdated. A
private send through ERC-4337, of HYPE or of a token, takes only its gas fee out of the notes: it emits ExitCreated
with an amount and a fee of zero, and creates no exit. A token exit emits TokenExitCreated instead, which carries
its gas fee, and VaultUpdated for the gas taken out of totalBacking.
transact: submitted by anyone
function transact(Transaction calldata t, bytes calldata proof) external;Anyone can submit a transaction directly and pay its gas from their own address, or only ext.caller if it is set.
The submitter is never paid from the notes:
| Rule | Error |
|---|---|
ext.caller is not the vault (a proof made for ERC-4337 cannot be submitted here) | WrongCaller |
If ext.caller is set, msg.sender is that address | WrongCaller |
ext.gasFee is zero | GasFeeNotAllowed |
An exit that is not held is paid in the same transaction. If the recipient refuses the HYPE, or the token transfer
fails, the whole transaction reverts (HypeTransferFailed, or the token's own error through SafeERC20) and nothing
is spent. The submitting address is public, so this path shows who sent the
transaction; the app uses ERC-4337 instead.
Registration
function register(uint256 opk, bytes32 viewKey) external;
function registerFor(address owner, uint256 opk, bytes32 viewKey, uint256 deadline, bytes calldata signature) external;register stores the caller's owner key and viewing key in keysOf. registerFor does the same for owner with an
EIP-712 Register(address owner,uint256 opk,bytes32 viewKey,uint256 nonce,uint256 deadline) signature, from an EOA or
an ERC-1271 wallet, submitted by anyone. An owner key of zero or outside the field reverts (NotAFieldElement), an
expired or wrong signature reverts (SignatureExpired, InvalidSignature), and registering again replaces the keys.
Both emit KeysRegistered. See Keys.
Exits
An exit is an asset leaving the notes for a public address. This section describes HYPE exits; token exits share the fee, the hold and the payout paths, with their own limits (see Tokens). The terms of a HYPE exit:
| Term | Value |
|---|---|
| Exit fee | EXIT_FEE_BPS = 100: 1% of what leaves after the gas fee, rounded down (exitFeeOf(amount)) |
| Large exit | One whose net payout is above largeExitThreshold |
| Hold | LARGE_EXIT_DELAY = 24 hours from the transaction |
| Threshold range | MIN_LARGE_EXIT_THRESHOLD = 1 HYPE to MAX_LARGE_EXIT_THRESHOLD = 1,000,000 HYPE |
For example, an unshield of 10 HYPE takes 10 HYPE plus the gas fee out of the notes. The recipient receives 9.9 HYPE,
0.1 HYPE goes to feesAccrued, and the gas fee goes to the EntryPoint deposit. The gas fee pays no exit fee.
The hold compares the net payout with the threshold in force when the exit is created, and fixes payableAt then.
Changing the threshold later neither releases nor holds an existing exit.
Paying an exit
The exit's ID is the transaction's first nullifier. It is paid in one of three ways:
| Path | When | Who |
|---|---|---|
transact | At once, unless held | The submitter's transaction |
execute4337(exitId) | In the execution step of the UserOperation, unless held or stopped | The EntryPoint |
releaseExit(exitId) | Once payableAt has passed and the exit is not stopped | Anyone; the asset goes to the exit's recipient |
execute4337 never reverts on a recipient that refuses the HYPE, or on a token transfer that fails (it uses
trySafeTransfer): the exit then stays in exits for releaseExit. An exit whose execution step ran out of gas, or
ran while the vault was paused, also stays. releaseExit reverts with NoSuchExit, ExitIsStopped or ExitNotDue
when it cannot pay, and with HypeTransferFailed if the recipient refuses the HYPE (a failing token transfer reverts
it too). Paying a HYPE exit deletes it, takes its amount out of pendingExitTotal and emits ExitPaid and
VaultUpdated; paying a token exit takes it out of the token's pendingExits and emits TokenExitPaid and
TokenBookUpdated.
Stopping and resuming
function stopExit(uint256 exitId) external; // GUARDIAN_ROLE
function resumeExit(uint256 exitId) external; // GUARDIAN_ROLEThe guardian can stop any exit not yet paid (ExitStopped), held or not, and resume it (ExitResumed). A resumed
exit is payable from its original payableAt. Both revert with NoSuchExit for an unknown exit; resumeExit reverts
with ExitNotStopped for one that is not stopped. Both work while the vault is paused.
A stopped exit's amount stays reserved, in pendingExitTotal for HYPE or in the token's pendingExits. The vault has
no function that sends it elsewhere or returns it to a note: it waits until the guardian resumes the exit.
A HYPE exit whose payout is above the threshold, 1,000 HYPE at deployment, waits 24 hours before anyone can release it, and the guardian can stop it meanwhile. Exits at or below the threshold are paid in the same transaction.
Tokens
Besides HYPE, the vault holds ERC-20 tokens its admin lists, one at a time. Today four are listed: VOID
(0xD31B21Cb80E431AC996269deC31A40fCf048B77f), listed with the v2 upgrade on 5 November 2026, and VOLTA, Volta's own token
(0x0f79931689Af199e7CF868ed1107bBA2C83c68f7), listed on 6 November 2026, and BLOB
(0x9a9815Af521149E8D982791703A31bca2F95f534), listed on 7 November 2026, and YIELDEX (0x301eB259De132D592966fe0712DB75b27792A68a), a Volta launch listed on 8 November 2026. BLOB pays its holders dividends in HYPE, which each holder claims from the token; BLOB held in the vault earns none for its owners, since the dividends that accrue to the vault cannot be claimed.
struct TokenConfig {
bool listed;
bool entriesPaused;
uint256 largeExitThreshold; // a net exit above it waits 24 hours
uint256 windowCap; // net exits paid at once per 24-hour window
}
struct TokenBook {
uint256 backing; // the token held by notes
uint256 pendingExits; // decided, not yet paid
uint256 feesAccrued; // exit fees, for the token fee recipient
uint64 windowStart;
uint256 windowOut; // net exits paid at once in the current window
}Listing
| Function | Who | Effect |
|---|---|---|
listToken(token, threshold, windowCap) | DEFAULT_ADMIN_ROLE | Lists a token (TokenListed). Reverts on address 0 (ZeroAddress), an address without code (NotAToken) or a token already listed (TokenAlreadyListed) |
setTokenLimits(token, threshold, windowCap) | DEFAULT_ADMIN_ROLE | Changes a listed token's limits (TokenLimitsSet). Exits already created keep their time |
setTokenEntriesPaused(token, paused) | GUARDIAN_ROLE | Closes or opens one token to new shields (TokenEntriesPaused) |
setTokenFeeRecipient(recipient) | DEFAULT_ADMIN_ROLE | Sets where token exit fees go (TokenFeeRecipientSet); zero reverts (ZeroAddress) |
Both limit-setting functions require a threshold above zero and a window cap at least as large as the threshold
(TokenLimitsOutOfRange); setTokenLimits and setTokenEntriesPaused revert for a token that is not listed
(TokenNotListed). A listed token is never removed, since its notes must stay spendable; it can only be closed to new
deposits.
A listed token must be a plain ERC-20: no fee on transfer, no rebasing or other balance changes outside transfers, no
transfer hooks into the receiver, and at most 18 decimals, so that every amount fits the circuit's 120 bits. The
vault enforces the first through shieldToken's balance check; the upgrade script that listed VOID checked its code,
its decimals and that its supply is below 2^120. An issuer able to freeze the vault's address could freeze that
token's exits; other assets would not be affected, since payouts are per asset.
| VOID's limits | Value |
|---|---|
| Large-exit threshold | 5,000,000 VOID, 0.5% of the supply |
| Window cap | 25,000,000 VOID per 24-hour window |
| Token fee recipient | The team's recipient, the same address the fee collector pays the team's half to |
Token exits
A token exit (exitAsset = the token) is recorded in validation, or in transact:
tokenBook.backing -= exitAmount reverts if the token's notes hold less
fee = floor(exitAmount * 100 / 10_000) 1%, in the token
net = exitAmount - fee
tokenBook.feesAccrued += fee
if now >= windowStart + 24 hours: windowStart = now, windowOut = 0
held = net > largeExitThreshold or windowOut + net > windowCap
if held: payableAt = now + 24 hours
else: payableAt = now, windowOut += net
tokenBook.pendingExits += net
exits[nullifiers[0]] = Exit(net, payableAt, false, recipient, token)It emits TokenExitCreated(exitId, token, recipient, net, fee, gasFee, payableAt) and TokenBookUpdated. The gas
fee is HYPE from the fee pair and pays no exit fee.
The window is fixed, not sliding: it restarts at the first token exit at least 24 hours after the window's start.
windowOut counts only exits payable at once; held exits are not counted, since the guardian has 24 hours to stop
them. So a bug in the verifier or in the vault could drain at most about one window cap of each token before the
guardian can act, and, because the windows are fixed, a full cap at the end of one window and another at the start of
the next.
Token exits are paid, stopped, resumed and released through the same functions as HYPE exits (see Paying an exit).
A VOID exit whose payout is above 5,000,000 VOID, or that would take the VOID paid out at once in the current 24-hour window above 25,000,000 VOID, waits 24 hours before anyone can release it, and the guardian can stop it meanwhile.
Token fees
function sweepTokenFees(address token) external;Anyone may call it. It sends the token's feesAccrued, in the token, to tokenFeeRecipient, the team, and emits
TokenFeesSwept(token, recipient, amount). It does nothing when there is nothing to send and reverts (ZeroAddress)
while no recipient is set. Token exit fees never reach the fee collector, so they buy no VOID and burn nothing: the
burn is fed by HYPE exit fees.
Fees
function sweepFees() external;sweepFees sends two things to feeCollector, and anyone may call it:
feesAccrued, the HYPE exit fees;- the vault's whole EntryPoint deposit, withdrawn with
withdrawTo. Every operation deposits its own gas fee in its validation, so what is left between bundles is what operations paid for gas and did not use. HYPE anyone else deposits for the vault goes the same way.
It emits FeesSwept(collector, amount), and does nothing when there is nothing to send. It keeps working while the
vault is paused: it is not a payout from the notes. The fee collector splits what arrives in half, between the team
and buying VOID to burn; see VOID and Volta.
receive() accepts HYPE only from the EntryPoint (NotEntryPoint): a withdrawal of the deposit. Any other HYPE
reaches the vault only through shield.
The pause
pause() stops every spend of a note and every payout until the guardian calls unpause(). It has no time limit.
paused() reads it; OpenZeppelin's Paused(account) and Unpaused(account) are emitted.
| While paused | Functions |
|---|---|
Revert (EnforcedPause) | transact, validateUserOp, execute4337, releaseExit |
| Keep working | shield and shieldToken (unless entries are paused), register, registerFor, sweepFees, sweepTokenFees, stopExit, resumeExit, addStake, the admin's and guardian's setters, every view |
So private sends, unshields and payment-link claims all stop, for HYPE and every token, through ERC-4337 or
transact. An exit validated before the pause stays in exits until the pause ends; anyone can then release it.
The entries pauses are separate from the pause and narrower: they stop only shields. setEntriesPaused(bool) stops
shield and shieldToken for every token at once (EntriesPaused), one switch for an emergency.
setTokenEntriesPaused(token, bool) stops one token's shieldToken (TokenEntriesPaused) and leaves HYPE and the
other tokens open. With entries paused, every note can still be spent and every exit paid.
Roles
The vault has three OpenZeppelin AccessControl roles, granted by initialize:
function initialize(
uint256 largeExitThreshold_,
address feeCollector_,
address admin,
address upgrader,
address guardian_
) external;initialize reverts on a zero address (ZeroAddress) and on a threshold out of range (ThresholdOutOfRange), and
runs once.
| Role | Functions | Effect |
|---|---|---|
DEFAULT_ADMIN_ROLE | grantRole, revokeRole | Grants and revokes every role, its own included |
setLargeExitThreshold(threshold) | Sets the payout above which a HYPE exit waits 24 hours, within 1 to 1,000,000 HYPE (LargeExitThresholdSet) | |
setFeeCollector(collector) | Sets where sweepFees sends the fees from now on (FeeCollectorSet); zero reverts (ZeroAddress) | |
listToken, setTokenLimits, setTokenFeeRecipient | Lists a token, sets its limits, sets where token exit fees go. See Tokens | |
UPGRADER_ROLE | upgradeToAndCall(newImplementation, data) | Replaces the implementation, with no timelock. See Upgrades |
GUARDIAN_ROLE | pause(), unpause() | Stops and resumes every spend and every payout |
setEntriesPaused(bool) | Stops and resumes shield and every shieldToken | |
setTokenEntriesPaused(token, bool) | Stops and resumes one token's shieldToken | |
stopExit(exitId), resumeExit(exitId) | Stops and resumes one exit not yet paid, of any asset |
The thresholds and window caps are the admin's, not the guardian's, so a guardian key alone cannot open the way for
large exits. Any
holder can give up a role with renounceRole(role, ownAddress), and hasRole(role, account) reads who holds one. At
deployment a single key holds all three roles.
No role can read a note or spend one: notes are encrypted to their owners' viewing keys, and a spend needs the owner's nullifier key and a fresh signature by the owner's wallet. The guardian cannot move HYPE or tokens. The admin decides where future fees go and which tokens can enter. The upgrader replaces the code that runs against the vault's storage, so it decides every rule of the vault, the verifier among them. See the trust model.
Upgrades
PrivateVault is an OpenZeppelin UUPSUpgradeable implementation behind an ERC1967Proxy, created at a fixed
address through CreateX's CREATE3.
- State lives in the proxy.
initializewrites the empty note tree, the threshold, the fee collector and the roles. The implementation's constructor disables its own initializers, so nobody can initialize it, and an upgrade cannot initialize the proxy again. - The verifier is immutable in the implementation. The constructor sets the EIP-712 domain name (
voidcash, version1), the verifier, the hasher, the four-input hasher and the EntryPoint. A new verifier, or any other new address, comes with a new implementation. - Upgrading. A holder of
UPGRADER_ROLEcallsupgradeToAndCall(newImplementation, data);_authorizeUpgradechecks the role. It takes effect in that transaction. - Storage is append-only. The layout is the note tree's variables, then the vault's own in the order of the
table above, from
totalBackingtoregisterNonces, then the three variables v2 appended:tokenConfig,tokenBookandtokenFeeRecipient. OpenZeppelin's access control, pause and initializer state sit in their own ERC-7201 namespaced slots, and the reentrancy guard uses transient storage. An upgrade may only add variables after the existing ones.contracts/test/VaultStorageLayout.t.solchecks the layout from the compiler's output.
An upgrade keeps everything in the proxy: every note, the tree and its root history, the spent nullifiers, the totals,
the accrued fees, the threshold, the fee collector, every pending exit with its time and stop flag, the registered
keys, the roles and the HYPE. The EntryPoint deposit belongs to the proxy's address. The EIP-712 domain names the
proxy as its verifying contract, so Spend and Register signatures stay valid as long as the new implementation
keeps the name and version. contracts/test/VaultUpgrade.t.sol checks all of this, and that the new verifier decides
from the upgrade on.
The v2 upgrade
On 5 November 2026 the vault moved to its v2 implementation (0x45648A8aE69C2c4EAE0dc5727E66F8921F771181), with a new verifier
(0x29688590CA80928f8f0039334D9f723180cd482a) for the three-input, four-output circuit and the four-input hasher
(0xdf0EDcbbBBdd91ec58a7dBbc2b7457d553Aa2bc0). It needed no initializer: the new storage starts empty. The same script then set the token fee
recipient and listed VOID, and checked that the tree's root, the HYPE books, the threshold, the fee collector, the
pauses, the name, the roles and the HYPE balance were unchanged.
A proof made for v1 does not verify under v2, so operations in flight at the switch had to be made again. Every HYPE
note created before the upgrade stays spendable: its commitment is unchanged, and v1 ciphertexts still decrypt.
contracts/test/VaultUpgradeV2.t.sol upgrades a proxy holding v1 state, with notes, a held exit, a stopped exit,
fees, a gas deposit, keys and roles, and checks that every v1 slot is unchanged, that the old exits read asset 0 and
pay in HYPE, that old roots and nullifiers still count, and that tokens work after.
Invariants
The invariant tests (contracts/test/invariant/VaultInvariant.t.sol) drive the vault through random shields,
direct and ERC-4337 spends, held and refused exits, stops, releases, sweeps and pauses, of HYPE and of a test token,
and check after every step:
balance(vault) == totalBacking + pendingExitTotal + feesAccrued
pendingExitTotal == sum of exits[id].amount over the HYPE exits not yet paid
totalBacking == HYPE shielded - HYPE taken out of the notes
HYPE shielded == balance(vault) + deposit(vault) + balance(collector) + HYPE paid to recipients
token.balanceOf(vault) == backing + pendingExits + feesAccrued the token's book
pendingExits == sum of exits[id].amount over the token's exits not yet paid
backing == token shielded - token taken out of the notes
token shielded == token.balanceOf(vault) + token paid out
windowOut <= windowCapThey also check that a held exit is never paid before its time, a stopped exit is never paid, and a direct transaction with a gas fee is never accepted.
The first holds because HYPE enters only through shield and the EntryPoint, and every path that moves HYPE changes
one bucket by exactly the amount moved. HYPE forced into the vault without a call (for example by SELFDESTRUCT)
would only make the balance larger than the sum. The same goes for a token sent to the vault by plain transfer, so
on-chain token.balanceOf(vault) >= backing + pendingExits + feesAccrued for every listed token.
shield, shieldToken, transact, releaseExit, sweepFees, sweepTokenFees and execute4337 are nonReentrant (OpenZeppelin
ReentrancyGuardTransient), so a recipient cannot call back into the vault while it is being paid.
totalBacking is all the HYPE that unspent notes can ever take out. If the circuit or the contracts let a note be
created without HYPE behind it, its holder could take HYPE that backs other notes, and the last notes to exit would
find totalBacking short: their exits would revert. Each token's backing plays the same role for that token.
The transact circuit
The one zero-knowledge circuit behind every private spend - its inputs, constraints, public inputs, ExtData binding, sizes and toolchain.
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.