noircashdocs

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

SymbolStorageMeaning
BtotalBackingNOIR backing all notes
StotalSharesShares held by all unspent notes
VVIRTUAL_SHARES = 1e6Virtual 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 share

Both 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

PathWhat 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.buyThe 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 escrow

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

SourceAmount
FeeHarvester, buyThe NOIR bought with the harvested Pons creator fees, after the gas deposit and the caller tip
FeeHarvester, gas reserveThe whole gas reserve, when the EntryPoint deposit is at or above its target
AnyoneAny 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:

FeeRateWhere it goes
Standard feecurve.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 taxcurve.creatorTaxBps(): 0 for NOIRAll 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       NOIR

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

ParameterValue
targetDepositImmutable; deploy default 0.1 ETH (TARGET_DEPOSIT)
MAX_SLIPPAGE_BPS300 (3%)
CALLER_TIP_BPS50 (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:

  1. Virtual shares. V = 1e6 and the + 1 on 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.
  2. 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 is keccak256("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 decreases

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

On this page