noircashdocs

Contracts

Every external function, event, struct and error of PrivateVault, PrivateRouter, FeeHarvester and the Pons market code they share.

The noircash contracts are verified on the explorer. They are immutable and have no owner: every address they work with is fixed in the constructor, and there is no initializer and no admin function. NOIR itself is not one of them: it is a plain ERC-20 created by Pons V2 (see NOIR). Addresses are on Deployments; how the pieces fit is in Architecture.

Solidity ^0.8.26, compiled for the Cancun EVM. The verifier (HonkVerifier) is generated by Barretenberg and not covered here beyond its interface.

ContractRole
PrivateVaultShielded NOIR, notes, keys, vault shares, price average, ERC-4337 account
PrivateRouterPrivate buys and sales in one transaction, quotes
FeeHarvesterNOIR's Pons creator fee recipient; turns the creator fees into gas and holder yield
PonsSwapper, PonsMarketTrading and pricing on the Pons curve or the graduated pool, shared by the router and the harvester
PoseidonMerkleTreeThe note tree, inherited by the vault

NOIR

NOIR is a Pons PonsV2LauncherToken: OpenZeppelin ERC20 plus ERC20Burnable, 18 decimals, a fixed supply of 1,000,000,000 minted into its bonding curve at launch, no owner and no transfer restrictions. noircash adds nothing to it. Name noircash, symbol NOIR.

PrivateVault

The private side of NOIR. Shielding moves NOIR into the vault and creates an encrypted note; unshielding moves it back out. Inside, notes are spent with a zero-knowledge proof and a signature by the owner's wallet. The vault is also the ERC-4337 account that carries private transactions. It inherits PoseidonMerkleTree, OpenZeppelin EIP712 and IAccount.

Constructor

constructor(
    string memory name_,          // EIP-712 domain name (the deploy passes TOKEN_NAME, "noircash")
    IERC20 noir_,
    IPonsFactory ponsFactory_,
    IPonsCurve curve_,
    IPoolManager poolManager_,
    PoolId poolId_,               // the Pons graduated pool
    IVerifier verifier_,
    IPoseidon2 hasher_,
    IEntryPoint entryPoint_,
    address saleRouter_,          // the PrivateRouter
    address harvester_            // the FeeHarvester
);

All of these are immutable and readable: noir(), ponsFactory(), curve(), poolManager(), poolId(), verifier(), hasher(), entryPoint(), saleRouter(), harvester(). The EIP-712 version is "1".

Structs

struct Transaction {
    uint256 root;            // a root of the note tree the proof was made against
    uint256[2] nullifiers;   // one per input note (dummies included)
    uint256[2] commitments;  // one per output note (dummies included)
    uint256 exitShares;      // shares leaving the private side, gas fee included
    ExtData ext;
}

/// Bound to the proof through extDataHash: nobody can change it after the wallet signs.
struct ExtData {
    address recipient;   // receives exitShares - gasFee, converted to NOIR
    address caller;      // if non-zero, only this address may submit (the router; the vault itself for ERC-4337)
    uint256 gasFee;      // shares, out of exitShares, paid to the gas reserve
    bytes data;          // parameters for `caller`: router sale terms, or the ERC-4337 gas fields
    bytes ciphertext0;   // encrypted data of output note 0
    bytes ciphertext1;   // encrypted data of output note 1
}

struct Keys {
    uint256 opk;       // owner public key, H(nk, wallet key)
    bytes32 viewKey;   // X25519 public key for note encryption
}

/// A sale applied in validation and settled in execution.
struct Sale {
    uint256 amount;
    address ethRecipient;
    uint256 minEthOut;
}

What goes in ext.data depends on ext.caller:

ext.callerext.data
address(0)anything; anyone may submit the transaction to transact
PrivateRouterabi.encode(address ethRecipient, uint256 minEthOut) for sell
the vault itselfabi.encode(uint256 minCallGasLimit, address ethRecipient, uint256 minEthOut) for ERC-4337

Key registry

function register(uint256 opk, bytes32 viewKey) external;
function registerFor(address owner, uint256 opk, bytes32 viewKey, uint256 deadline, bytes calldata signature) external;
function keysOf(address owner) external view returns (uint256 opk, bytes32 viewKey);
function registerNonces(address owner) external view returns (uint256);
FunctionDescription
registerRegisters the caller's owner key and viewing key. opk must be non-zero and below the field modulus. Calling it again replaces the keys
registerForSame for owner, with an EIP-712 signature (EOA or ERC-1271) that anyone can submit. Reverts SignatureExpired after deadline
keysOfThe registered keys; opk == 0 means not registered
registerNoncesNext nonce of owner for registerFor

