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.
| Contract | Role |
|---|---|
PrivateVault | Shielded NOIR, notes, keys, vault shares, price average, ERC-4337 account |
PrivateRouter | Private buys and sales in one transaction, quotes |
FeeHarvester | NOIR's Pons creator fee recipient; turns the creator fees into gas and holder yield |
PonsSwapper, PonsMarket | Trading and pricing on the Pons curve or the graduated pool, shared by the router and the harvester |
PoseidonMerkleTree | The 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.caller | ext.data |
|---|---|
address(0) | anything; anyone may submit the transaction to transact |
PrivateRouter | abi.encode(address ethRecipient, uint256 minEthOut) for sell |
| the vault itself | abi.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);| Function | Description |
|---|---|
register | Registers the caller's owner key and viewing key. opk must be non-zero and below the field modulus. Calling it again replaces the keys |
registerFor | Same for owner, with an EIP-712 signature (EOA or ERC-1271) that anyone can submit. Reverts SignatureExpired after deadline |
keysOf | The registered keys; opk == 0 means not registered |
registerNonces | Next 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);| Function | Description |
|---|---|
shield | Pulls 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 |
donate | Pulls 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 |
absorb | Anyone. 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 |
transact | Spends 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 |
nullifierSpent | Whether a nullifier has been used |
publicInputs | The 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 |
extDataHash | keccak256(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);| Function | Description |
|---|---|
root | Current root |
isKnownRoot | True for any of the last 64 roots and for the final root of every full tree; false for 0 |
nextLeafIndex | Next free leaf in the current tree. Leaves are always inserted in pairs, so it is even |
treeNumber | Number of full trees before the current one |
isFinalRoot | Whether a root is the final root of a full tree (valid forever) |
hasher | The 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);| Function | Description |
|---|---|
validateUserOp | EntryPoint 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) |
execute4337 | EntryPoint 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 |
claimSale | Anyone. If a sale's execution ran out of gas, transfers its NOIR to the ETH recipient. Reverts NoSuchSale if none is pending |
addStake | Anyone can add EntryPoint stake for the vault. Nothing can unlock it |
pendingSales | Sales validated but not settled, keyed by first nullifier |
pendingSaleTotal | Sum of the pending sales' NOIR |
The operation must satisfy all of these, or validation reverts with InvalidUserOp or WrongCaller:
ext.calleris the vault itself;- no
initCodeand nopaymasterAndData; callDatais exactlyexecute4337(t.nullifiers[0]);nonceisuint192(t.nullifiers[0]) << 64(one nonce key per first nullifier);callGasLimitis at least theminCallGasLimitinext.data;- the gas fee, valued at the price average, covers the operation's maximum cost plus 20%
(
InsufficientGasFeeotherwise,NoPricebefore 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);| Function | Description |
|---|---|
poke | Anyone. 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 |
tokensPerEthEma | Moving average of the price, NOIR per ETH scaled by 1e18. 0 before the first price |
lastSpot / lastPriceBlock | Price at the latest update and its block; it enters the average when a later block starts |
gasReserve | NOIR collected as gas fees, waiting for the harvester |
releaseGasReserve | Harvester 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| Event | When |
|---|---|
KeysRegistered | register or registerFor |
NoteAdded | A leaf from shield or transact, with its encrypted note data |
Shielded | shield: from is who shielded (the user, or the router for a purchase), index the leaf it created, shares the note's share amount |
Exited | NOIR left the private side to recipient; nullifier is the transaction's first nullifier |
NullifierSpent | Twice per private transaction, once per nullifier |
Donation | donate: harvested creator fees and gas-reserve surplus, from the harvester; absorb: NOIR sent by plain transfer, from the zero address |
GasFeeCollected | NOIR added to the gas reserve by a private transaction |
PriceUpdated | The price average changed |
SaleSettled | An ERC-4337 sale settled: swapped is false if the NOIR went to the recipient instead |
VaultUpdated | totalBacking and totalShares after every change |
TreeCompleted | A 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
| Error | Cause |
|---|---|
ZeroAddress | Constructor with no NOIR or verifier, or an exit with no recipient |
NotAFieldElement | A stub, commitment, nullifier, exit amount or opk at or above the field modulus (or opk == 0) |
ZeroShares | An amount too small to mint a single share |
UnknownRoot | The proof's root is not in the root history nor a final root |
NullifierAlreadySpent / DuplicateNullifier | A note already spent, or the same nullifier twice in one transaction |
InvalidProof | The verifier rejected the proof (direct transact only; ERC-4337 returns a failed signature instead) |
WrongCaller | ext.caller is set and is not msg.sender (or not the vault, for ERC-4337) |
FeeExceedsExit | ext.gasFee > exitShares |
InvalidUserOp | A UserOperation that breaks one of the rules above |
InsufficientGasFee | The gas fee does not cover the maximum cost plus the margin |
NoPrice | No price average yet |
NoSuchSale | claimSale for a sale that is not pending |
InvalidSignature / SignatureExpired | registerFor signature invalid or past its deadline |
NotEntryPoint / NotHarvester | Restricted 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| Function | Description |
|---|---|
buy | Spends 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 |
sell | Spends 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) |
onPrivateExit | Vault only (NotVault). Settles an ERC-4337 sale: pulls the amount NOIR the vault has just approved and sells it |
quote | Output 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, belowThe 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;| Function | Description |
|---|---|
poolKey / poolId | The 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 |
spotPrice | NOIR per ETH, 1e18 scale, at the live venue: the pool's slot0 after graduation, the curve's reserves before, 0 in between |
onPool | Whether the factory reports NOIR's phase as PoolCreated |
unlockCallback | PoolManager 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 swapInterfaces
// 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.