The vault and the holder yield
How notes hold shares of the vault's NOIR, the exact conversion formulas and rounding, how the fee harvester turns the Pons creator fees into donations, and the locked seed against the first-depositor attack.
Notes do not hold tokens. They hold shares of one vault, PrivateVault (0x0a55A88524002dd8F053Ffc644015EDc9653963a), which holds the NOIR
behind all notes. The vault's NOIR grows with every donation while the number of shares stays the same, so each share
is worth more NOIR over time, without any note being touched. The donations come from the Pons creator fees on every
NOIR trade, collected by the fee harvester.
State
| Symbol | Storage | Meaning |
|---|---|---|
| B | totalBacking | NOIR backing all notes |
| S | totalShares | Shares held by all unspent notes |
| V | VIRTUAL_SHARES = 1e6 | Virtual shares, as in OpenZeppelin's ERC-4626 |
B and S are public. VaultUpdated(totalBacking, totalShares) is emitted after every change, so the share value over
time can be rebuilt from events alone.
Conversions
function convertToShares(uint256 assets) public view returns (uint256) {
return assets * (totalShares + VIRTUAL_SHARES) / (totalBacking + 1);
}
function convertToAssets(uint256 shares) public view returns (uint256) {
return shares * (totalBacking + 1) / (totalShares + VIRTUAL_SHARES);
}share value = (B + 1) / (S + 1e6) NOIR (wei) per shareBoth divisions round down, which is always in favour of the vault: shielding a NOIR mints at most
a * (S + V) / (B + 1) shares, and exiting s shares pays at most s * (B + 1) / (S + V) NOIR. As a consequence,
the share value never decreases. A shield that would mint zero shares reverts (ZeroShares).
Share amounts are limited to 120 bits by the circuit. At the initial rate of 1e6 shares per NOIR wei, the whole 1e9 NOIR supply is 1e33 shares, below 2^120 (about 1.3e36), and the rate only falls as the share value rises.
Entering the vault
| Path | What happens |
|---|---|
shield(stub, amount, ciphertext) | The vault pulls amount NOIR from the caller with transferFrom (the caller approves the vault first) and mints convertToShares(amount) shares into a note |
PrivateRouter.buy | The router buys NOIR with ETH and shields it for the buyer in the same transaction |
Minting adds amount to B and the shares to S. See Public NOIR and shielding.
Exits
A transact with exitShares > 0 removes shares from the private side:
total = convertToAssets(exitShares)
fee = convertToAssets(ext.gasFee) 0 if gasFee is 0
S -= exitShares
B -= total
gasReserve += fee
amount = total - fee NOIR transferred to ext.recipient, or held in sale escrowThe amount leaves the vault as an ERC-20 transfer of NOIR to ext.recipient (event Exited), or is held in
pendingSales for an ERC-4337 sale. See Gas and ERC-4337.
Donations
donate(amount) pulls amount NOIR from the caller with transferFrom, adds it to B, leaves S unchanged, and emits
Donation(from, amount). Anyone may call it. Sources in the protocol:
| Source | Amount |
|---|---|
FeeHarvester, buy | The NOIR bought with the harvested Pons creator fees, after the gas deposit and the caller tip |
FeeHarvester, gas reserve | The whole gas reserve, when the EntryPoint deposit is at or above its target |
| Anyone | Any donate(amount) after approving the vault |
A donation raises the value of every share, including the locked seed's, in proportion to shares held. Public NOIR held outside the vault receives nothing. No noircash contract pays any share of the fees to the team.
A plain ERC-20 transfer of NOIR to the vault's address becomes a donation too, once anyone calls absorb(): it
adds the vault's balance above totalBacking + gasReserve + pendingSaleTotal to B and emits Donation from the zero
address. Every harvest calls it. To get NOIR back into a note, use shield, never a transfer.
The fee harvester
Pons records FeeHarvester (0x76d4979272019475b7eD690d5943E14296bDcDd6) as NOIR's creator fee recipient (see
NOIR on Pons). Every trade of NOIR pays two Pons fees, charged on the ETH side of the trade on the curve, and paid out
in ETH after graduation too:
| Fee | Rate | Where it goes |
|---|---|---|
| Standard fee | curve.feeBps(): 1% | Pons keeps protocolFeeShareBps = 30% of it (fixed in NOIR's curve and pool by Pons); the rest goes to the creator fee recipient (buyback is off) |
| Creator tax | curve.creatorTaxBps(): 0 for NOIR | All of it to the creator fee recipient |
With the defaults, a trade pays 1%: 0.3% to Pons and 0.7% to the harvester. The rates are the same on the curve and on the graduated pool. Pons credits the creator share to its fee escrow, where the harvester claims it.
harvest() has no access control and runs in this order:
collect: if NOIR trades on its pool (after performing a pending graduation step):
try memeHook.sweepPoolFees(poolId, 0, 0)
else:
try curve.sweepFees(0)
if feeEscrow.balanceOf(harvester) != 0: feeEscrow.claim()
claimed = harvester's ETH balance (includes ETH left over from earlier harvests)
vault.poke(); vault.absorb()
tip = claimed * CALLER_TIP_BPS / 10_000 CALLER_TIP_BPS = 50 (0.5%), sent to the caller at the end
eth = claimed - tip
1. deposit = EntryPoint.balanceOf(vault)
if deposit < targetDeposit: depositTo(vault, min(targetDeposit - deposit, eth)); eth -= that
2. ema = vault.tokensPerEthEma
if deposit is still < targetDeposit and ema != 0: sell gas reserve for the shortfall (below)
else: donate the whole gas reserve to the vault
3. if eth != 0 and ema != 0: buy NOIR with the largest size that fits (below), donate it, vault.poke()Both the reserve sale and the buy must land within MAX_SLIPPAGE_BPS = 300 (3%) of the vault's price average,
after the Pons fees:
feeBps = curve.feeBps() + curve.creatorTaxBps()
kept = 10_000 - feeBps
reserve sale:
tokensIn = min(gasReserve, ceil(shortfall * ema * 10_000 / (1e18 * kept)))
minOut = tokensIn * 1e18 * kept / (ema * 10_000) * (10_000 - 300) / 10_000 ETH
buy:
minOut = spend * ema * kept / (1e18 * 10_000) * (10_000 - 300) / 10_000 NOIRThe harvester quotes each size first, on the curve from its reserves or on the pool with a swap that is run and
undone. If the quote is below minOut, it halves the size and tries again, up to 8 attempts in all (the smallest size
tried is 1/128 of the first). A reserve sale that never fits sells nothing; the gas reserve stays in the vault. A buy
that never fits spends nothing, and ETH that is not spent waits in the harvester for the next harvest.
| Parameter | Value |
|---|---|
targetDeposit | Immutable; deploy default 0.1 ETH (TARGET_DEPOSIT) |
MAX_SLIPPAGE_BPS | 300 (3%) |
CALLER_TIP_BPS | 50 (0.5%) of the harvester's ETH balance after the claim |
claimable() | ETH the next harvest would claim right away, not counting fees Pons has yet to sweep |
Each sweep is wrapped in try: it may have nothing to do, and converting fees that sit in NOIR on the pool can
require the Pons fee sweep operator. A failed sweep never blocks the claim or the rest of the harvest. Events:
Harvested(ethClaimed, ethToDeposit, ethSpent, tokensDonated), ReserveSold, ReserveDonated, CallerTipped.
The buy is a real purchase of NOIR on its market. The caller tip exists so that anyone, for example a bot, keeps the loop running without an operator.
The holder yield depends on the harvester staying NOIR's creator fee recipient. The Pons owner can redirect it with
setCreatorFeeRecipient(noir, newRecipient) after a 3-day timelock, visible in pendingCreatorFeeRecipient(noir)
before it can be executed. The yield would then stop. Notes, the vault's NOIR, shielding, private sends, unshields
and sales would not change. The harvester itself has no function to move its role elsewhere.
The locked seed and the first-depositor attack
In an empty ERC-4626-style vault, an attacker can deposit a tiny amount, then donate a large amount to inflate the share value so that the next depositor's shares round down to little or nothing. noircash has two defences:
- Virtual shares. V = 1e6 and the
+ 1on B act as a permanent virtual deposit. Most of an inflating donation accrues to the virtual shares, so the attack costs the attacker more than it can take from a victim. - The locked seed. Right after deploying the vault, the deploy script buys NOIR on the curve with
SEED_ETH(default 0.001 ETH, once the Pons opening snipe tax is over) and shields it into a note whose stub iskeccak256("noircash/locked-seed") mod p. Nobody knows a preimage, so the note is unspendable. On a fork of Robinhood Chain, 0.001 ETH bought about 583,000 NOIR at the curve's starting price.
The seed also keeps early donations from being split with the virtual shares: with S = 0, a donation made before the first holder shields would accrue mostly to V and dilute the first holder. With the seed, early donations accrue to the seed note instead.
The seed's shares, and every donation that accrues to them, are locked forever. Its fraction of every donation is
seed shares / S, which shrinks as more is shielded.
Invariants
These follow from the code paths above:
noir.balanceOf(vault) >= totalBacking + gasReserve + pendingSaleTotal
S = sum of shares in unspent notes (given a sound circuit)
(B + 1) / (S + V) never decreasesThe first holds with equality unless someone sent NOIR to the vault with a plain transfer, and the next absorb
restores it. Every path that moves NOIR
into or out of the vault changes B, the gas reserve or the sale escrow by the same amount: shield and donate pull
exactly amount, an exit transfers exactly total - fee and moves fee to the reserve, releaseGasReserve pays
only what it subtracts, and a sale leaves escrow either through the router's pull or by a transfer to its recipient.
B is the total a holder can ever exit, at most. If the circuit or the contracts had a bug that let shares be created from nothing, every holder's share value would fall.
The transact circuit
The one zero-knowledge circuit behind every private spend - its inputs, constraints, public inputs, ExtData binding, sizes and toolchain.
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.