A concentrated position needs both tokens of a pair, in a ratio set by where the price sits inside your range. A one-token deposit does the balancing for you. You send one token or ETH, and Levee's zap contract sells the share your range does not want on the same pool, mints the position to your wallet and sends back whatever is left, all in one transaction. You reach it from the Liquidity mode of the ticket on the Trade page; it works with Uniswap v3 pools (LeveeZapV3) and Uniswap v4 pools (LeveeZapV4).
One transaction, start to finish
You choose the token, the amount and a range. The app turns the range into two ticks, shows the expected split and sends zapMint. The contract then runs these steps in order and reverts at the first one that fails, so a failed deposit costs gas and nothing else.
- 01Check the requestA passed
deadlineisDeadline, a zeroamountInisZeroAmount, and atickLowerthat is not belowtickUpperisBadRange. On v3 the pool is looked up withfactory.getPool(token0, token1, fee), in either token order; no pool isNoPool. - 02Take the inputAn ERC-20 is pulled with
transferFrom, so the zap needs an approval first; the app asks for exactly the amount. On v3, ETH must arrive asmsg.valueequal toamountInand is wrapped to WETH, which must be one of the pool's tokens. A token outside the pool, or ETH sent alongside an ERC-20, isBadTokenIn. - 03Take the fee
ZAP_FEE_BPS(25) ofamountIngoes to theFeeRouter, booked against your address so that a referrer you have bound earns 20 % of it. The rest is what gets deposited. - 04Size the swap
ZapMathreads the price fromslot0, decides which token the range has too much of and how much of it to sell. If the answer is zero, the swap is skipped. - 05Swap on the same poolOne
exactInputSingleon the pool you are entering, with a minimum outputmaxSlippageBpsbelow the spot quote. The router's allowance is set to the exact amount and cleared straight after. - 06Mint to you
npm.mintoffers the balanced amounts, accepts as little asmaxSlippageBpsbelow each, and makesrecipientthe owner of the new NFT. - 07Check the resultLess liquidity than
minLiquidityisSlippage. - 08Return the restThe contract reads its own balance of both tokens and sends all of it to
msg.sender. On v3, if you paid in ETH, the WETH side is unwrapped first; a wallet that refuses ETH getsNativeTransferFailed. Last,ZapMintis emitted.
LeveeZapV3.zapIncrease adds to a v3 position you already hold by the same steps. It reads the pool and the ticks from the position itself, and only the owner, the address approved for that NFT, or an operator may call it (NotOwner otherwise). The app does not use it today.
How the swap is sized
Two cases need no search. If your range sits wholly above the price, the position holds only token0, so all token1 is sold; if it sits wholly below, the position holds only token1, so all token0 is sold. When the price is inside the range, the position needs both. Selling more of the excess token lowers the liquidity that leg can support and raises what the other leg can support, and the mint gets the smaller of the two, so the best amount is where they meet. ZapMath.optimalSwapAmountFrom finds it with a binary search of 32 halvings (ITERATIONS), which pins the amount to better than one part in four billion.
The search assumes the whole swap fills at the current price, less the pool fee: amount × (1 − fee) × price. A real swap moves the price, so it returns slightly less, the two legs end up slightly out of balance, and the mint leaves a remainder behind. That remainder is the refund in the last step; it comes back in whichever token it sits in and is not swapped again. The Liquidity mode shows its expected size as a share of your input.
previewZap is a view that returns the exact swap amount zapMint would use against the current pool state, with the holdings the model expects after it. The app computes the same split locally with zapPreview from @levee/core, which is tested against fixtures generated from the Solidity library (contracts/fixtures/zap.json).
Picking a range
The contract only sees two ticks. The shapes in the Liquidity mode come from @levee/core (shapes.ts) and are turned into ticks before anything is signed:
| Shape | Band around the current price |
|---|---|
SPOT | 5 % below to 5 % above |
CURVE | 25 % below to 25 % above |
WIDE | 50 % below to 50 % above |
| Custom | the prices you type or drag |
For a preset, each edge becomes a tick with ln(1 ± pct) / ln 1.0001 and is rounded outward to the pool's tick spacing, down for the lower tick and up for the upper, so the band covers at least what the preset promises. Ticks are logarithmic, so ±5 % is about 488 ticks up and 513 down. Custom prices snap to the nearest usable tick instead. Either way the range keeps at least one spacing of width.
For a tokenized stock, presets double both edges (AFTER_HOURS_MULT = 2) while the NYSE is closed: outside 09:30 to 16:00 New York time on weekdays, after 13:00 on early-close days, and on the exchange holidays listed for 2026 and 2027. The panel then reads "Widened after hours". The stock token keeps trading on chain while its reference price stands still, then jumps at the next open; a band sized for the trading day would be left behind. Custom ranges are never widened.
The three checks on your deposit
maxSlippageBps drives two of the three checks, and they measure different things.
- Swap output. The router refuses to fill below the spot quote less
maxSlippageBps. This bounds what your own swap loses to price impact. On v3 it surfaces as the router's "Too little received", on v4 asSlippage. - Mint amounts. On v3 the mint must take at least the balanced amounts less
maxSlippageBps; the position manager reverts with "Price slippage check" otherwise. On v4 the same figure caps what the mint may take (see below). This measures how far the holdings sit from the ratio the range wants after the swap has moved the price, and it tightens as the range narrows. - Liquidity floor.
minLiquidityis what the whole deposit must produce. The app sets it to the previewed liquidity less your slippage. Like the contract, the preview takes the 25 bps fee off your input before it splits it, so the whole margin is left for the price moving.
Both zaps tie the mint check to the swap figure. A large deposit into a narrow range near the edge of the price can therefore move the price far enough that the mint check fails at 1 %. If that happens, widen the range, deposit less or raise the slippage. Levee's vaults, which mint into narrow ranges for you, keep a separate mint tolerance of 500 bps (MINT_RATIO_TOLERANCE_BPS). Both zaps refuse a slippage above 5 000 bps (MAX_SLIPPAGE_BPS) with SlippageTooHigh, so no call can switch the two checks off.
In the app the default is 100 bps (DEFAULT_SLIPPAGE_BPS.zap) with presets of 50, 100 and 200. Above 500 bps (MAX_SLIPPAGE_WITHOUT_ACK_BPS) you must acknowledge the risk before the button unlocks. The app sets the deadline 600 s ahead.
Cost
You pay 25 bps of the input, in the input token, and nothing on the way out. The FeeRouter passes 20 % of it to your referrer if you have bound a code; the rest goes to the FeeCollector, which turns it into ETH for the $LEVEE buyback (see Fees and referrals). The pool charges its usual fee on the swapped part only: on a 0.30 % pool, a deposit that swaps half its input pays about 0.15 % of the input to the pool.
On Uniswap v4
LeveeZapV4.zapMint gives the same result through v4's plumbing: one PoolManager for every pool, swaps inside an unlock callback, and a PositionManager that pulls ERC-20s through Permit2.
- The pool is named by its full
PoolKey(currencies, fee, tick spacing, hooks), andhookDatais passed to the hooks on both the swap and the mint. The zap does not look the pool up; a key for a pool that does not exist fails inside Uniswap. - ETH is never wrapped. It is accepted only when one side of the pool is native ETH, and its fee goes through
feeRouter.takeNative. - The fee the model uses comes from
slot0when the pool has a dynamic fee, and from the key otherwise. - The swap runs inside
poolManager.unlock. The callback only answers the pool manager (NotPoolManager), sells exact input at the widest price limit, and revertsSlippageif the output is below the minimum. - The mint is a
MINT_POSITIONthenSETTLE_PAIRbatch, plusSWEEPwhen one side is native so unspent ETH comes back. v4 mints a liquidity figure against maximum amounts, so the tolerance works upward: each maximum is the amount that liquidity needs plusmaxSlippageBpsand 1 wei, never more than the contract holds. Holdings that support no liquidity revertSlippage. - Approvals.
approveOnce(token)sets a standing ERC-20 approval to Permit2 and a Permit2 allowance to the position manager. Anyone may call it, and calling it twice changes nothing. Standing approvals are safe here only because the contract never holds a balance between calls. The v3 zap sets every allowance per call and clears it afterwards. - No top-up. There is no
zapIncreaseon v4.
The contract's empty balance is checked by invariant tests: invariant_zapHoldsNothing and invariant_zapKeepsNoStandingApprovals for v3, invariant_zapV4HoldsNothing for v4.
Reference
Call parameters
| Name | Allowed | Default | Effect |
|---|---|---|---|
tokenIn | token0, token1 or address(0) | - | The token you send. address(0) is ETH, wrapped to WETH on the way in. |
amountIn | above 0 wei of tokenIn | - | Before the fee. Must equal msg.value when paying in ETH. |
token0, token1 | the pool tokens, any order | - | With fee, find the pool through the factory. |
fee | 100 · 500 · 3000 · 10000 hundredths of a bip | - | The pool fee tier, also paid on the swap. |
tickLower, tickUpper | lower below upper, on the spacing ticks | - | The range. Ticks off the spacing are rejected by the position manager. |
minLiquidity | 0 or more liquidity | preview less slippage | The least liquidity the mint may produce. |
maxSlippageBps | 0 - 10 000 bps | 100 | Bound on the swap output and on the mint amounts. 10 000 turns both off. |
deadline | unix seconds | now + 600 | Latest block time; also passed to the position manager. |
recipient | any address | - | Owner of the new NFT. Leftovers go to msg.sender, not here. |
ZapIncreaseParams keeps only what the position does not already fix: tokenId, tokenIn, amountIn, minLiquidity, maxSlippageBps, deadline. ZapMintV4Params replaces token0, token1 and fee with key (the PoolKey) and adds hookData.
Reverts
| Revert | Cause |
|---|---|
Deadline | The block is later than deadline. |
ZeroAmount | amountIn is zero, or ETH was chosen and msg.value differs from amountIn. |
BadRange | tickLower is not below tickUpper. |
NoPool | v3: no pool for the pair and fee. |
BadTokenIn | tokenIn is not a pool token, or ETH came with an ERC-20 deposit. |
Slippage | Less liquidity than minLiquidity. On v4 also a swap below its minimum, or holdings that support no liquidity. |
NotOwner | v3 zapIncrease from an address that is not the owner, approved or an operator. |
NativeTransferFailed | Your wallet refused the ETH refund. |
NotPoolManager | v4: the unlock callback was called by anything but the pool manager. |
Two more come from Uniswap: the router's "Too little received" when the swap check fails, and the position manager's "Price slippage check" when the mint check fails. The app simulates every write before it opens your wallet, so a deposit that would revert normally stops there, at no cost.
Emitted events
ZapMint(recipient, tokenId, liquidity, amount0, amount1, feePaid) on v3 and ZapMintV4 with the same fields on v4; ZapIncrease(tokenId, liquidity, amount0, amount1, feePaid) for a top-up. amount0 and amount1 are what the mint took, not what you sent, and feePaid is the 25 bps in the input token. The indexer does not watch the zap contracts. A zapped position reaches Portfolio through Uniswap's own position events, like any other position.
contracts/src/LeveeZapV3.solzapMint, zapIncrease, previewZap, the intake and the refundcontracts/src/LeveeZapV4.solzapMint, approveOnce, unlockCallback and the PositionManager batchcontracts/src/libraries/ZapExec.solbalance, balanceAndMint, balanceAndIncrease and floorBps: the swap and mint checkscontracts/src/libraries/ZapMath.soloptimalSwapAmountFrom, the 32-step search and quoteAtSpotpackages/core/src/zapMath.tszapPreview, the TypeScript port the app previews withpackages/core/src/shapes.tsSHAPE_PCT, rangeForPct and the after-hours wideningpackages/core/src/slippage.tsDEFAULT_SLIPPAGE_BPS, MAX_SLIPPAGE_WITHOUT_ACK_BPS, minOutapps/web/lib/trade/provide.tsrangeForProvide, rangeFromPrices and providePreviewcontracts/test/invariants/ZapHoldsNothing.t.solthe v3 holds-nothing and no-standing-approvals invariantscontracts/test/invariants/ZapV4HoldsNothing.t.solthe v4 holds-nothing invariant