The registerFor typed data is Register(address owner,uint256 opk,bytes32 viewKey,uint256 nonce,uint256 deadline) under the vault's EIP-712 domain. Registering lets others send you private notes; it changes nothing about NOIR held publicly. See Keys.

Private side

function shield(uint256 stub, uint256 amount, bytes calldata ciphertext) external returns (uint32 index);
function donate(uint256 amount) external;
function absorb() external returns (uint256 amount);
function transact(Transaction calldata t, bytes calldata proof) external;
function nullifierSpent(uint256 nullifier) external view returns (bool);
function publicInputs(Transaction calldata t) external view returns (bytes32[] memory);
function extDataHash(ExtData memory ext) external pure returns (uint256);
FunctionDescription
shieldPulls amount NOIR from the caller with transferFrom (approve the vault first) and creates a note completed with stub = H(opk, rho, r). Returns the leaf index. The router uses it for private buys
donatePulls amount NOIR from the caller and adds it to totalBacking without minting shares, which raises the value of every share. The harvester donates through it
absorbAnyone. Adds NOIR that reached the vault by a plain transfer (the balance above totalBacking + gasReserve + pendingSaleTotal) to totalBacking, and emits Donation from the zero address. The harvester calls it on every harvest
transactSpends up to two notes and creates two, with a proof; optionally exits shares as NOIR to ext.recipient. If ext.caller is set, only that address may call
nullifierSpentWhether a nullifier has been used
publicInputsThe 9 public inputs of the proof, in circuit order: root, 2 nullifiers, 2 commitments, exit shares, extDataHash, and the EIP-712 domain separator as two 128-bit halves
extDataHashkeccak256(abi.encode(ext)) mod FIELD

A note created by shield carries its share amount on-chain only (in Shielded), because the shares are known only when the transaction runs. Exits transfer NOIR out of the vault. See Notes and The circuit.

Vault

function totalBacking() external view returns (uint256);   // B: NOIR behind all notes
function totalShares() external view returns (uint256);    // S: shares in all notes
function convertToShares(uint256 assets) external view returns (uint256);
function convertToAssets(uint256 shares) external view returns (uint256);

convertToShares(a) = a * (S + VIRTUAL_SHARES) / (B + 1) and convertToAssets(s) = s * (B + 1) / (S + VIRTUAL_SHARES), rounding down. The vault's NOIR balance is totalBacking plus gasReserve plus NOIR held for pending sales. See The vault.

Note tree

Inherited from PoseidonMerkleTree.

function root() external view returns (uint256);
function isKnownRoot(uint256 root) external view returns (bool);
function nextLeafIndex() external view returns (uint32);
function treeNumber() external view returns (uint32);
function isFinalRoot(uint256 root) external view returns (bool);
function hasher() external view returns (address);
FunctionDescription
rootCurrent root
isKnownRootTrue for any of the last 64 roots and for the final root of every full tree; false for 0
nextLeafIndexNext free leaf in the current tree. Leaves are always inserted in pairs, so it is even
treeNumberNumber of full trees before the current one
isFinalRootWhether a root is the final root of a full tree (valid forever)
hasherThe Poseidon2 contract

See The Merkle tree.

ERC-4337 account

function validateUserOp(PackedUserOperation calldata op, bytes32 userOpHash, uint256 missingAccountFunds)
    external returns (uint256 validationData);
function execute4337(uint256 saleId) external;
function claimSale(uint256 saleId) external;
function addStake(uint32 unstakeDelaySec) external payable;
function pendingSales(uint256 saleId) external view returns (uint256 amount, address ethRecipient, uint256 minEthOut);
function pendingSaleTotal() external view returns (uint256);
FunctionDescription
validateUserOpEntryPoint only. Decodes abi.encode(Transaction, proof) from op.signature and applies the whole private transaction. Returns 1 (failed signature) for an invalid proof without writing anything, so a placeholder proof can be used to estimate gas. A sale's NOIR is kept in escrow (pendingSales)
execute4337EntryPoint only. Settles the operation's sale, if any: approves the router, which pulls the NOIR and sells it. If the sale reverts, the NOIR is transferred to the ETH recipient. Never reverts on a failed swap
claimSaleAnyone. If a sale's execution ran out of gas, transfers its NOIR to the ETH recipient. Reverts NoSuchSale if none is pending
addStakeAnyone can add EntryPoint stake for the vault. Nothing can unlock it
pendingSalesSales validated but not settled, keyed by first nullifier
pendingSaleTotalSum of the pending sales' NOIR

