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.
NOIR trades in two places, one after the other: its Pons bonding curve until graduation, then the Pons Uniswap
v4 pool that graduation creates. Both are Pons contracts, and any router or aggregator that supports Pons can trade
them. noircash adds PrivateRouter, which buys straight into a note and sells a note straight to ETH, and a price
average the vault uses to value gas fees. PrivateRouter and FeeHarvester trade through the same code,
PonsSwapper.
Phases
The Pons factory records NOIR's phase in getLaunchedToken(noir).phase:
| Phase | Value | Where NOIR trades | Price the vault reads |
|---|---|---|---|
NotGraduated | 0 | The bonding curve | Curve reserves |
Swept | 1 | Nowhere: the curve is closed, the pool is not seeded yet | None (0) |
PoolCreated | 2 | The Uniswap v4 pool | The pool's slot0 |
onPool() on the router and the harvester is true in phase 2.
The bonding curve
The curve (0x7D1e90200654C2BE3e3a0eC035bC7Fa3C55f026f) is one Pons contract per token. It received the whole 1,000,000,000 NOIR supply at
launch and trades it against native ETH:
function buy(uint256 quoteIn, uint256 minTokensOut, address recipient) external payable returns (uint256 tokensOut);
function sell(uint256 tokensIn, uint256 minQuoteOut, address recipient) external returns (uint256 quoteOut);
function getReserves() external view returns (uint256 quoteReserve, uint256 tokenReserve);| Property | Value |
|---|---|
| Fees | feeBps (1%) plus creatorTaxBps (0 for NOIR), each taken from the ETH leg: from the ETH paid on a buy, from the ETH received on a sale |
| Opening snipe tax | 99% of a buy, decaying to 0 over the first 5 seconds after launch; keyed on the recipient (currentSnipeTaxBps(recipient)); buys only |
| Snipe-tax exemptions | Addresses named at launch, as Pons' rules allow |
| A buy past the end of the curve | Filled up to what is left, and the rest of the ETH refunded |
| Sells | Closed once readyToGraduate() is true |
PrivateRouter buys on the curve with itself as recipient, so a buy through it in the first 5 seconds pays the
snipe tax. Its quote includes the tax.
Graduation
When the curve has sold everything it sells (4.2 ETH collected, per the Pons launch terms), NOIR graduates in two steps, both permissionless on the Pons factory:
factory.graduate(noir) sweeps the curve's reserves: phase Swept.
factory.createGraduatedPool(noir) initializes the Uniswap v4 pool and seeds it with a full-range position locked by
Pons: phase PoolCreated. It can be retried if it fails.
Pons normally runs both inside the buy that finishes the curve. When it does not, PonsSwapper._settleGraduation
runs whichever step is pending before any trade by the router or the harvester:
if phase == NotGraduated and curve.readyToGraduate(): factory.graduate(noir)
if phase == Swept: factory.createGraduatedPool(noir)So NOIR is never stuck between the two venues for a noircash trade: the first router buy, router sale or harvest after the curve fills completes graduation and then trades on the pool. A fork test covers a crossing buy that leaves NOIR swept and the next router buy that seeds the pool and trades on it.
According to Pons' published terms, 5/7 of the supply is sold on the curve, and the remaining 2/7 is swept at graduation: 10/49 of the supply seeds the pool and 4/49 is locked by Pons.
The graduated pool
The router and the harvester build the pool key once, at construction, from the Pons factory:
PoolKey({
currency0: Currency.wrap(address(0)), // native ETH
currency1: Currency.wrap(noir),
fee: launch.poolFee, // 0
tickSpacing: launch.tickSpacing, // 200
hooks: IHooks(ponsFactory.memeHook())
})launch is ponsFactory.getLaunchedToken(noir), and memeHook is immutable in the factory. The vault receives the
resulting pool ID in its constructor.
Only the factory can initialize a pool with the Pons meme hook: its beforeInitialize reverts for any other sender.
So nobody can open NOIR's pool first at a price of their choosing. A pool with any other key (another fee, tick
spacing or hook) has a different pool ID. Such a decoy pool can exist, as for any token, but noircash never trades on
it or reads its price. Fork tests check both.
The pool charges no LP fee (fee = 0). The Pons meme hook takes the same standard fee and creator tax as the curve
in afterSwap, from the swap's unspecified currency, so part of it can land in NOIR. Pons converts that NOIR to ETH
before paying the creator, and this conversion can require the Pons fee sweep operator. See
the fee harvester.
The price average
The vault keeps tokensPerEthEma, an average price in NOIR per ETH scaled by 1e18, used to value
gas fees and by the harvester's slippage checks. It moves only through poke(), which anyone
may call. The router calls it after every buy and every sale, and the harvester at the start of a harvest and after
its buy.
spot, phase NotGraduated: tokenReserve * 1e18 / quoteReserve curve.getReserves(); 0 if quoteReserve is 0
spot, phase PoolCreated: sqrtPriceX96^2 / 2^96 * 1e18 / 2^96 poolManager.getSlot0(poolId)
spot, phase Swept: 0
poke():
if spot == 0: return (nothing changes)
if ema == 0: ema = spot
else if block.number != lastPriceBlock:
close = lastSpot, clamped to [ema * 0.9, ema * 1.1] MAX_PRICE_STEP_BPS = 1_000
ema = (7 * ema + close) / 8
lastSpot = spot, lastPriceBlock = block.number| Property | Consequence |
|---|---|
| A block's price enters only at a later block's first poke | A poke folds the price recorded at the previous poke, not the current one |
| Close clamped to ±10%, weight 1/8 | The average moves at most 1.25% per block, however far the market was pushed |
| One fold per poked block | Blocks without a poke do not move the average; trades elsewhere move it only once someone pokes |
| ERC-4337 validation reads only the stored average | The market is never read during validation |
The price folded for a block is the price at that block's last poke. Anyone can poke, so a price pushed, poked and
pushed back within one block still counts, clamped to ±10%, and each such block costs the Pons fees on both legs of
the push. On Robinhood Chain block.number returns the Ethereum L1 block number, about 12 seconds apart, so "block"
above means an L1 block. Moving the average by the 20% gas-fee margin takes about 15 consecutive blocks at the clamp.
PriceUpdated(tokensPerEthEma) is emitted whenever the average changes.
PrivateRouter
The router (0x00fe9533Df78aF8e59cF8BC0EcDDe551140920D5) holds no funds between transactions. It trades on whichever venue is live, with no
price limit on the pool: minimum outputs protect the user instead.
/// Spends msg.value ETH on NOIR and shields it into a note completed with `stub`. Refunds unspent ETH.
function buy(uint256 stub, uint256 minTokensOut, bytes calldata ciphertext)
external payable returns (uint32 leafIndex, uint256 tokensOut);
/// Spends notes with `proof`, exits shares to the router, sells the NOIR for ETH.
/// Requires ext.caller == ext.recipient == router and ext.data == abi.encode(ethRecipient, minEthOut).
function sell(PrivateVault.Transaction calldata t, bytes calldata proof) external returns (uint256 ethOut);
/// Called by the vault only: pulls `amount` NOIR the vault has just approved and sells it.
function onPrivateExit(uint256 amount, address ethRecipient, uint256 minEthOut) external;
/// Output of trading `amountIn` now (ETH in for a buy, NOIR in for a sale), Pons fees included.
function quote(bool isBuy, uint256 amountIn) external returns (uint256 amountOut);| Function | Checks | Events |
|---|---|---|
buy | tokensOut >= minTokensOut (InsufficientOutput) | Bought(leafIndex, ethIn, tokensOut), plus the vault's Shielded and NoteAdded |
sell | Routed here (NotRoutedHere), ethOut >= minEthOut | Sold(ethRecipient, tokensIn, ethOut), plus the vault's Exited |
onPrivateExit | Caller is the vault (NotVault), ethOut >= minEthOut | Sold |
quote | None | None |
quote computes a curve trade from the curve's reserves and fee rates, as the curve does, since the curve has no
quote function:
buy: net = ethIn - ethIn*feeBps/10_000 - ethIn*creatorTaxBps/10_000 - ethIn*currentSnipeTaxBps(router)/10_000
out = min(net * tokenReserve / (quoteReserve + net), sellableTokens())
sell: gross = tokensIn * quoteReserve / (tokenReserve + tokensIn)
out = gross - gross*feeBps/10_000 - gross*creatorTaxBps/10_000On the pool it runs the real swap and reverts it, carrying the output out in a Quote(amountOut) error, so it is
not a view: call it with eth_call. A buy that crosses graduation can be refunded in part by the curve; the router
returns unspent ETH to the buyer.
sell is paid for by whoever submits it, whose address is then visible; the private path is the ERC-4337 sale. The
app sets minTokensOut and minEthOut from quote less slippageBps (100, that is 1%, by default). For sales the
minimum is inside ext.data, bound to the proof, so no submitter can loosen it.
A buy through PrivateRouter.buy shows the buyer's address, the ETH amount and the NOIR amount. It does not show
which later spend consumed the note. See Staying private.
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.
Gas and ERC-4337
How the vault pays for its users' gas as its own ERC-4337 account, what validation checks, how sales settle, and how the fee harvester keeps the deposit funded.