noircashdocs

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:

PhaseValueWhere NOIR tradesPrice the vault reads
NotGraduated0The bonding curveCurve reserves
Swept1Nowhere: the curve is closed, the pool is not seeded yetNone (0)
PoolCreated2The Uniswap v4 poolThe 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);
PropertyValue
FeesfeeBps (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 tax99% of a buy, decaying to 0 over the first 5 seconds after launch; keyed on the recipient (currentSnipeTaxBps(recipient)); buys only
Snipe-tax exemptionsAddresses named at launch, as Pons' rules allow
A buy past the end of the curveFilled up to what is left, and the rest of the ETH refunded
SellsClosed 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
PropertyConsequence
A block's price enters only at a later block's first pokeA poke folds the price recorded at the previous poke, not the current one
Close clamped to ±10%, weight 1/8The average moves at most 1.25% per block, however far the market was pushed
One fold per poked blockBlocks without a poke do not move the average; trades elsewhere move it only once someone pokes
ERC-4337 validation reads only the stored averageThe 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);
FunctionChecksEvents
buytokensOut >= minTokensOut (InsufficientOutput)Bought(leafIndex, ethIn, tokensOut), plus the vault's Shielded and NoteAdded
sellRouted here (NotRoutedHere), ethOut >= minEthOutSold(ethRecipient, tokensIn, ethOut), plus the vault's Exited
onPrivateExitCaller is the vault (NotVault), ethOut >= minEthOutSold
quoteNoneNone

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_000

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

On this page