The operation must satisfy all of these, or validation reverts with InvalidUserOp or WrongCaller:

  • ext.caller is the vault itself;
  • no initCode and no paymasterAndData;
  • callData is exactly execute4337(t.nullifiers[0]);
  • nonce is uint192(t.nullifiers[0]) << 64 (one nonce key per first nullifier);
  • callGasLimit is at least the minCallGasLimit in ext.data;
  • the gas fee, valued at the price average, covers the operation's maximum cost plus 20% (InsufficientGasFee otherwise, NoPrice before the first price).

See Gas.

Price average and gas reserve

function poke() external;
function tokensPerEthEma() external view returns (uint256);
function lastSpot() external view returns (uint192);
function lastPriceBlock() external view returns (uint64);
function gasReserve() external view returns (uint256);
function releaseGasReserve(uint256 max) external returns (uint256 amount);
FunctionDescription
pokeAnyone. Reads the live market price (the curve's reserves before graduation, the graduated pool's slot0 after) and records it. The first update of a block folds the previous update's price into the average, clamped to ±10% of it, with weight 1/8. The first price ever seeds the average. Does nothing while neither venue is live. The router and the harvester call it on every trade
tokensPerEthEmaMoving average of the price, NOIR per ETH scaled by 1e18. 0 before the first price
lastSpot / lastPriceBlockPrice at the latest update and its block; it enters the average when a later block starts
gasReserveNOIR collected as gas fees, waiting for the harvester
releaseGasReserveHarvester only (NotHarvester otherwise). Transfers up to max reserve NOIR to the harvester

The vault also exposes eip712Domain() (ERC-5267, from OpenZeppelin EIP712), and the constants TREE_DEPTH, ROOT_HISTORY_SIZE, VIRTUAL_SHARES, GAS_MARGIN_BPS and MAX_PRICE_STEP_BPS (values on Constants).

Events

event KeysRegistered(address indexed owner, uint256 opk, bytes32 viewKey);
event NoteAdded(uint256 indexed index, uint256 commitment, bytes ciphertext);
event Shielded(address indexed from, uint256 indexed index, uint256 amount, uint256 shares);
event Exited(uint256 indexed nullifier, address indexed recipient, uint256 amount, uint256 shares);
event NullifierSpent(uint256 indexed nullifier);
event Donation(address indexed from, uint256 amount);
event GasFeeCollected(uint256 amount);
event PriceUpdated(uint256 tokensPerEthEma);
event SaleSettled(uint256 indexed saleId, bool swapped);
event VaultUpdated(uint256 totalBacking, uint256 totalShares);
event TreeCompleted(uint256 indexed treeNumber, uint256 finalRoot); // from PoseidonMerkleTree
EventWhen
KeysRegisteredregister or registerFor
NoteAddedA leaf from shield or transact, with its encrypted note data
Shieldedshield: from is who shielded (the user, or the router for a purchase), index the leaf it created, shares the note's share amount
ExitedNOIR left the private side to recipient; nullifier is the transaction's first nullifier
NullifierSpentTwice per private transaction, once per nullifier
Donationdonate: harvested creator fees and gas-reserve surplus, from the harvester; absorb: NOIR sent by plain transfer, from the zero address
GasFeeCollectedNOIR added to the gas reserve by a private transaction
PriceUpdatedThe price average changed
SaleSettledAn ERC-4337 sale settled: swapped is false if the NOIR went to the recipient instead
VaultUpdatedtotalBacking and totalShares after every change
TreeCompletedA tree filled up; its final root stays valid and a new tree starts at index 0

NOIR's own Transfer events show every movement into and out of the vault, as for any ERC-20.

Errors

