Overview
Reveal is a set of contracts that apply the same selling rules to every token launched through it.
The protocol does not custody funds, does not execute trades on your behalf, and does not decide who may sell. It computes, per position, how much may be released right now — and rejects anything above that.
Three rules do the work: a time-based unlock, relief that accelerates when a position is underwater, and a ramp on how large a single buy may be in the opening minutes.
None of them is a setting. The creator picks a name, a symbol and an image; the rules, the supply and the tick range live in the launcher, identical for every launch. Letting each creator choose how much they are constrained is not a constraint, and it makes two tokens incomparable.
Quickstart
A launch is a single transaction against the factory.
// Three strings. That is the whole surface.
//
// Supply, rules and the tick range are not arguments -- they live in the
// launcher, identical for every launch, with no function to change them.
// Nobody funds the pool either: the supply is placed one-sided, so the
// buyers' ETH becomes the liquidity. The creator pays gas and nothing else.
function launch(
string calldata name,
string calldata symbol,
string calldata metadataURI // image, description, links
) external returns (address token, address pool);
// The same launch, plus a buy for the creator in the same transaction --
// the first position on the curve, before the anti-sniper delay opens for
// anyone else. Capped at CREATOR_BUY_MAX_BPS of the supply, and locked on
// the ordinary schedule: earlier in, never earlier out.
function launchWithBuy(
string calldata name,
string calldata symbol,
string calldata metadataURI
) external payable returns (address token, address pool);One transaction deploys the token, creates its pool, seeds it, and arms the rules. There is never a moment where the token exists without its pool — so no window in which someone opens a competing pool or buys before the gates are live.
metadataURI is written once and has no setter. This interface writes the whole thing as a data: URI, image included, so a token displays from chain state alone — no IPFS pin, no server, nothing to keep paying for.
Launch lifecycle
What happens between deployment and a fully liquid market.
- 1 · Deploy
- The factory deploys the token, creates the pool, and writes the rules into immutable storage. Nothing can trade yet.
- 2 · Delay window
- For launchDelay seconds, buys revert. This removes the same-block advantage that lets a bot own the first print. One exception, and only one: a creator using launchWithBuy buys inside the launch block itself, capped at 5% of the supply.
- 3 · Buy ramp
- Trading opens with a cap on individual buy size that grows over buyRamp seconds until it disappears.
- 4 · Discovery
- Buys become positions. Every outgoing transfer -- to the pool or to another wallet -- is metered against that position, so pressure arrives spread out rather than at once.
- 5 · Steady state
- Once a position has passed unlockSeconds it is an ordinary ERC-20 balance. No cap, no window, no residual restriction.
Positions, not balances
The unit of accounting is the buy, not the wallet.
A wallet carries one locked tranche. A buy folds into it: what is still locked from before is added to the locked share of the new buy, and the clock restarts on the sum. Topping up therefore makes the locked remainder younger, which is what the rule should do — while everything already free stays free.
struct Position {
uint64 lockStart; // reset by every buy
int24 lockTick; // entry price of what is still locked
uint128 lockedBasis; // size whose (10_000 - unlockedBps) share is locked
}Note what is not stored: any record of what the position has already released. An earlier version kept a running releasedTotal and compared it to a recomputed budget — and that debt outlived the position. Exit almost entirely, leave a wei of dust, buy again, and the old debt was set against the new purchase: the buy started with nothing releasable, though the protocol promises 10% immediately.
The free amount is now a subtraction rather than a ledger, so there is no debt to outlive anything.
Computing the sellable amount
What you hold, minus what is still locked.
unlockedBps(holder) =
max( timeUnlockedBps(now - lockStart),
reliefBps(drawdownTicks(holder)) )
lockedOf(holder) =
min( lockedBasis * (10_000 - unlockedBps) / 10_000,
balanceOf(holder) )
releasable(holder) = balanceOf(holder) - lockedOf(holder)Which gives the guarantee the accounting exists for: after any buy of amount, releasable is exactly what it was before plus amount × initialUnlockBps / 10_000, whatever the holder's history.
Relief is a floor, not an addition. A position deep in drawdown never unlocks less than its schedule already permits, and a position in profit is never penalised for it. The min against the balance matters because relief can recede: a holder who sold into a crash and then saw the price recover would otherwise be locked above what they still hold.
Where the rules live
In the token's transfer hook, not only in the router.
If limits only existed in the launchpad's router, moving tokens to a second wallet or trading them on another pool would bypass everything. The check therefore sits in the token itself.
- The pool
- One address, written at launch and never changed. Tokens leaving toward it are a sell, and consume the position's unlock budget.
- Wallet to wallet
- Consumes the unlock budget exactly as a sale would, so splitting a position across ten addresses does not reset its schedule. What arrives is therefore already unlocked, and the recipient is not re-locked.
- Nothing is blocked outright
- There is no whitelist, no blocked destination, no pause and no admin. A transfer either fits inside what the position may release, or it reverts — and the receiving address opens a position of its own.
- Protocol fees
- Fees moving from the pool to the treasury skip the buy ramp, since collection is permissionless and must not depend on the clock. They do open a position, so the protocol is bound by its own selling rules.
Price oracle
Drawdown is measured against a time-weighted average, never spot.
This is the single most important implementation detail in the protocol. Reading spot price would let anyone crash the market for one block, unlock their entire position under maximum relief, and sell into the recovery.
// 5 minute TWAP, read from the pool's own tick accumulator.
// Ticks, not prices: 1.0001^n is not worth computing on chain.
(int24 reference, bool fresh) = twapTick(); // !fresh -> no relief at all
uint256 drop = ticksBelow(lockTick, reference);
uint256 relief = drop * 10_000 / 6_932; // 6932 ticks = a halvingEntry price is the opposite: recorded at spot, not TWAP. The average lags by minutes, so during a fast climb a buyer would be credited a price far below what they actually paid, would look permanently in profit, and would never receive the relief their real loss entitles them to.
Spot is safe in that direction. Inflating it to manufacture future relief means buying at the inflated price yourself — the loss is then real, and the relief earned.
A longer window costs responsiveness during a genuine crash; a shorter one lowers the cost of manipulating relief. Five minutes is a constant in the token, not a parameter, and is expected to be revisited before audit.
Unlock schedule
Basis points throughout, so 10000 bps equals 100%.
The schedule does not depend on position size — a whale and a small buyer unlock on the same curve. Nothing else separates them either: once a position is unlocked it is an ordinary ERC-20 balance. Making the schedule size-aware is an open design question, not a shipped feature.
Drawdown relief
A floor that rises continuously as a position goes under water.
Revealed
A milestone the pool reaches. Not a migration, and not a promise.
A launch past the threshold is shown as Revealed. The contracts call the same thing graduation — graduated, graduationProgress, GRADUATION_QUOTE — and those names are immutable, so an integration should expect them. The word differs on purpose: on most launchpads graduating is when liquidity migrates somewhere else, and borrowing the term would borrow the expectation.
At 4.2 ETH the launch is called graduated. That is the whole of it: the same token keeps trading in the same pool, at the same fee tier, against the same locked position, on the same ticks. No liquidity is withdrawn or re-minted, no reserves move, no second DEX is involved, and no permission or tax changes.
Anti-sniper
Rules that only apply to the opening minutes.
These reduce the advantage of being first; they do not remove it. Ordering ultimately belongs to whoever sequences the chain.
Dev buy
The one advantage the protocol hands to a named party.
A creator may buy their own token inside the launch transaction, through launchWithBuy. That means the first position on the curve, at the opening price, before the anti-sniper delay lets anyone else in. It is a real advantage and it is written down here rather than folded into a sentence about fair launches.
Deployment
Where the protocol actually is, so every claim on this page can be checked.
- RevealLauncher · 0x94d97C7AEc431b989132e3664b7cB3613CaC5b81
- Every launch goes through it. Holds the rules, the supply and the tick range, none of which it can change.
- RevealLocker · 0x9D223bd9ebae36a04Ce4c29a4bEE203d7EA1791e
- Deployed by the launcher, so its launcher() is necessarily the address above. Owns every position NFT and cannot give one back.
- Treasury · 0xa40679bC2f4f5B51Edb05E7A2D573292A3479c62
- Immutable, written into the locker's constructor. Every swap fee ends here and nowhere else; there is no setter.
- Uniswap V3 factory · 0x1f7d7550B1b028f7571E69A784071F0205FD2EfA
- Chain infrastructure, not ours. The launcher checks the position manager reports this same factory before accepting it.
- NonfungiblePositionManager · 0x73991a25C818Bf1f1128dEAaB1492D45638DE0D3
- Mints the position straight to the locker. The launcher's copy of its interface deliberately omits decreaseLiquidity, burn, approve and transferFrom.
- WETH (quote) · 0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73
- The only quote asset. Not the OP-stack predeploy — this is an Arbitrum Orbit chain and 0x4200…0006 carries no code here.
Chain id 4663. The constructor arguments and the code hashes of what is really in place are committed at contracts/deployments/4663.json, so a third party can contest the deployment without trusting this page.
Token metadata
The document a token carries, and what this interface will show of it.
metadataURI is an argument of launch, written once with no setter. So it is written by whoever launched the token — not necessarily by this form. Everything above is therefore enforced twice: as a bound when this interface writes a document, and as a filter when it reads one.
Interface
The surface an integration needs.
interface IRevealLauncher {
// Three strings. Supply, rules and tick range are not arguments.
function launch(string calldata name, string calldata symbol,
string calldata metadataURI)
external returns (address token, address pool);
// The same, plus the creator's own buy in that transaction.
function launchWithBuy(string calldata name, string calldata symbol,
string calldata metadataURI)
external payable returns (address token, address pool);
// The registry. Readable before any token exists, which is what lets a
// form state the cap while it is being filled in.
function creatorBuyCap() external view returns (uint256);
function tokenCount() external view returns (uint256);
function tokens(uint256 index) external view returns (address);
function rules() external view returns (Rules memory);
function locker() external view returns (address);
}
interface IRevealToken {
// What may leave right now -- to the pool or to another wallet.
function releasable(address holder) external view returns (uint256);
// Its complement. releasable + lockedOf == balanceOf, always.
function lockedOf(address holder) external view returns (uint256);
// The largest buy the ramp allows at this instant, and when buys open.
function maxBuyNow() external view returns (uint256);
function buyOpensAt() external view returns (uint256);
// How open the position is, and how far under water.
function unlockedBps(address holder) external view returns (uint256);
function drawdownTicks(address holder) external view returns (uint256);
function twapTick() external view returns (int24 tick, bool fresh);
// The creator's window. Zero from the block after the launch onwards.
function creator() external view returns (address);
function creatorBought() external view returns (uint256);
function creatorBuyRemaining() external view returns (uint256);
function rules() external view returns (Rules memory);
function metadataURI() external view returns (string memory);
}
interface IRevealLocker {
// Status milestone. Nothing here moves liquidity.
function graduationProgress(address token) external view returns (uint256);
function graduated(address token) external view returns (bool);
function syncGraduation(address token) external;
// Permissionless, and always pays the immutable treasury.
function collect(address token) external returns (uint256, uint256);
// The invariants worth checking yourself.
function positionOwner(address token) external view returns (address);
function liquidityNow(address token) external view returns (uint128);
}releasable is the call a front end must make before enabling a sell button — and it is not optional. Uniswap wraps token transfers in its own _safeTransfer, so a rejected sell surfaces as the pool's TF, never as our custom error. Reading the failure afterwards tells the user nothing; reading the view beforehand tells them the exact amount that works. maxBuyNow is its counterpart on the buy side, for the same reason.
sellableNow still exists as an alias of releasable, kept for integrations written against the earlier interface. There used to be two limits to reconcile; there is now one number.
fresh is false while the pool has no oracle history to average. Relief returns zero in that state rather than falling back to spot, because spot relief would pay anyone who crashes the price for a single block.
Events
What an indexer should listen to.
- Launched(address token, address creator, address pool, uint256 tokenId, uint256 supply, uint128 liquidity, int24 tickLower, int24 tickUpper, Rules rules)
- Emitted once per launch, by the launcher. tokenId is the Uniswap V3 position NFT, minted straight to the locker. Name, symbol and metadataURI are not repeated here — they are read from the token, which keeps the event off the compiler's stack limit.
- CreatorBought(address token, address creator, uint256 quoteIn, uint256 tokensOut)
- The creator took the first position inside their own launch transaction. Emitted only when that happened, so a launch that bought its own float is distinguishable from one that did not without reading pool transfers.
- Entry(address holder, uint256 amount, uint64 lockStart, int24 lockTick)
- A position acquired tokens from the pool. Carries the merged tranche's state, so an indexer never has to recompute it. A plain incoming transfer emits nothing here: what left the sender was already unlocked, so it arrives unlocked.
- Exit(address holder, uint256 amount, uint256 unlockedBps, bool viaPool)
- A position let tokens out, and how open it was at that moment. viaPool separates a sell from a plain transfer — both consume the same unlock budget.
- Registered(address token, address pool, uint256 tokenId, int24 tickLower, int24 tickUpper, uint128 liquidity)
- The locker took permanent ownership of the position. Emitted once per launch, by the locker.
- Collected(address token, uint256 quoteToTreasury, uint256 tokensToCreator)
- Swap fees were materialised and paid out — the quote side to the immutable treasury, the token side to the launch's creator. Named by recipient rather than by amount0/amount1, which forced every reader to work out which of the two was the quote. Anyone may trigger it, neither destination can be redirected, and the position's liquidity is unchanged by construction.
- Graduated(address token, address pool, uint256 quoteAmount)
- The locked position crossed GRADUATION_QUOTE and someone recorded it. Emitted at most once per token. Nothing moved.
Errors
Custom errors, so a failed trade explains itself.
- LaunchDelayActive(uint256 opensAt)
- A buy arrived before launchDelay elapsed. Returns when it opens.
- BuyTooLarge(uint256 maxBuy)
- The buy exceeds what the ramp allows at this point. Returns the size that would pass.
- CreatorBuyTooLarge(uint256 remaining)
- The creator's buy would carry their launch-block total past CREATOR_BUY_MAX_BPS. Returns what is left under the cap. It fails the whole launch, not just the buy — a half-launched token would be worse.
- PositionLocked(uint256 releasable)
- The position may not release this much yet. Returns the amount that would succeed — offer that rather than a bare failure.
- StringTooLong()
- Name, symbol or metadataURI is empty or past its bound — 64, 16 and 16384 bytes. Without a bound, a launch could cost arbitrary gas and the token would be unreadable to any indexer.
- OnlyLauncher() · AlreadyInitialized()
- Arming the rules is callable once, by the launcher that deployed the token. There is no second path into that state.
- NotGraduatedYet(uint256 progress) · AlreadyGraduated()
- syncGraduation was called below the threshold, or a second time. Returns the progress it measured so the caller can see how far off it is.
- QuoteWasSpent(uint256) · SupplyNotDeposited(uint256,uint256) · LiquidityMismatch(uint128,uint128) · WrongInitialTick(int24,int24)
- A launch did not produce exactly what it must: quote was consumed, the supply did not land, the minted liquidity differs from the derived value, or the pool did not open at the intended tick. Each aborts the whole launch rather than leaving a pool with the wrong curve.
Fees
Nothing at launch, and the pool's own fee tier on trades.
- Launch cost
- Gas only. Nobody advances capital — not the creator, not the protocol. The whole supply is placed on one side of a tick range, so the pool starts with zero quote and the buyers' ETH becomes the liquidity.
- Liquidity
- The Uniswap V3 position NFT is minted straight to RevealLocker and never belongs to anyone else -- not the creator, not the deployer, not the treasury, not an EOA. The locker cannot decrease liquidity, burn, approve or transfer it: those functions are absent from the interface it holds, not merely guarded. There is no owner, no rescue path and no upgrade path.
- Trade fee
- The Uniswap V3 fee tier of the pool, accruing to that locked position. collect(token) is permissionless: anyone can trigger it, nobody can redirect it, and the locker never holds funds between calls.
- Split by side, not by percentage
- Uniswap charges its fee on whichever asset goes in, so a buy pays in ETH and a sell pays in the token. collect sends the quote side to the immutable treasury and the token side to whoever launched it — two calls in one transaction, each with a different recipient. The share follows what was actually earned rather than a number decided in advance.
- What a creator receives
- Tokens, in proportion to how much of their own token was sold. They arrive straight from the pool and open an ordinary position: a tenth sellable at once, all of it after unlockSeconds, and a collection re-ages the locked remainder exactly as a repurchase would. A creator is paid earlier than others are, never freer.
- Nobody has to claim for the treasury
- Because one call pays both recipients, a creator collecting their own share pays the protocol in the same transaction. The treasury has nothing to trigger and no schedule to keep.
- No sell penalty
- Selling is never taxed more heavily than buying. Asymmetric taxes push holders toward the exits they can still use, which is the opposite of the intent here.
Indexing
Reconstructing state from events.
Positions are the unit of state worth indexing. Track Entry and Exit, then recompute sellable amounts rather than storing them — they change with every block through elapsed time and price.
// derived, never stored -- or just ask the contract
const sellable = await token.read.releasable([holder]);An indexer is not required to display a token: name, symbol and metadataURI are all readable from a plain RPC node. It is required for anything historical — yesterday's price, a holder count, a volume chart. A node answers the present only.
This interface runs one, and it holds no database. Filtering eth_getLogs on a single pool address returns that pool's entire history in one call, so volume, trade count and the price curve are recomputed from the Swap logs on request and cached for thirty seconds. Holders come the same way: an ERC-20 cannot enumerate its own holders, so the Transfer logs are replayed into balances.
# every swap the pool ever emitted, in one request
curl "$RPC" -d '{"method":"eth_getLogs","params":[{
"address":"<pool>","fromBlock":"0x0","toBlock":"latest"}]}'The node caps a query at 10 000 matched logs, which the reader handles by halving the block range and retrying. That keeps working as pools age, but it stops being one request — a pool busy enough eventually costs several, and the answer arrives more slowly.
Known limits
Where the protocol stops helping.
- Multi-wallet splitting
- Buying through many wallets gives each one its own independent position and its own schedule. The aim is to make that costly and visible, not impossible -- any real fix would require identity, which this protocol will not do. Once a balance is fully unlocked, splitting it across wallets is not an exploit: it is an ordinary ERC-20 doing what one does.
- MEV
- Sandwiching and front-running are reduced by the opening delay and buy ramp, never eliminated. Ordering belongs to the sequencer.
- Oracle manipulation
- A sufficiently capitalised actor can move a 5 minute TWAP. Deeper liquidity raises that cost more than any parameter here — and since buyers are the liquidity, it deepens as a launch succeeds.
- Immutability cuts both ways
- Nothing can be fixed after deployment, and since the rules are shared, a badly chosen value applies to every launch rather than one. Correcting it means deploying a new launcher; the tokens already out keep the old rules forever.
- Errors do not survive the pool
- Uniswap wraps transfers, so a refused sell reaches the user as TF, not as PositionLocked. Any interface that skips the releasable view will show its users a failure it cannot explain.
- The creator gets the first position
- launchWithBuy lets a creator buy up to 5% of the supply inside the launch block, before the anti-sniper delay opens for anyone else. Whoever buys next pays a price the creator already moved. The lock applies to them identically — it costs later buyers position, not protection — but it is an asymmetry, and it is not going to be argued away here.
- History is recomputed, not stored
- The indexer keeps no database: every figure is rebuilt from logs on request. That means nothing to fall out of sync, and no historical claim that cannot be re-derived from the chain — but it also means the cost grows with a pool's history, and that a node refusing to serve logs takes the whole history down with it. The live price never depends on it.
