voidcashdocs

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

StorageTypeMeaning
totalBackinguint256HYPE backing every unspent HYPE note
pendingExitTotaluint256HYPE of exits decided and not yet paid. It no longer backs any note
feesAccrueduint256HYPE exit fees collected and not yet swept to the fee collector
largeExitThresholduint256A 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
feeCollectoraddressReceives the HYPE exit fees and what operations overpaid for gas: 0x145618DF0662dCd38a2Cf2AC4d24Dfe1FC8A4991
entriesPausedboolWhile true, shield and every shieldToken revert. Nothing else is affected
exits(exitId)ExitExits not yet paid, of any asset, keyed by the transaction's first nullifier
nullifierSpent(nf)boolSpent nullifiers
keysOf(owner)(uint256 opk, bytes32 viewKey)The registered owner key and X25519 viewing key of each address
registerNonces(owner)uint256Nonces of registerFor signatures
tokenConfig(token)TokenConfigWhether a token is listed, whether its entries are paused, and its limits. See Tokens
tokenBook(token)TokenBookA token's backing, pending exits, accrued fees and current window
tokenFeeRecipientaddressReceives 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.value

The 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.

CheckError
Entries are not pausedEntriesArePaused
stub is a field element (below the BN254 scalar modulus)NotAFieldElement
msg.value is not zeroZeroAmount
msg.value is below 2^120 wei, the circuit's rangeAmountTooLarge

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 += amount

Like 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.

CheckError
The token is listedTokenNotListed
Neither the global entries pause nor the token's own is onEntriesArePaused
stub is a field elementNotAFieldElement
amount is not zeroZeroAmount
amount is below 2^120AmountTooLarge
The vault's balance of the token grew by exactly amountTokenAmountMismatch

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
OperationexitAssetexitAmountfeeAmount
Private send of HYPE, with a spare HYPE note00gas fee
Private send of HYPE, without one0gas fee0
Unshield of HYPE0gas fee + exit0
Private send of a token00gas fee
Unshield of a tokenthe tokenexitgas 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:

CheckError
root is a known root of the note treeUnknownRoot
The three nullifiers differDuplicateNullifier
No nullifier is spentNullifierAlreadySpent
Nullifiers and commitments are field elementsNotAFieldElement
exitAmount and feeAmount are below 2^120AmountTooLarge
For a token exit: the token is listedTokenNotListed
For a token exit: exitAmount is not zeroZeroAmount
For a token exit: feeAmount equals ext.gasFee, so the HYPE side pays the gas and nothing elseFeeAmountNotGasFee
For a token exit: it names a recipientZeroAddress
ext.gasFee is at most the HYPE leavingFeeExceedsExit
HYPE leaving beyond the gas fee names a recipientZeroAddress
The verifier accepts the proofInvalidProof (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:

RuleError
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 addressWrongCaller
ext.gasFee is zeroGasFeeNotAllowed

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:

TermValue
Exit feeEXIT_FEE_BPS = 100: 1% of what leaves after the gas fee, rounded down (exitFeeOf(amount))
Large exitOne whose net payout is above largeExitThreshold
HoldLARGE_EXIT_DELAY = 24 hours from the transaction
Threshold rangeMIN_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:

PathWhenWho
transactAt once, unless heldThe submitter's transaction
execute4337(exitId)In the execution step of the UserOperation, unless held or stoppedThe EntryPoint
releaseExit(exitId)Once payableAt has passed and the exit is not stoppedAnyone; 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_ROLE

The 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

FunctionWhoEffect
listToken(token, threshold, windowCap)DEFAULT_ADMIN_ROLELists 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_ROLEChanges a listed token's limits (TokenLimitsSet). Exits already created keep their time
setTokenEntriesPaused(token, paused)GUARDIAN_ROLECloses or opens one token to new shields (TokenEntriesPaused)
setTokenFeeRecipient(recipient)DEFAULT_ADMIN_ROLESets 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 limitsValue
Large-exit threshold5,000,000 VOID, 0.5% of the supply
Window cap25,000,000 VOID per 24-hour window
Token fee recipientThe 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:

  1. feesAccrued, the HYPE exit fees;
  2. 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 pausedFunctions
Revert (EnforcedPause)transact, validateUserOp, execute4337, releaseExit
Keep workingshield 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.

RoleFunctionsEffect
DEFAULT_ADMIN_ROLEgrantRole, revokeRoleGrants 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, setTokenFeeRecipientLists a token, sets its limits, sets where token exit fees go. See Tokens
UPGRADER_ROLEupgradeToAndCall(newImplementation, data)Replaces the implementation, with no timelock. See Upgrades
GUARDIAN_ROLEpause(), 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. initialize writes 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, version 1), 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_ROLE calls upgradeToAndCall(newImplementation, data); _authorizeUpgrade checks 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 totalBacking to registerNonces, then the three variables v2 appended: tokenConfig, tokenBook and tokenFeeRecipient. 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.sol checks 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               <= windowCap

They 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.

On this page