ErrorCause
ZeroAddressConstructor with no NOIR or verifier, or an exit with no recipient
NotAFieldElementA stub, commitment, nullifier, exit amount or opk at or above the field modulus (or opk == 0)
ZeroSharesAn amount too small to mint a single share
UnknownRootThe proof's root is not in the root history nor a final root
NullifierAlreadySpent / DuplicateNullifierA note already spent, or the same nullifier twice in one transaction
InvalidProofThe verifier rejected the proof (direct transact only; ERC-4337 returns a failed signature instead)
WrongCallerext.caller is set and is not msg.sender (or not the vault, for ERC-4337)
FeeExceedsExitext.gasFee > exitShares
InvalidUserOpA UserOperation that breaks one of the rules above
InsufficientGasFeeThe gas fee does not cover the maximum cost plus the margin
NoPriceNo price average yet
NoSuchSaleclaimSale for a sale that is not pending
InvalidSignature / SignatureExpiredregisterFor signature invalid or past its deadline
NotEntryPoint / NotHarvesterRestricted function called by the wrong address

NOIR transfers go through OpenZeppelin SafeERC20, so a missing approval or balance reverts with NOIR's own ERC-20 error.

PrivateRouter

Buys NOIR straight into a note and sells a note straight to ETH, in one transaction. It trades on the Pons curve until graduation and on the Pons v4 pool after. It holds no funds between transactions. See Buy and Sell.

constructor(IPoolManager poolManager_, IPonsFactory ponsFactory_, PrivateVault vault_, address token);

function vault() external view returns (address);
function buy(uint256 stub, uint256 minTokensOut, bytes calldata ciphertext)
    external payable returns (uint32 leafIndex, uint256 tokensOut);
function sell(PrivateVault.Transaction calldata t, bytes calldata proof) external returns (uint256 ethOut);
function onPrivateExit(uint256 amount, address ethRecipient, uint256 minEthOut) external;
function quote(bool isBuy, uint256 amountIn) external returns (uint256 amountOut);
// and everything of PonsSwapper, below
FunctionDescription
buySpends msg.value ETH on NOIR, shields it into a note completed with stub, and pokes the price. Reverts InsufficientOutput below minTokensOut. A buy that crosses graduation can leave ETH unspent: it is returned to the caller
sellSpends notes with a proof, exits the shares to the router and sells the NOIR for ETH sent to ethRecipient. The transaction must name the router as both ext.caller and ext.recipient (NotRoutedHere otherwise), with ext.data = abi.encode(ethRecipient, minEthOut)
onPrivateExitVault only (NotVault). Settles an ERC-4337 sale: pulls the amount NOIR the vault has just approved and sells it
quoteOutput of trading amountIn now (ETH in for a buy, NOIR in for a sale), Pons fees included. On the pool it runs the real swap and reverts it; on the curve it computes from the reserves and fee rates. Not a view: call it with eth_call

Swaps are exact-input with no price limit; minTokensOut and minEthOut are the only protection.

event Bought(uint32 indexed leafIndex, uint256 ethIn, uint256 tokensOut);
event Sold(address indexed ethRecipient, uint256 tokensIn, uint256 ethOut);

error InsufficientOutput();
error NotRoutedHere();
error EthTransferFailed();
error NotVault();

FeeHarvester

NOIR's Pons creator fee recipient, so the creator share of every Pons trade fee, and the creator tax if any, comes here in ETH. No owner and no parameter can change after deployment. See Gas and The vault.

struct Config {
    IPoolManager poolManager;
    IPonsFactory ponsFactory;
    IPonsFeeEscrow feeEscrow;
    IEntryPoint entryPoint;
    PrivateVault vault;          // its CREATE3 address, fixed before it is deployed
    uint256 targetDeposit;
}

constructor(Config memory config, address token); // token: NOIR, already launched on Pons

function MAX_SLIPPAGE_BPS() external view returns (uint256);   // 300
function CALLER_TIP_BPS() external view returns (uint256);     // 50
function entryPoint() external view returns (address);
function feeEscrow() external view returns (address);
function vault() external view returns (address);
function targetDeposit() external view returns (uint256);

function harvest() external returns (uint256 tokensDonated);
function claimable() external view returns (uint256);
// and everything of PonsSwapper, below

The constructor reads Pons' record of the token, ponsFactory.getLaunchedToken(token), and reverts unless Pons launched it (NotAPonsLaunch), names this contract as its creator fee recipient (NotTheFeeRecipient) and pairs it with native ETH (NotAnEthPair). So a harvester can only be deployed for a token whose fees it receives. It then records NOIR, its curve and the graduated pool key.

harvest() is open to anyone. In order:

Collect. Graduates the curve or seeds the pool if that is due, sweeps the fees Pons holds for NOIR (sweepPoolFees on the hook after graduation, sweepFees on the curve before), and claims the ETH from the fee escrow. Each sweep is wrapped in try, so none of them can block the harvest. Then pokes the price and calls the vault's absorb.

Tip. Sets aside CALLER_TIP_BPS (0.5%) of the ETH handled for the caller.

Gas deposit. Tops up the vault's EntryPoint deposit towards targetDeposit with that ETH.

Gas reserve. If the deposit is still short, sells just enough of the vault's gas reserve and deposits the ETH. Otherwise, takes the whole reserve and donates it to the vault.

Buy and donate. Spends the rest of the ETH on NOIR and donates it to the vault. The buy must land within MAX_SLIPPAGE_BPS (3%) of what the price average predicts after Pons fees; a buy that does not fit is halved, over up to 8 sizes (down to 1/128), and ETH that does not fit waits for the next harvest. Then pokes the price again.

Reserve sales use the same 3% bound and halving. claimable() is the ETH the next harvest would claim right away (the escrow balance plus ETH already held), not counting fees Pons has yet to sweep.

event Harvested(uint256 ethClaimed, uint256 ethToDeposit, uint256 ethSpent, uint256 tokensDonated);
event ReserveSold(uint256 tokensIn, uint256 ethOut);
event ReserveDonated(uint256 amount);
event CallerTipped(address indexed caller, uint256 amount);

error EthTransferFailed();
error NotAPonsLaunch();
error NotTheFeeRecipient();
error NotAnEthPair();

PonsSwapper and PonsMarket

PonsMarket is a library that says which venue is live and what its price is; PonsSwapper is the abstract contract the router and the harvester inherit to trade there.

function poolManager() external view returns (address);
function ponsFactory() external view returns (address);
function noir() external view returns (address);
function curve() external view returns (address);
function poolId() external view returns (PoolId);
function poolKey() external view returns (PoolKey memory);
function spotPrice() external view returns (uint256);
function onPool() external view returns (bool);
function unlockCallback(bytes calldata data) external returns (bytes memory);
receive() external payable;
FunctionDescription
poolKey / poolIdThe graduated pool: native ETH / NOIR, the launch's poolFee and tickSpacing, and ponsFactory.memeHook(). A pool with any other key has another id and is never used
spotPriceNOIR per ETH, 1e18 scale, at the live venue: the pool's slot0 after graduation, the curve's reserves before, 0 in between
onPoolWhether the factory reports NOIR's phase as PoolCreated
unlockCallbackPoolManager only (NotPoolManager)

The phases come from factory.getLaunchedToken(noir).phase: 0 not graduated (curve), 1 swept, 2 pool created. Before every trade, PonsSwapper calls graduate if the curve reports readyToGraduate(), and createGraduatedPool if the phase is swept. Both are permissionless on the Pons factory.

A curve quote is computed from getReserves(), feeBps(), creatorTaxBps() and, for buys, the opening currentSnipeTaxBps(recipient), capped at sellableTokens(). A pool quote runs the swap inside unlock and reverts with Quote(amountOut).

error NotPoolManager();
error Quote(uint256 amountOut); // internal: carries the quote out of the reverted swap

Interfaces

// the verifier
interface IVerifier {
    function verify(bytes calldata proof, bytes32[] calldata publicInputs) external view returns (bool);
}

// implemented by PrivateRouter, called by PrivateVault
interface ISaleRouter {
    function onPrivateExit(uint256 amount, address ethRecipient, uint256 minEthOut) external;
}

The contracts call these parts of Pons V2: on the factory getLaunchedToken, memeHook, graduate and createGraduatedPool; on the curve buy, sell, getReserves, sellableTokens, feeBps, creatorTaxBps, currentSnipeTaxBps, graduated, readyToGraduate and sweepFees; on the meme hook sweepPoolFees; on the fee escrow claim and balanceOf. getLaunchedToken returns the PonsLaunchedToken struct.

The ERC-4337 types are those of v0.8 (PackedUserOperation, IAccount, IEntryPoint), with the same ABI.

FallbackHasher is an optional Poseidon2 hasher that tries a Stylus program first and falls back to the EVM hasher. Deployments do not use it: they use the Yul hasher.

On this page